850 lines
33 KiB
Python
850 lines
33 KiB
Python
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()
|