"""批次 A(P0 前端配套)后端补口测试。 覆盖: 1. 商品可用单位接口 `GET /catalog/products//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")