383 lines
15 KiB
Python
383 lines
15 KiB
Python
"""批次 A(P0 前端配套)后端补口测试。
|
||
|
||
覆盖:
|
||
1. 商品可用单位接口 `GET /catalog/products/<id>/units/`(含换算率与按 rate 的销售价)
|
||
2. 销售开单动作接口 `create-bill` / `confirm`(含 402 信用超限、最低售价 400)
|
||
3. 采购开单动作接口 `create-bill`(批次号/到期日穿透)/ `confirm`
|
||
4. 对账单客户侧 HTML(匿名可访问、含金额、可打印)
|
||
5. 对账单分享吊销接口
|
||
6. 打印渲染器 `{{#if}}`/`{{else}}` 与批次号列
|
||
"""
|
||
|
||
import pytest
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
from model_bakery import baker
|
||
from rest_framework.test import APIClient
|
||
from rest_framework_simplejwt.tokens import RefreshToken
|
||
|
||
from apps.catalog.models import Product, Unit, UnitConversion
|
||
from apps.inventory.models import Stock, StockBatch, Warehouse
|
||
from apps.partner.models import Customer, Supplier
|
||
from apps.finance.models import StatementShare
|
||
from apps.printing.renderer import render_template
|
||
|
||
|
||
@pytest.fixture
|
||
def auth_client(db, user, tenant):
|
||
c = APIClient()
|
||
refresh = RefreshToken.for_user(user)
|
||
c.credentials(
|
||
HTTP_AUTHORIZATION=f"Bearer {refresh.access_token}",
|
||
HTTP_X_TENANT_ID=tenant.code,
|
||
)
|
||
return c
|
||
|
||
|
||
@pytest.fixture
|
||
def anon_client(db):
|
||
"""同步匿名客户端(覆盖 conftest 的 async 版本,便于在同步用例里断言匿名访问)。"""
|
||
return APIClient()
|
||
|
||
|
||
@pytest.fixture
|
||
def warehouse(db, tenant):
|
||
return baker.make(Warehouse, tenant=tenant, code="WH01", name="主仓")
|
||
|
||
|
||
@pytest.fixture
|
||
def product(db, tenant):
|
||
"""基本单位"瓶",换算"箱"=24,默认售价 5。"""
|
||
base = baker.make(Unit, tenant=tenant, code="bottle", name="瓶", is_base=True)
|
||
p = baker.make(Product, tenant=tenant, code="P001", name="可乐",
|
||
sale_price=Decimal("5"), base_unit=base)
|
||
box = baker.make(Unit, tenant=tenant, code="box", name="箱", is_base=False)
|
||
UnitConversion.objects.create(tenant=tenant, product=p, unit=box, rate=Decimal("24"))
|
||
return p
|
||
|
||
|
||
@pytest.fixture
|
||
def batch_product(db, tenant):
|
||
p = baker.make(Product, tenant=tenant, code="B001", name="鲜奶",
|
||
sale_price=Decimal("10"), is_batch_managed=True,
|
||
shelf_life_days=30)
|
||
return p
|
||
|
||
|
||
@pytest.fixture
|
||
def customer(db, tenant):
|
||
return baker.make(Customer, tenant=tenant, code="C001", name="张三商店",
|
||
credit_limit=Decimal("0"))
|
||
|
||
|
||
@pytest.fixture
|
||
def supplier(db, tenant):
|
||
return baker.make(Supplier, tenant=tenant, code="S001", name="上游厂")
|
||
|
||
|
||
def _stock_in(tenant, warehouse, product, qty, unit_cost="2"):
|
||
from apps.inventory import services as inv_services
|
||
inv_services.inbound(
|
||
tenant=tenant, warehouse=warehouse, product=product,
|
||
quantity=Decimal(str(qty)), unit_cost=Decimal(unit_cost),
|
||
)
|
||
|
||
|
||
# ---------- 1. 商品可用单位 ----------
|
||
|
||
|
||
def test_product_units_api(db, auth_client, product):
|
||
resp = auth_client.get(f"/api/v1/catalog/products/{product.id}/units/")
|
||
assert resp.status_code == 200, resp.content
|
||
body = resp.json()
|
||
assert body["base_unit_name"] == "瓶"
|
||
assert Decimal(body["sale_price"]) == Decimal("5")
|
||
assert body["is_batch_managed"] is False
|
||
units = {u["unit_name"]: u for u in body["units"]}
|
||
assert Decimal(units["瓶"]["rate"]) == Decimal("1")
|
||
assert Decimal(units["箱"]["rate"]) == Decimal("24")
|
||
# 箱价 = 基本价 × 24
|
||
assert Decimal(units["箱"]["price"]) == Decimal("120")
|
||
assert units["箱"]["is_base"] is False
|
||
|
||
|
||
# ---------- 2. 销售开单/过账 ----------
|
||
|
||
|
||
def test_sales_create_bill_with_unit_and_round_off(
|
||
db, auth_client, tenant, warehouse, customer, product
|
||
):
|
||
"""2 箱 × 单价 120.5(录入单位计价)+ 抹零,落库金额与基本单位数量正确。"""
|
||
_stock_in(tenant, warehouse, product, 100)
|
||
box = UnitConversion.objects.get(product=product).unit
|
||
resp = auth_client.post("/api/v1/sales/bills/create-bill/", {
|
||
"customer": customer.id,
|
||
"warehouse": warehouse.id,
|
||
"round_to": "1",
|
||
"lines": [{
|
||
"product": product.id, "quantity": 2, "unit_price": "120.5",
|
||
"source_unit": box.id,
|
||
}],
|
||
}, format="json")
|
||
assert resp.status_code == 201, resp.content
|
||
body = resp.json()
|
||
assert body["state"] == "draft"
|
||
# 241.0 抹到元 → 净额 241,抹零额…241 已是整数,故用 120.6 让结果非整
|
||
assert Decimal(body["total_amount"]) == Decimal("241")
|
||
line = body["lines"][0]
|
||
assert Decimal(line["quantity"]) == Decimal("48") # 2 × 24
|
||
assert Decimal(line["source_quantity"]) == Decimal("2")
|
||
assert line["unit_name"] == "箱"
|
||
|
||
|
||
def test_sales_create_bill_round_off_amount(
|
||
db, auth_client, tenant, warehouse, customer, product
|
||
):
|
||
"""抹零落到分/角/元三档:241.36 → 抹元 241(抹零 0.36)。"""
|
||
_stock_in(tenant, warehouse, product, 100)
|
||
resp = auth_client.post("/api/v1/sales/bills/create-bill/", {
|
||
"customer": customer.id, "warehouse": warehouse.id, "round_to": "1",
|
||
"lines": [{"product": product.id, "quantity": 2, "unit_price": "120.68"}],
|
||
}, format="json")
|
||
assert resp.status_code == 201, resp.content
|
||
body = resp.json()
|
||
assert Decimal(body["total_amount"]) == Decimal("241")
|
||
assert Decimal(body["round_off"]) == Decimal("0.36")
|
||
|
||
|
||
def test_sales_create_bill_auto_price_when_missing(
|
||
db, auth_client, tenant, warehouse, customer, product
|
||
):
|
||
"""不传单价 → 自动取默认售价(无录入单位时按基本单位价)。"""
|
||
_stock_in(tenant, warehouse, product, 100)
|
||
resp = auth_client.post("/api/v1/sales/bills/create-bill/", {
|
||
"customer": customer.id, "warehouse": warehouse.id,
|
||
"lines": [{"product": product.id, "quantity": 3}],
|
||
}, format="json")
|
||
assert resp.status_code == 201, resp.content
|
||
line = resp.json()["lines"][0]
|
||
assert Decimal(line["unit_price"]) == Decimal("5")
|
||
assert Decimal(line["amount"]) == Decimal("15")
|
||
|
||
|
||
def test_sales_create_bill_below_min_price_400(
|
||
db, auth_client, tenant, warehouse, customer, product
|
||
):
|
||
product.min_sale_price = Decimal("8")
|
||
product.save()
|
||
resp = auth_client.post("/api/v1/sales/bills/create-bill/", {
|
||
"customer": customer.id, "warehouse": warehouse.id,
|
||
"lines": [{"product": product.id, "quantity": 1, "unit_price": "5"}],
|
||
}, format="json")
|
||
assert resp.status_code == 400
|
||
body = resp.json()
|
||
assert body["code"] == "below_min_price"
|
||
assert body["product_code"] == "P001"
|
||
|
||
|
||
def test_sales_create_bill_below_min_price_allowed(
|
||
db, auth_client, tenant, warehouse, customer, product
|
||
):
|
||
"""行带 allow_below_min=true → 审批放行。"""
|
||
product.min_sale_price = Decimal("8")
|
||
product.save()
|
||
_stock_in(tenant, warehouse, product, 10)
|
||
resp = auth_client.post("/api/v1/sales/bills/create-bill/", {
|
||
"customer": customer.id, "warehouse": warehouse.id,
|
||
"lines": [{"product": product.id, "quantity": 1, "unit_price": "5",
|
||
"allow_below_min": True}],
|
||
}, format="json")
|
||
assert resp.status_code == 201, resp.content
|
||
|
||
|
||
def test_sales_confirm_bill_api_deducts_stock(
|
||
db, auth_client, tenant, warehouse, customer, product
|
||
):
|
||
_stock_in(tenant, warehouse, product, 10)
|
||
create = auth_client.post("/api/v1/sales/bills/create-bill/", {
|
||
"customer": customer.id, "warehouse": warehouse.id,
|
||
"lines": [{"product": product.id, "quantity": 4, "unit_price": "5"}],
|
||
}, format="json").json()
|
||
resp = auth_client.post(f"/api/v1/sales/bills/{create['id']}/confirm/", {}, format="json")
|
||
assert resp.status_code == 200, resp.content
|
||
assert resp.json()["state"] == "confirmed"
|
||
assert Stock.objects.get(product=product).on_hand == Decimal("6")
|
||
|
||
|
||
def test_sales_confirm_over_credit_402_then_force(
|
||
db, auth_client, tenant, warehouse, product
|
||
):
|
||
"""信用超限返回 402 + code,force=true 放行。"""
|
||
limited = baker.make(Customer, tenant=tenant, code="C999", name="受限客户",
|
||
credit_limit=Decimal("100"))
|
||
from apps.finance.models import Receivable
|
||
baker.make(Receivable, tenant=tenant, customer=limited, bill_no="RC-OLD",
|
||
bill_date=date.today(), total_amount=Decimal("80"), status="open")
|
||
_stock_in(tenant, warehouse, product, 10)
|
||
create = auth_client.post("/api/v1/sales/bills/create-bill/", {
|
||
"customer": limited.id, "warehouse": warehouse.id,
|
||
"lines": [{"product": product.id, "quantity": 10, "unit_price": "5"}],
|
||
}, format="json").json()
|
||
resp = auth_client.post(f"/api/v1/sales/bills/{create['id']}/confirm/", {}, format="json")
|
||
assert resp.status_code == 402
|
||
body = resp.json()
|
||
assert body["code"] == "credit_limit_exceeded"
|
||
assert body["customer_code"] == "C999"
|
||
# 未放行:库存未扣
|
||
assert Stock.objects.get(product=product).on_hand == Decimal("10")
|
||
# 强制过账
|
||
resp2 = auth_client.post(f"/api/v1/sales/bills/{create['id']}/confirm/",
|
||
{"force": True}, format="json")
|
||
assert resp2.status_code == 200, resp2.content
|
||
assert resp2.json()["state"] == "confirmed"
|
||
|
||
|
||
# ---------- 3. 采购开单/过账(批次) ----------
|
||
|
||
|
||
def test_purchase_create_and_confirm_with_batch(
|
||
db, auth_client, tenant, warehouse, supplier, batch_product
|
||
):
|
||
resp = auth_client.post("/api/v1/purchase/bills/create-bill/", {
|
||
"supplier": supplier.id, "warehouse": warehouse.id,
|
||
"lines": [{
|
||
"product": batch_product.id, "quantity": 12, "unit_price": "6",
|
||
"batch_no": "B20260901", "production_date": "2026-09-01",
|
||
"expiry_date": "2026-10-01",
|
||
}],
|
||
}, format="json")
|
||
assert resp.status_code == 201, resp.content
|
||
bill = resp.json()
|
||
assert bill["lines"][0]["batch_no"] == "B20260901"
|
||
assert bill["lines"][0]["is_batch_managed"] is True
|
||
|
||
resp2 = auth_client.post(f"/api/v1/purchase/bills/{bill['id']}/confirm/", {}, format="json")
|
||
assert resp2.status_code == 200, resp2.content
|
||
assert resp2.json()["state"] == "confirmed"
|
||
sb = StockBatch.objects.get(product=batch_product, batch_no="B20260901")
|
||
assert sb.on_hand == Decimal("12")
|
||
assert sb.expiry_date == date(2026, 10, 1)
|
||
|
||
|
||
def test_purchase_batch_managed_missing_batch_no_400(
|
||
db, auth_client, tenant, warehouse, supplier, batch_product
|
||
):
|
||
"""批次商品漏填批次号 → 服务层拒绝,接口翻译为 400。"""
|
||
resp = auth_client.post("/api/v1/purchase/bills/create-bill/", {
|
||
"supplier": supplier.id, "warehouse": warehouse.id,
|
||
"lines": [{"product": batch_product.id, "quantity": 5, "unit_price": "6"}],
|
||
}, format="json")
|
||
assert resp.status_code == 201 # 建单阶段不校验批次(过账才校验)
|
||
bill_id = resp.json()["id"]
|
||
resp2 = auth_client.post(f"/api/v1/purchase/bills/{bill_id}/confirm/", {}, format="json")
|
||
assert resp2.status_code == 400, resp2.content
|
||
|
||
|
||
# ---------- 4. 对账单客户侧 HTML ----------
|
||
|
||
|
||
def test_public_statement_html(db, auth_client, anon_client, tenant, customer):
|
||
from apps.finance.models import Receivable
|
||
baker.make(Receivable, tenant=tenant, customer=customer, bill_no="RC001",
|
||
bill_date=date.today(), total_amount=Decimal("1234.5"), status="open")
|
||
share = baker.make(
|
||
StatementShare, tenant=tenant, customer=customer,
|
||
date_from=date.today(), date_to=date.today(),
|
||
)
|
||
resp = anon_client.get(f"/api/v1/open/statements/{share.token}/?view=html")
|
||
assert resp.status_code == 200, resp.content
|
||
assert resp["Content-Type"].startswith("text/html")
|
||
html_text = resp.content.decode("utf-8")
|
||
assert "对 账 单" in html_text
|
||
assert "张三商店" in html_text
|
||
assert "1,234.50" in html_text # 金额千分位
|
||
assert "window.print()" in html_text # 可打印
|
||
assert "RC001" in html_text
|
||
|
||
|
||
def test_public_statement_json_still_works(db, auth_client, anon_client, tenant, customer):
|
||
share = baker.make(
|
||
StatementShare, tenant=tenant, customer=customer,
|
||
date_from=date.today(), date_to=date.today(),
|
||
)
|
||
resp = anon_client.get(f"/api/v1/open/statements/{share.token}/")
|
||
assert resp.status_code == 200
|
||
assert resp.json()["customer"]["code"] == "C001"
|
||
|
||
|
||
def test_statement_share_revoke_api(db, auth_client, anon_client, tenant, customer):
|
||
resp = auth_client.post("/api/v1/finance/statements/share/", {
|
||
"customer_id": customer.id,
|
||
"date_from": date.today().isoformat(),
|
||
"date_to": date.today().isoformat(),
|
||
"expires_days": 7,
|
||
}, format="json")
|
||
assert resp.status_code == 201, resp.content
|
||
token = resp.json()["token"]
|
||
assert anon_client.get(f"/api/v1/open/statements/{token}/").status_code == 200
|
||
|
||
resp2 = auth_client.post(f"/api/v1/finance/statements/share/{token}/revoke/", {},
|
||
format="json")
|
||
assert resp2.status_code == 200, resp2.content
|
||
assert resp2.json()["revoked"] is True
|
||
assert anon_client.get(f"/api/v1/open/statements/{token}/").status_code == 404
|
||
|
||
|
||
# ---------- 5. 渲染器 if/else ----------
|
||
|
||
|
||
def test_renderer_if_block():
|
||
ctx = {"a": 1, "b": "", "lines": []}
|
||
assert render_template("{{#if a}}有{{/if}}", ctx) == "有"
|
||
assert render_template("{{#if b}}有{{/if}}", ctx) == ""
|
||
assert render_template("{{#if b}}有{{else}}无{{/if}}", ctx) == "无"
|
||
assert render_template("{{#if lines}}有{{else}}无{{/if}}", ctx) == "无"
|
||
# 嵌套
|
||
assert render_template("{{#if a}}{{#if b}}AB{{else}}A{{/if}}{{/if}}", ctx) == "A"
|
||
|
||
|
||
def test_renderer_if_inside_each():
|
||
ctx = {"lines": [{"n": "A", "batch": "B1"}, {"n": "B", "batch": ""}]}
|
||
body = "{{#each lines}}[{{item.n}}{{#if item.batch}}:{{item.batch}}{{/if}}]{{/each}}"
|
||
assert render_template(body, ctx) == "[A:B1][B]"
|
||
|
||
|
||
# ---------- 6. 打印批次列 ----------
|
||
|
||
|
||
def test_print_sales_bill_has_batch_column(
|
||
db, auth_client, tenant, warehouse, customer, batch_product
|
||
):
|
||
"""批次商品的销售单打印带批次号(FEFO 分摊结果)。"""
|
||
from apps.inventory import services as inv_services
|
||
inv_services.inbound(
|
||
tenant=tenant, warehouse=warehouse, product=batch_product,
|
||
quantity=Decimal("10"), unit_cost=Decimal("4"),
|
||
batch_no="BATCH-A", expiry_date=date(2026, 12, 31),
|
||
)
|
||
create = auth_client.post("/api/v1/sales/bills/create-bill/", {
|
||
"customer": customer.id, "warehouse": warehouse.id,
|
||
"lines": [{"product": batch_product.id, "quantity": 3, "unit_price": "10"}],
|
||
}, format="json").json()
|
||
auth_client.post(f"/api/v1/sales/bills/{create['id']}/confirm/", {}, format="json")
|
||
|
||
resp = auth_client.get(f"/api/v1/printing/render/sales_bill/{create['id']}/?autoprint=0")
|
||
assert resp.status_code == 200
|
||
html_text = resp.content.decode("utf-8")
|
||
assert "批次号" in html_text
|
||
assert "BATCH-A" in html_text
|
||
|
||
|
||
def test_print_non_batch_no_batch_column(
|
||
db, auth_client, tenant, warehouse, customer, product
|
||
):
|
||
"""非批次商品不出现批次号列。"""
|
||
_stock_in(tenant, warehouse, product, 10)
|
||
create = auth_client.post("/api/v1/sales/bills/create-bill/", {
|
||
"customer": customer.id, "warehouse": warehouse.id,
|
||
"lines": [{"product": product.id, "quantity": 2, "unit_price": "5"}],
|
||
}, format="json").json()
|
||
auth_client.post(f"/api/v1/sales/bills/{create['id']}/confirm/", {}, format="json")
|
||
resp = auth_client.get(f"/api/v1/printing/render/sales_bill/{create['id']}/?autoprint=0")
|
||
assert "批次号" not in resp.content.decode("utf-8")
|