243 lines
8.7 KiB
Python
243 lines
8.7 KiB
Python
"""批次 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("<svg")
|
||
assert "<rect" in svg
|
||
|
||
|
||
def test_qr_version_scales_with_length():
|
||
assert _pick_version(10) == 1
|
||
assert _pick_version(60) >= 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
|