Files
dealerhub/backend/tests/test_batch_a_api.py

383 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""批次 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")