"""批次 D5 · 打印扩展测试:58mm 小票 / 三联送货单 / 对账单二维码。 重点:二维码必须**可解码回原文**(往返验证),避免交付"看着像二维码但扫不出来"。 """ import base64 import re import pytest 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 from apps.inventory.models import Warehouse from apps.partner.models import Customer from apps.printing import services as printing_services from apps.printing.models import PrintTemplate from apps.printing.qr import qr_svg_data_uri, statement_qr_context, _pick_version from apps.printing.qr_selftest import decode as qr_decode from apps.sales import services as sales_services @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): return APIClient() @pytest.fixture def warehouse(db, tenant): return baker.make(Warehouse, tenant=tenant, code="WH01", name="主仓") @pytest.fixture def customer(db, tenant): return baker.make(Customer, tenant=tenant, code="C001", name="张三便利店", phone="13800000000", credit_limit=Decimal("99999")) @pytest.fixture def product(db, tenant): return baker.make(Product, tenant=tenant, code="P001", name="可乐", sale_price=Decimal("5")) def _bill(tenant, warehouse, customer, product, *, round_to=None): from apps.inventory import services as inv inv.inbound(tenant=tenant, warehouse=warehouse, product=product, quantity=Decimal("100"), unit_cost=Decimal("3")) bill = sales_services.create_sales_bill( tenant=tenant, customer=customer, warehouse=warehouse, lines=[{"product": product, "quantity": 3, "unit_price": 5}], round_to=round_to, ) sales_services.confirm_sales_bill(bill) return bill @pytest.mark.parametrize("text", [ "/api/v1/open/statements/9eb70e10-82e1-47d1-bc5b-fb3e0716ba19/", "https://dealerhub.example.com/api/v1/open/statements/c71ac955-27d6-4942-968c-720a6afe5394/", "HELLO-DEALERHUB-2026", "1", "x" * 60, "对账单-中文测试", ]) def test_qr_roundtrip(text): """生成 → 解码 必须完全一致(等价于"能不能真扫出来")。""" uri = qr_svg_data_uri(text, scale=4) svg = base64.b64decode(uri.split(",", 1)[1]).decode("utf-8") assert qr_decode(svg) == text def test_qr_is_svg_data_uri(): uri = qr_svg_data_uri("test") assert uri.startswith("data:image/svg+xml;base64,") svg = base64.b64decode(uri.split(",", 1)[1]).decode() assert svg.startswith("= 2 assert _pick_version(90) >= 3 def test_qr_rejects_too_long(): with pytest.raises(ValueError): _pick_version(10000) def test_statement_qr_context_creates_share(db, tenant, customer): ctx = statement_qr_context(tenant, customer) assert ctx is not None assert ctx["url"].startswith("/api/v1/open/statements/") assert ctx["data_uri"].startswith("data:image/svg+xml") svg = base64.b64decode(ctx["data_uri"].split(",", 1)[1]).decode() assert qr_decode(svg) == ctx["url"] from apps.finance.models import StatementShare assert StatementShare.objects.filter( tenant=tenant, customer=customer, token=ctx["token"] ).exists() def test_statement_qr_reuses_existing_share(db, tenant, customer): a = statement_qr_context(tenant, customer) b = statement_qr_context(tenant, customer) assert a["token"] == b["token"] def test_statement_qr_none_without_customer(db, tenant): assert statement_qr_context(tenant, None) is None def test_receipt_template_registered(db, tenant): printing_services.ensure_default_templates(tenant) codes = set(PrintTemplate.objects.filter(tenant=tenant).values_list("code", flat=True)) assert {"xs-default", "xs-receipt-58", "xs-triplicate"} <= codes def test_receipt_render_uses_58mm_shell(db, auth_client, tenant, warehouse, customer, product): bill = _bill(tenant, warehouse, customer, product) resp = auth_client.get( f"/api/v1/printing/render/sales_bill/{bill.id}/?template=xs-receipt-58&autoprint=0" ) assert resp.status_code == 200, resp.content html = resp.content.decode("utf-8") assert "size: 58mm auto" in html assert "销 售 单" in html assert "应收合计" in html assert bill.bill_no in html def test_receipt_shows_round_off(db, auth_client, tenant, warehouse, customer, product): """有小数零头时才显示抹零(3 × 5.17 = 15.51 → 抹到 15,抹零 0.51)。""" from apps.inventory import services as inv bill = _bill(tenant, warehouse, customer, product, round_to="1") # 换一张有零头的单(直接在既有单上加行不便于控制,这里新建) bill2 = sales_services.create_sales_bill( tenant=tenant, customer=customer, warehouse=warehouse, lines=[{"product": product, "quantity": 3, "unit_price": "5.17"}], round_to="1", ) assert bill2.round_off == Decimal("0.5100") resp = auth_client.get( f"/api/v1/printing/render/sales_bill/{bill2.id}/?template=xs-receipt-58&autoprint=0" ) html = resp.content.decode("utf-8") assert "抹零" in html assert "0.5100" in html or "0.51" in html def test_triplicate_renders_three_slips(db, auth_client, tenant, warehouse, customer, product): bill = _bill(tenant, warehouse, customer, product) resp = auth_client.get( f"/api/v1/printing/render/sales_bill/{bill.id}/?template=xs-triplicate&autoprint=0" ) assert resp.status_code == 200, resp.content html = resp.content.decode("utf-8") assert "A4 landscape" in html for tag in ("存根联", "客户联", "财务联"): assert tag in html, tag assert html.count('class="tpl-slip"') == 3 # 单号:标题 1 次 + 三份联次各 1 次 assert html.count(bill.bill_no) == 4 assert html.count("张三便利店") == 3 def test_triplicate_has_line_items(db, auth_client, tenant, warehouse, customer, product): bill = _bill(tenant, warehouse, customer, product) resp = auth_client.get( f"/api/v1/printing/render/sales_bill/{bill.id}/?template=xs-triplicate&autoprint=0" ) html = resp.content.decode("utf-8") assert html.count("可乐") == 3 def test_sales_bill_context_has_qr(db, tenant, warehouse, customer, product): bill = _bill(tenant, warehouse, customer, product) ctx = printing_services.sales_bill_context(bill) assert ctx["statement_qr"] is not None assert ctx["statement_qr"]["data_uri"].startswith("data:image/svg+xml") def test_sales_bill_render_includes_qr(db, auth_client, tenant, warehouse, customer, product): bill = _bill(tenant, warehouse, customer, product) resp = auth_client.get(f"/api/v1/printing/render/sales_bill/{bill.id}/?autoprint=0") html = resp.content.decode("utf-8") assert "qr-block" in html assert "扫码查看对账单" in html assert "data:image/svg+xml;base64," in html def test_qr_code_encodes_working_public_url(db, auth_client, anon_client, tenant, warehouse, customer, product): """端到端:单据二维码 → 解码 URL → 匿名访问该 URL 得 200。""" bill = _bill(tenant, warehouse, customer, product) resp = auth_client.get(f"/api/v1/printing/render/sales_bill/{bill.id}/?autoprint=0") html = resp.content.decode("utf-8") m = re.search(r'src="data:image/svg\+xml;base64,([^"]+)"', html) assert m, "未找到二维码" svg = base64.b64decode(m.group(1)).decode("utf-8") url = qr_decode(svg) assert url and url.startswith("/api/v1/open/statements/") resp2 = anon_client.get(url + "?view=json") assert resp2.status_code == 200, resp2.content assert resp2.json()["customer"]["code"] == "C001" def test_purchase_bill_has_no_qr(db, tenant, warehouse, product): from apps.partner.models import Supplier from apps.purchase import services as purchase_services supplier = baker.make(Supplier, tenant=tenant, code="S001", name="上游厂") bill = purchase_services.create_purchase_bill( tenant=tenant, supplier=supplier, warehouse=warehouse, lines=[{"product": product, "quantity": 5, "unit_price": 3}], ) ctx = printing_services.purchase_bill_context(bill) assert "statement_qr" not in ctx