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_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_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()