ForgeFlow-ERP/backend/tests/test_document_archive_service.py
2026-06-12 18:02:06 +08:00

850 lines
33 KiB
Python
Raw 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.

from __future__ import annotations
import unittest
from datetime import date, datetime
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
from reportlab.lib.pagesizes import A4
from reportlab.pdfbase import pdfmetrics
from sqlalchemy import BigInteger, create_engine, select
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session, sessionmaker
import app.models.document_archive # noqa: E402,F401
import app.models.master_data # noqa: E402,F401
import app.models.miniapp # noqa: E402,F401
import app.models.operations # noqa: E402,F401
import app.models.org # noqa: E402,F401
import app.models.planning # noqa: E402,F401
import app.models.sales # noqa: E402,F401
from app.models.base import Base # noqa: E402
from app.models.document_archive import DocumentArchive # noqa: E402
from app.models.master_data import Item, Warehouse # noqa: E402
from app.models.operations import PurchaseOrder, PurchaseOrderItem, PurchaseReceipt, PurchaseReceiptItem, StockLot, Supplier # noqa: E402
from app.models.org import Department, Employee, User # noqa: E402
from app.models.sales import Customer, SalesOrder, SalesOrderItem # noqa: E402
from app.services.document_archives import ( # noqa: E402
ARCHIVE_STATUS_FAILED,
ARCHIVE_STATUS_READY,
ArchiveContext,
ArchiveLine,
DOCUMENT_TYPE_PURCHASE_RECEIPT,
DOCUMENT_TYPE_QUALITY_INSPECTION,
DOCUMENT_TYPE_SALES_ORDER,
DOCUMENT_TYPE_PURCHASE_ORDER,
FILE_FORMAT_PDF,
TEMPLATE_VERSION,
collect_purchase_order_archive_context,
collect_purchase_receipt_archive_context,
collect_quality_inspection_archive_context,
generate_document_archive,
get_latest_archive,
render_pdf_archive,
_archive_table_columns,
_draw_archive_page_frame,
_draw_cell,
_draw_archive_footer,
)
@compiles(BigInteger, "sqlite")
def _compile_big_integer_for_sqlite(type_, compiler, **kw) -> str:
_ = type_, compiler, kw
return "INTEGER"
class DocumentArchiveServiceTest(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
self.db: Session = self.SessionLocal()
def tearDown(self) -> None:
self.db.close()
def _seed_sales_order(self) -> SalesOrder:
now = datetime.now()
customer = Customer(
id=1,
customer_code="CUST-001",
customer_name="上海测试客户有限公司",
short_name="测试客户",
contact_name="张三",
contact_phone="13800000000",
address="上海市浦东新区测试路 100 号",
tax_no="91310000TEST",
credit_days=30,
status="启用",
created_at=now,
updated_at=now,
)
item = Item(
id=10,
item_code="FG-001",
item_name="精密锻件",
item_type="成品",
specification="A-100",
material_grade="42CrMo",
unit_weight_kg=1.25,
status="启用",
created_at=now,
updated_at=now,
)
order = SalesOrder(
id=100,
order_no="SO-20260611-0001",
customer_id=customer.id,
order_date=date(2026, 6, 11),
promised_date=date(2026, 6, 20),
sales_employee_id=None,
delivery_address="上海市浦东新区测试路 100 号",
tax_rate=0.13,
total_amount=1234.56,
status="已审核",
created_at=now,
updated_at=now,
)
order_item = SalesOrderItem(
id=1000,
sales_order_id=order.id,
line_no=1,
product_item_id=item.id,
customer_part_no="CPN-001",
order_qty=12,
delivered_qty=2,
unit_price=102.88,
line_amount=1234.56,
promised_date=date(2026, 6, 20),
status="未交付",
created_at=now,
updated_at=now,
)
self.db.add_all([customer, item, order, order_item])
self.db.commit()
return order
def _seed_archive_user(self, *, nickname: str | None = "张制单") -> User:
now = datetime.now()
department = Department(
id=900,
dept_code="D-ARCHIVE",
dept_name="归档测试部",
parent_id=None,
org_node_type="DEPARTMENT",
dept_type="部门",
manager_name=None,
manager_employee_id=None,
status="ACTIVE",
sort_no=0,
remark=None,
created_at=now,
updated_at=now,
)
employee = Employee(
id=901,
employee_code="EMP-ARCHIVE",
employee_name="归档操作员",
dept_id=department.id,
mobile="13811112222",
gender=None,
hire_date=None,
job_title=None,
shift_code=None,
manager_employee_id=None,
is_operator=0,
is_workshop_staff=0,
status="ACTIVE",
remark=None,
created_at=now,
updated_at=now,
)
user = User(
id=902,
username="archive_admin",
password_hash="hashed",
employee_id=employee.id,
dept_id=department.id,
nickname=nickname,
email=None,
is_super_admin=0,
last_login_at=None,
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.db.add_all([department, employee, user])
self.db.commit()
return user
def _seed_purchase_order(self, *, target_warehouse_type: str) -> PurchaseOrder:
now = datetime.now()
supplier = Supplier(
id=200,
supplier_code="SUP-001",
supplier_name="宁波测试供应商有限公司",
short_name="测试供应商",
contact_name="李四",
contact_phone="13900000000",
address="宁波市测试路 1 号",
lead_time_days=0,
default_tax_rate=0.13,
status="启用",
created_at=now,
updated_at=now,
)
item = Item(
id=210,
item_code="RM-001" if target_warehouse_type == "RAW" else "AUX-001",
item_name="圆钢" if target_warehouse_type == "RAW" else "包装辅料",
item_type="原材料" if target_warehouse_type == "RAW" else "辅料",
specification="D40",
material_grade="42CrMo",
unit_weight_kg=1,
status="启用",
created_at=now,
updated_at=now,
)
order = PurchaseOrder(
id=300,
po_no=f"PO-{target_warehouse_type}-20260611-0001",
supplier_id=supplier.id,
order_date=date(2026, 6, 11),
expected_date=date(2026, 6, 20),
warning_lead_days=0,
purchaser_employee_id=None,
target_warehouse_type=target_warehouse_type,
tax_rate=0.13,
total_amount=8641.98,
status="已审核",
remark="采购归档测试",
created_at=now,
updated_at=now,
)
order_item = PurchaseOrderItem(
id=3000,
purchase_order_id=order.id,
line_no=1,
material_item_id=item.id,
source_demand_id=None,
order_qty=0 if target_warehouse_type == "RAW" else 12,
order_weight_kg=1234.56 if target_warehouse_type == "RAW" else 0,
received_qty=0 if target_warehouse_type == "RAW" else 3,
received_weight_kg=234.5 if target_warehouse_type == "RAW" else 0,
unit_price=7,
line_amount=8641.98,
status="未收货",
remark="测试明细",
created_at=now,
updated_at=now,
)
self.db.add_all([supplier, item, order, order_item])
self.db.commit()
return order
def _seed_purchase_receipt(self, *, target_warehouse_type: str = "RAW") -> PurchaseReceipt:
now = datetime.now()
supplier = Supplier(
id=400,
supplier_code="SUP-RCPT",
supplier_name="宁波到货供应商有限公司",
short_name="到货供应商",
contact_name="王五",
contact_phone="13700000000",
address="宁波市到货路 2 号",
lead_time_days=0,
default_tax_rate=0.13,
status="启用",
created_at=now,
updated_at=now,
)
item = Item(
id=410,
item_code="RM-RCPT-001" if target_warehouse_type == "RAW" else "AUX-RCPT-001",
item_name="到货圆钢" if target_warehouse_type == "RAW" else "到货辅料",
item_type="原材料" if target_warehouse_type == "RAW" else "辅料",
specification="D50",
material_grade="42CrMo",
unit_weight_kg=1,
status="启用",
created_at=now,
updated_at=now,
)
warehouse = Warehouse(
id=420,
warehouse_code="WH-RCPT",
warehouse_name="原材料库" if target_warehouse_type == "RAW" else "辅料库",
warehouse_type=target_warehouse_type,
manager_employee_id=None,
status="启用",
created_at=now,
updated_at=now,
)
order = PurchaseOrder(
id=430,
po_no="采购2026-00088",
supplier_id=supplier.id,
order_date=date(2026, 6, 11),
expected_date=date(2026, 6, 20),
warning_lead_days=0,
purchaser_employee_id=None,
target_warehouse_type=target_warehouse_type,
tax_rate=0.13,
total_amount=3500,
status="LOCKED",
remark="到货归档采购单",
created_at=now,
updated_at=now,
)
order_item = PurchaseOrderItem(
id=440,
purchase_order_id=order.id,
line_no=1,
material_item_id=item.id,
source_demand_id=None,
order_qty=0 if target_warehouse_type == "RAW" else 10,
order_weight_kg=500 if target_warehouse_type == "RAW" else 0,
received_qty=0 if target_warehouse_type == "RAW" else 4,
received_weight_kg=125.5 if target_warehouse_type == "RAW" else 0,
unit_price=7,
line_amount=3500,
status="PARTIAL_RECEIVED",
remark="采购明细备注",
created_at=now,
updated_at=now,
)
receipt = PurchaseReceipt(
id=450,
receipt_no="入库2026-00009",
purchase_order_id=order.id,
warehouse_id=warehouse.id,
receipt_date=datetime(2026, 6, 12, 9, 30, 0),
receiver_employee_id=None,
supplier_delivery_no=None,
logistics_waybill_no="YD-20260612",
logistics_freight_amount=88.8,
logistics_photo_url="/uploads/receipt.jpg",
status="PENDING_QC",
remark="到货入库归档测试",
created_at=now,
updated_at=now,
)
receipt_item = PurchaseReceiptItem(
id=460,
receipt_id=receipt.id,
line_no=1,
purchase_order_item_id=order_item.id,
material_item_id=item.id,
location_id=None,
lot_no="JH到货圆钢00001_0001",
merge_to_lot_id=None,
received_qty=0 if target_warehouse_type == "RAW" else 4,
received_weight_kg=125.5 if target_warehouse_type == "RAW" else 0,
accepted_qty=0,
accepted_weight_kg=0,
unit_cost=7.2,
status="PENDING_QC",
remark="入库明细备注",
created_at=now,
updated_at=now,
)
self.db.add_all([supplier, item, warehouse, order, order_item, receipt, receipt_item])
self.db.commit()
return receipt
def test_generate_sales_order_pdf_archive_creates_latest_ready_archive(self) -> None:
order = self._seed_sales_order()
with TemporaryDirectory() as tempdir:
result = generate_document_archive(
self.db,
DOCUMENT_TYPE_SALES_ORDER,
order.id,
archive_root=tempdir,
)
self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY)
self.assertEqual(result.archive_version, 1)
self.assertEqual(result.document_no, order.order_no)
latest = get_latest_archive(self.db, DOCUMENT_TYPE_SALES_ORDER, order.id)
self.assertIsNotNone(latest)
assert latest is not None
self.assertEqual(latest.document_no, order.order_no)
self.assertEqual(latest.archive_version, 1)
self.assertEqual(latest.status, ARCHIVE_STATUS_READY)
pdf_path = Path(latest.file_path)
self.assertTrue(pdf_path.exists())
self.assertEqual(pdf_path.read_bytes()[:4], b"%PDF")
def test_generate_archive_fills_preparer_and_operator_from_current_user(self) -> None:
order = self._seed_sales_order()
user = self._seed_archive_user(nickname="张制单")
captured_contexts: list[ArchiveContext] = []
def capture_render(context: ArchiveContext, output_path: str | Path) -> None:
captured_contexts.append(context)
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
Path(output_path).write_bytes(b"%PDF archive test")
with TemporaryDirectory() as tempdir, patch(
"app.services.document_archives.render_pdf_archive",
side_effect=capture_render,
):
result = generate_document_archive(
self.db,
DOCUMENT_TYPE_SALES_ORDER,
order.id,
created_by=user.id,
archive_root=tempdir,
)
self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY)
self.assertEqual(len(captured_contexts), 1)
self.assertEqual(captured_contexts[0].prepared_by, "张制单")
self.assertEqual(captured_contexts[0].operator_name, "张制单")
def test_draw_archive_footer_renders_preparer_and_operator_names(self) -> None:
class FakePdf:
def __init__(self) -> None:
self.drawn_texts: list[str] = []
def setFont(self, *args, **kwargs) -> None:
_ = args, kwargs
def drawRightString(self, x, y, text) -> None:
_ = x, y
self.drawn_texts.append(text)
def drawString(self, x, y, text) -> None:
_ = x, y
self.drawn_texts.append(text)
context = ArchiveContext(
document_type=DOCUMENT_TYPE_SALES_ORDER,
business_id=100,
document_no="SO-20260611-0001",
title="销售订单归档",
partner_label="客户",
partner_name="上海测试客户有限公司",
document_date=date(2026, 6, 11),
due_date_label="承诺日期",
due_date=date(2026, 6, 20),
status="已审核",
tax_rate=0.13,
total_amount=2500,
prepared_by="张制单",
operator_name="张经办",
)
fake_pdf = FakePdf()
_draw_archive_footer(fake_pdf, context, 120, "Helvetica", "Helvetica-Bold")
self.assertIn("制单: 张制单", fake_pdf.drawn_texts)
self.assertIn("经办人: 张经办", fake_pdf.drawn_texts)
self.assertNotIn("制单: __________", fake_pdf.drawn_texts)
def test_collect_raw_purchase_order_archive_context_uses_weight_as_quantity(self) -> None:
order = self._seed_purchase_order(target_warehouse_type="RAW")
context = collect_purchase_order_archive_context(self.db, order.id)
self.assertEqual(context.document_type, DOCUMENT_TYPE_PURCHASE_ORDER)
self.assertEqual(len(context.lines), 1)
line = context.lines[0]
self.assertEqual(float(line.quantity or 0), 1234.56)
self.assertEqual(float(line.delivered_or_received_quantity or 0), 234.5)
self.assertNotEqual(float(line.quantity or 0), 0)
self.assertIn("kg", line.remark or "")
def test_collect_aux_purchase_order_archive_context_uses_order_quantity(self) -> None:
order = self._seed_purchase_order(target_warehouse_type="AUX")
context = collect_purchase_order_archive_context(self.db, order.id)
self.assertEqual(len(context.lines), 1)
line = context.lines[0]
self.assertEqual(float(line.quantity or 0), 12)
self.assertEqual(float(line.delivered_or_received_quantity or 0), 3)
self.assertEqual(line.remark, "测试明细")
def test_collect_purchase_receipt_archive_context_uses_receipt_items(self) -> None:
receipt = self._seed_purchase_receipt(target_warehouse_type="RAW")
context = collect_purchase_receipt_archive_context(self.db, receipt.id)
self.assertEqual(context.document_type, DOCUMENT_TYPE_PURCHASE_RECEIPT)
self.assertEqual(context.business_id, receipt.id)
self.assertEqual(context.document_no, "入库2026-00009")
self.assertEqual(context.title, "到货入库单归档")
self.assertEqual(context.partner_label, "供应商")
self.assertEqual(context.partner_name, "宁波到货供应商有限公司")
self.assertEqual(context.due_date_label, "采购订单")
self.assertEqual(context.due_date, "采购2026-00088")
self.assertIn("运单号YD-20260612", context.remark or "")
self.assertIn("运费88.8", context.remark or "")
self.assertEqual(len(context.lines), 1)
line = context.lines[0]
self.assertEqual(line.item_code, "RM-RCPT-001")
self.assertEqual(line.item_name, "到货圆钢")
self.assertEqual(float(line.quantity or 0), 125.5)
self.assertEqual(float(line.delivered_or_received_quantity or 0), 0)
self.assertEqual(float(line.unit_price or 0), 7.2)
self.assertEqual(context.status, "待质检")
self.assertEqual(line.remark, "批次JH到货圆钢00001_0001状态待质检入库明细备注")
def test_collect_quality_inspection_archive_context_uses_inspection_result(self) -> None:
receipt = self._seed_purchase_receipt(target_warehouse_type="RAW")
receipt_item = self.db.scalar(select(PurchaseReceiptItem).where(PurchaseReceiptItem.receipt_id == receipt.id))
self.assertIsNotNone(receipt_item)
assert receipt_item is not None
receipt.status = "PASSED"
receipt_item.status = "PASSED"
receipt_item.accepted_weight_kg = receipt_item.received_weight_kg
receipt_item.remark = "表面无划伤,尺寸符合要求"
lot = StockLot(
id=470,
lot_no=receipt_item.lot_no,
parent_lot_id=None,
lot_role="INBOUND_RAW",
material_sub_batch_no=None,
item_id=receipt_item.material_item_id,
warehouse_id=receipt.warehouse_id,
location_id=None,
source_doc_type="PURCHASE_RECEIPT",
source_doc_id=receipt.id,
source_line_id=receipt_item.id,
source_material_lot_id=None,
source_material_sub_batch_no=None,
source_material_summary=None,
inbound_qty=0,
inbound_weight_kg=receipt_item.received_weight_kg,
remaining_qty=0,
remaining_weight_kg=receipt_item.received_weight_kg,
locked_qty=0,
locked_weight_kg=0,
unit_cost=receipt_item.unit_cost,
production_date=None,
expire_date=None,
quality_status="PASS",
logistics_waybill_no=None,
logistics_freight_amount=None,
logistics_photo_url=None,
status="AVAILABLE",
remark="质检员王工",
created_at=datetime.now(),
updated_at=datetime.now(),
)
self.db.add_all([receipt, receipt_item, lot])
self.db.commit()
context = collect_quality_inspection_archive_context(self.db, receipt_item.id)
self.assertEqual(context.document_type, DOCUMENT_TYPE_QUALITY_INSPECTION)
self.assertEqual(context.business_id, receipt_item.id)
self.assertEqual(context.document_no, f"质检-{receipt.receipt_no}-{receipt_item.line_no}")
self.assertEqual(context.title, "质量校验单归档")
self.assertEqual(context.partner_label, "供应商")
self.assertEqual(context.partner_name, "宁波到货供应商有限公司")
self.assertEqual(context.due_date_label, "入库单")
self.assertEqual(context.due_date, receipt.receipt_no)
self.assertEqual(context.status, "已放行")
self.assertIn("质检人员:质检员王工", context.remark or "")
self.assertEqual(len(context.lines), 1)
line = context.lines[0]
self.assertEqual(line.item_code, "RM-RCPT-001")
self.assertEqual(line.item_name, "到货圆钢")
self.assertEqual(float(line.quantity or 0), 125.5)
self.assertEqual(float(line.delivered_or_received_quantity or 0), 125.5)
self.assertEqual(line.remark, "库存批次号JH到货圆钢00001_0001质检结论已放行表面无划伤尺寸符合要求")
def test_generate_purchase_receipt_pdf_archive_creates_latest_ready_archive(self) -> None:
receipt = self._seed_purchase_receipt(target_warehouse_type="RAW")
with TemporaryDirectory() as tempdir:
result = generate_document_archive(
self.db,
DOCUMENT_TYPE_PURCHASE_RECEIPT,
receipt.id,
archive_root=tempdir,
)
self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY)
self.assertEqual(result.document_no, "入库2026-00009")
latest = get_latest_archive(self.db, DOCUMENT_TYPE_PURCHASE_RECEIPT, receipt.id)
self.assertIsNotNone(latest)
assert latest is not None
self.assertEqual(latest.status, ARCHIVE_STATUS_READY)
self.assertTrue(Path(latest.file_path).exists())
def test_generate_sales_order_pdf_archive_increments_archive_version(self) -> None:
order = self._seed_sales_order()
with TemporaryDirectory() as tempdir:
first = generate_document_archive(
self.db,
DOCUMENT_TYPE_SALES_ORDER,
order.id,
archive_root=tempdir,
)
second = generate_document_archive(
self.db,
DOCUMENT_TYPE_SALES_ORDER,
order.id,
archive_root=tempdir,
)
self.assertEqual(first.archive_version, 1)
self.assertEqual(second.archive_version, 2)
archives = self.db.execute(
select(DocumentArchive)
.where(
DocumentArchive.document_type == DOCUMENT_TYPE_SALES_ORDER,
DocumentArchive.business_id == order.id,
)
.order_by(DocumentArchive.archive_version)
).scalars().all()
self.assertEqual(len(archives), 2)
self.assertEqual([archive.archive_version for archive in archives], [1, 2])
self.assertTrue(all(Path(archive.file_path).exists() for archive in archives))
def test_generate_archive_records_failed_row_when_version_lookup_fails(self) -> None:
order = self._seed_sales_order()
with patch(
"app.services.document_archives.next_archive_version",
side_effect=RuntimeError("version lookup failed"),
):
result = generate_document_archive(self.db, DOCUMENT_TYPE_SALES_ORDER, order.id)
self.assertEqual(result.archive_status, ARCHIVE_STATUS_FAILED)
self.assertEqual(result.archive_version, 1)
self.assertIn("version lookup failed", result.archive_error_message or "")
failed_archives = self.db.execute(
select(DocumentArchive).where(
DocumentArchive.document_type == DOCUMENT_TYPE_SALES_ORDER,
DocumentArchive.business_id == order.id,
DocumentArchive.status == ARCHIVE_STATUS_FAILED,
)
).scalars().all()
self.assertEqual(len(failed_archives), 1)
self.assertEqual(failed_archives[0].archive_version, 1)
self.assertEqual(failed_archives[0].document_no, f"{DOCUMENT_TYPE_SALES_ORDER}-{order.id}")
def test_render_pdf_archive_includes_more_than_eighteen_lines(self) -> None:
context = ArchiveContext(
document_type=DOCUMENT_TYPE_SALES_ORDER,
business_id=100,
document_no="SO-20260611-0001",
title="销售订单归档",
partner_label="客户",
partner_name="上海测试客户有限公司",
document_date=date(2026, 6, 11),
due_date_label="承诺日期",
due_date=date(2026, 6, 20),
status="已审核",
tax_rate=0.13,
total_amount=2500,
lines=[
ArchiveLine(
line_no=line_no,
item_code=f"FG-{line_no:03d}",
item_name=f"精密锻件{line_no}",
specification="A-100",
quantity=line_no,
delivered_or_received_quantity=0,
unit_price=100,
line_amount=100 * line_no,
promised_or_expected_date=date(2026, 6, 20),
)
for line_no in range(1, 26)
],
)
captured_line_numbers: list[int] = []
def capture_draw_cell(pdf, x, y, width, height, text, font_name, size=8, align="left"):
_ = pdf, x, y, width, height, font_name, size, align
if str(text).isdigit():
captured_line_numbers.append(int(text))
with TemporaryDirectory() as tempdir, patch("app.services.document_archives._draw_cell", side_effect=capture_draw_cell):
render_pdf_archive(context, Path(tempdir) / "archive.pdf")
for line_no in range(1, 26):
self.assertIn(line_no, captured_line_numbers)
def test_archive_table_columns_fit_inside_a4_page_frame(self) -> None:
table_x = 42
right_margin = 42
total_width = sum(width for _, width, _ in _archive_table_columns())
self.assertLessEqual(table_x + total_width, A4[0] - right_margin)
def test_draw_cell_wraps_long_text_without_ellipsis(self) -> None:
class FakePdf:
def __init__(self) -> None:
self.font_name = "Helvetica"
self.size = 8
self.drawn_texts: list[str] = []
def rect(self, *args, **kwargs) -> None:
_ = args, kwargs
def setFont(self, font_name, size) -> None:
self.font_name = font_name
self.size = size
def drawString(self, x, y, text) -> None:
_ = x, y
self.drawn_texts.append(text)
def drawRightString(self, x, y, text) -> None:
_ = x, y
self.drawn_texts.append(text)
def drawCentredString(self, x, y, text) -> None:
_ = x, y
self.drawn_texts.append(text)
fake_pdf = FakePdf()
_draw_cell(
fake_pdf,
42,
600,
62,
72,
"批次JH_不锈钢板用于测试00101_0001状态待质检入库明细备注",
"Helvetica",
8,
"left",
)
rendered_text = "".join(fake_pdf.drawn_texts)
self.assertNotIn("...", rendered_text)
self.assertEqual(rendered_text, "批次JH_不锈钢板用于测试00101_0001状态待质检入库明细备注")
for line in fake_pdf.drawn_texts:
rendered_width = pdfmetrics.stringWidth(line, fake_pdf.font_name, fake_pdf.size)
self.assertLessEqual(rendered_width, 54)
def test_draw_archive_page_frame_wraps_long_metadata_inside_columns(self) -> None:
class FakePdf:
def __init__(self) -> None:
self.font_name = "Helvetica"
self.size = 10
self.drawn_texts: list[tuple[float, float, str, str, int]] = []
def setFillColorRGB(self, *args, **kwargs) -> None:
_ = args, kwargs
def rect(self, *args, **kwargs) -> None:
_ = args, kwargs
def setStrokeColor(self, *args, **kwargs) -> None:
_ = args, kwargs
def setStrokeColorRGB(self, *args, **kwargs) -> None:
_ = args, kwargs
def setFillColor(self, *args, **kwargs) -> None:
_ = args, kwargs
def setFont(self, font_name, size) -> None:
self.font_name = font_name
self.size = size
def drawRightString(self, x, y, text) -> None:
_ = x, y, text
def drawCentredString(self, x, y, text) -> None:
_ = x, y, text
def drawString(self, x, y, text) -> None:
self.drawn_texts.append((x, y, text, self.font_name, self.size))
def rotate(self, *args, **kwargs) -> None:
_ = args, kwargs
fake_pdf = FakePdf()
context = ArchiveContext(
document_type=DOCUMENT_TYPE_SALES_ORDER,
business_id=100,
document_no="生产领料-JH_不锈钢板-用于测试00101_0001-苹果电脑后盖-超长批次-客户特殊版本-返工追溯链路",
title="生产领料出库单归档",
partner_label="生产产品",
partner_name="苹果电脑后盖-测试",
document_date=date(2026, 6, 11),
due_date_label="生产台账",
due_date=2,
status="在生产",
tax_rate=0,
total_amount=636,
contact_name="",
contact_phone="",
address="原材料库",
)
table_y = _draw_archive_page_frame(fake_pdf, context, "Helvetica", "Helvetica-Bold", 1, 1)
metadata_lines = [
(text, font_name, size)
for x, y, text, font_name, size in fake_pdf.drawn_texts
if x == 42 and y > A4[1] - 190
]
document_no_lines = [text for text, _, _ in metadata_lines if "单据编号" in text or "返工追溯" in text]
self.assertGreaterEqual(len(document_no_lines), 2)
self.assertLess(table_y, A4[1] - 190)
for text, font_name, size in metadata_lines:
rendered_width = pdfmetrics.stringWidth(text, font_name, size)
self.assertLessEqual(rendered_width, 288)
def test_generate_archive_failed_row_uses_free_version_after_collision(self) -> None:
order = self._seed_sales_order()
def insert_colliding_archive_and_fail(context, output_path):
_ = context, output_path
self.db.add(
DocumentArchive(
document_type=DOCUMENT_TYPE_SALES_ORDER,
business_id=order.id,
document_no=order.order_no,
archive_version=1,
template_version=TEMPLATE_VERSION,
file_format=FILE_FORMAT_PDF,
file_name="existing.pdf",
file_path="/tmp/existing.pdf",
file_hash="existing",
status=ARCHIVE_STATUS_READY,
error_message=None,
created_by=None,
)
)
self.db.commit()
raise RuntimeError("render failed after version collision")
with patch("app.services.document_archives.render_pdf_archive", side_effect=insert_colliding_archive_and_fail):
result = generate_document_archive(self.db, DOCUMENT_TYPE_SALES_ORDER, order.id)
self.assertEqual(result.archive_status, ARCHIVE_STATUS_FAILED)
self.assertEqual(result.archive_version, 2)
failed_archive = self.db.execute(
select(DocumentArchive).where(
DocumentArchive.document_type == DOCUMENT_TYPE_SALES_ORDER,
DocumentArchive.business_id == order.id,
DocumentArchive.status == ARCHIVE_STATUS_FAILED,
)
).scalar_one()
self.assertEqual(failed_archive.archive_version, 2)
self.assertIn("render failed after version collision", failed_archive.error_message or "")
if __name__ == "__main__":
unittest.main()