from __future__ import annotations import hashlib import re import zipfile from dataclasses import dataclass, field from datetime import date, datetime from decimal import Decimal from pathlib import Path from typing import Iterable from fastapi import HTTPException from reportlab.lib import colors from reportlab.lib.pagesizes import A4 from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.cidfonts import UnicodeCIDFont from reportlab.pdfgen import canvas from sqlalchemy import case, func, or_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, aliased from app.models.document_archive import DocumentArchive from app.models.master_data import Item, Warehouse from app.models.org import Employee, User from app.models.operations import ( CompletionReceipt, InventoryTxn, ProductionBatchLedger, ProductionBatchLedgerTxn, PurchaseOrder, PurchaseOrderItem, PurchaseReceipt, PurchaseReceiptItem, StockLot, Supplier, WarehouseLocation, WorkOrder, ) from app.models.sales import Customer, SalesOrder, SalesOrderItem from app.schemas.document_archives import DocumentArchiveGenerateResult DOCUMENT_TYPE_SALES_ORDER = "销售订单" DOCUMENT_TYPE_PURCHASE_ORDER = "采购订单" DOCUMENT_TYPE_PURCHASE_RECEIPT = "到货入库单" DOCUMENT_TYPE_QUALITY_INSPECTION = "质量校验单" DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT = "生产领料出库单" DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT = "生产入库结算单" DOCUMENT_TYPE_WAREHOUSE_OPERATION = "仓库出入库单" ARCHIVE_STATUS_READY = "已归档" ARCHIVE_STATUS_FAILED = "归档失败" ARCHIVE_STATUS_MISSING = "未生成" TEMPLATE_VERSION = "单据纸面V1" FILE_FORMAT_PDF = "PDF" ARCHIVE_TABLE_X = 42 ARCHIVE_TABLE_RIGHT_MARGIN = 42 ARCHIVE_TABLE_TOP_Y_OFFSET = 190 ARCHIVE_TABLE_BOTTOM_Y = 132 ARCHIVE_MIN_ROW_HEIGHT = 24 ARCHIVE_CELL_HORIZONTAL_PADDING = 8 ARCHIVE_CELL_VERTICAL_PADDING = 8 ARCHIVE_CELL_FONT_SIZE = 8 ARCHIVE_METADATA_FONT_SIZE = 10 ARCHIVE_METADATA_LEFT_X = 42 ARCHIVE_METADATA_RIGHT_X = 340 ARCHIVE_METADATA_COLUMN_GAP = 10 ARCHIVE_METADATA_LEFT_WIDTH = ARCHIVE_METADATA_RIGHT_X - ARCHIVE_METADATA_LEFT_X - ARCHIVE_METADATA_COLUMN_GAP ARCHIVE_METADATA_RIGHT_WIDTH = A4[0] - ARCHIVE_TABLE_RIGHT_MARGIN - ARCHIVE_METADATA_RIGHT_X ARCHIVE_METADATA_TOP_Y_OFFSET = 92 ARCHIVE_METADATA_MIN_ROW_HEIGHT = 20 ARCHIVE_METADATA_ROW_PADDING = 7 ARCHIVE_METADATA_TABLE_GAP = 18 ARCHIVE_STATUS_LABELS = { "ACTIVE": "启用", "ENABLED": "启用", "INACTIVE": "停用", "DISABLED": "停用", "OPEN": "未开始", "NEW": "新建", "DRAFT": "草稿", "SUBMITTED": "已提交", "APPROVED": "已审批", "PARTIAL": "部分完成", "PARTIAL_RECEIVED": "部分收货", "CLOSED": "已关闭", "CANCELED": "已取消", "CANCELLED": "已取消", "ORDERED": "已下单", "RECEIVED": "已收货", "RELEASED": "已下达", "IN_PROGRESS": "进行中", "READY_TO_RECEIPT": "待成品入库", "COMPLETED": "已完工", "SETTLED": "已结单", "DONE": "已完成", "PENDING": "待处理", "PENDING_QC": "待质检", "PASS": "已放行", "PASSED": "已放行", "REJECT": "已拒收", "REJECTED": "已拒收", "POSTED": "已过账", "AVAILABLE": "可用", "LOCKED": "已锁单", "DEPLETED": "已耗尽", "CONFIRMED": "已确认", "OVERRIDDEN": "已覆盖", "CREATED": "已创建", "IDLE": "空闲", "READY": "就绪", "ERROR": "异常", "FAILED": "失败", "EXPORTED": "已导出", "IMPORTED": "已导入", "SUPERSEDED": "已替代", "MINIAPP_ONLY": "小程序清单", "HAS_RETURN": "存在退货", } PRODUCTION_INBOUND_TXN_LABELS = { "FG_IN": "成品入库", "PRODUCTION_FINISHED_IN": "成品入库", "PRODUCTION_SURPLUS_IN": "生产余料入库", "PRODUCTION_SCRAP_IN": "生产废料入库", } WAREHOUSE_OPERATION_TXN_LABELS = { "CUSTOMER_SUPPLIED": "客料入库", "CUSTOMER_SUPPLIED_IN": "客料入库", "OPENING_IN": "期初入库", "PURCHASE_IN": "采购入库", "PRODUCTION_SURPLUS_IN": "生产余料入库", "OUTSOURCING_SURPLUS_IN": "委外余料入库", "PRODUCTION_SCRAP_IN": "生产废料入库", "REWORK_SCRAP_IN": "返工废料入库", "OUTSOURCING_SCRAP_IN": "委外废料入库", "RETURN_SCRAP_IN": "退货废料入库", "WIP_IN": "产中入库", "WIP_OUT": "产中出库", "OUTSOURCING_IN": "委外入库", "OUTSOURCING_OUT": "委外出库", "PRODUCTION_FINISHED_IN": "生产入库", "FG_IN": "成品入库", "REWORK_FINISHED_IN": "返工入库", "PRODUCTION_OUT": "生产出库", "MATERIAL_ISSUE": "生产领料出库", "SCRAP_SALE_OUT": "售卖出库", "PURCHASE_RETURN_OUT": "退货出库", "SPECIAL_IN": "特殊入库", "SPECIAL_OUT": "特殊出库", "SALES_OUT": "销售出库", "SCRAP_IN": "废料入库", "SCRAP_OUT": "废料出库", "RETURN_IN": "退货入库", "RETURN_OUT": "退货出库", "AUX_IN": "辅料入库", "AUX_OUT": "辅料出库", "RAW_IN": "原材料入库", "RAW_OUT": "原材料出库", "SEMI_FINISHED_IN": "半成品入库", "SEMI_FINISHED_OUT": "半成品出库", "FINISHED_IN": "成品入库", "FINISHED_OUT": "成品出库", "TRANSFER_IN": "调拨入库", "TRANSFER_OUT": "调拨出库", "STOCKTAKE_GAIN": "盘盈入库", "STOCKTAKE_LOSS": "盘亏出库", } PRODUCTION_DOCUMENT_BATCH_PREFIX = "单据批次号:" def production_document_batch_no_from_text(value: str | None) -> str | None: text = str(value or "").strip() if not text: return None for part in re.split(r"[;;]", text): current = part.strip() if current.startswith(PRODUCTION_DOCUMENT_BATCH_PREFIX): batch_no = current[len(PRODUCTION_DOCUMENT_BATCH_PREFIX):].strip() return batch_no or None return None @dataclass(slots=True) class ArchiveLine: line_no: int item_code: str item_name: str specification: str | None quantity: Decimal | float | int | None delivered_or_received_quantity: Decimal | float | int | None unit_price: Decimal | float | int | None line_amount: Decimal | float | int | None promised_or_expected_date: date | datetime | str | None = None remark: str | None = None @dataclass(slots=True) class ArchiveContext: document_type: str business_id: int document_no: str title: str partner_label: str partner_name: str document_date: date | datetime | str | None due_date_label: str due_date: date | datetime | str | None status: str tax_rate: Decimal | float | int | None total_amount: Decimal | float | int | None contact_name: str | None = None contact_phone: str | None = None address_label: str = "地址" address: str | None = None remark: str | None = None lines: list[ArchiveLine] = field(default_factory=list) prepared_by: str | None = None operator_name: str | None = None def default_archive_root() -> Path: return Path(__file__).resolve().parents[2] / "storage" / "document_archives" def safe_filename(value: str) -> str: safe = re.sub(r'[\\/:*?"<>|\s]+', "_", value.strip()) safe = re.sub(r"_+", "_", safe).strip("._") return safe or "document" def sha256_file(path: str | Path) -> str: digest = hashlib.sha256() with Path(path).open("rb") as file: for chunk in iter(lambda: file.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def decimal_text(value: Decimal | float | int | None, places: int = 2) -> str: if value is None: return "" number = Decimal(str(value)) quant = Decimal("1") if places <= 0 else Decimal("1").scaleb(-places) text = f"{number.quantize(quant):f}" return text.rstrip("0").rstrip(".") if "." in text else text def date_text(value: date | datetime | str | None) -> str: if value is None: return "" if isinstance(value, datetime): return value.strftime("%Y-%m-%d") if isinstance(value, date): return value.strftime("%Y-%m-%d") return str(value) def archive_status_text(value: str | None) -> str: text = str(value or "").strip() if not text: return "" normalized = text.upper() if normalized in ARCHIVE_STATUS_LABELS: return ARCHIVE_STATUS_LABELS[normalized] if re.fullmatch(r"[A-Z0-9_]+", text): return "未识别状态" return text def resolve_archive_user_label(db: Session, user_id: int | None) -> str | None: if user_id is None: return None row = db.execute( select(User, Employee) .outerjoin(Employee, Employee.id == User.employee_id) .where(User.id == user_id) ).first() if row is None: return None user, employee = row for value in (user.nickname, employee.employee_name if employee else None, user.username): text = str(value or "").strip() if text: return text return None def apply_archive_operator_context(db: Session, context: ArchiveContext, created_by: int | None) -> ArchiveContext: operator_label = resolve_archive_user_label(db, created_by) if operator_label: context.prepared_by = operator_label context.operator_name = context.operator_name or operator_label return context def get_latest_archive(db: Session, document_type: str, business_id: int) -> DocumentArchive | None: return db.execute( select(DocumentArchive) .where( DocumentArchive.document_type == document_type, DocumentArchive.business_id == business_id, ) .order_by(DocumentArchive.archive_version.desc(), DocumentArchive.id.desc()) .limit(1) ).scalar_one_or_none() def next_archive_version(db: Session, document_type: str, business_id: int) -> int: latest_version = db.execute( select(func.max(DocumentArchive.archive_version)).where( DocumentArchive.document_type == document_type, DocumentArchive.business_id == business_id, DocumentArchive.file_format == FILE_FORMAT_PDF, ) ).scalar_one() return int(latest_version or 0) + 1 def next_available_archive_version( db: Session, document_type: str, business_id: int, preferred_version: int, ) -> int: version = max(1, int(preferred_version)) used_versions = set( db.execute( select(DocumentArchive.archive_version).where( DocumentArchive.document_type == document_type, DocumentArchive.business_id == business_id, DocumentArchive.file_format == FILE_FORMAT_PDF, DocumentArchive.archive_version >= version, ) ).scalars() ) while version in used_versions: version += 1 return version def collect_sales_order_archive_context(db: Session, sales_order_id: int) -> ArchiveContext: row = db.execute( select(SalesOrder, Customer) .join(Customer, Customer.id == SalesOrder.customer_id) .where(SalesOrder.id == sales_order_id) ).one_or_none() if row is None: raise HTTPException(status_code=404, detail="销售订单不存在") order, customer = row item_rows = db.execute( select(SalesOrderItem, Item) .join(Item, Item.id == SalesOrderItem.product_item_id) .where(SalesOrderItem.sales_order_id == sales_order_id) .order_by(SalesOrderItem.line_no) ).all() lines = [ ArchiveLine( line_no=order_item.line_no, item_code=item.item_code, item_name=item.item_name, specification=item.specification, quantity=order_item.order_qty, delivered_or_received_quantity=order_item.delivered_qty, unit_price=order_item.unit_price, line_amount=order_item.line_amount, promised_or_expected_date=order_item.promised_date, remark=order_item.customer_part_no, ) for order_item, item in item_rows ] return ArchiveContext( document_type=DOCUMENT_TYPE_SALES_ORDER, business_id=order.id, document_no=order.order_no, title="销售订单归档", partner_label="客户", partner_name=customer.customer_name, document_date=order.order_date, due_date_label="承诺日期", due_date=order.promised_date, status=archive_status_text(order.status), tax_rate=order.tax_rate, total_amount=order.total_amount, contact_name=customer.contact_name, contact_phone=customer.contact_phone, address=order.delivery_address or customer.address, lines=lines, ) def collect_purchase_order_archive_context(db: Session, purchase_order_id: int) -> ArchiveContext: row = db.execute( select(PurchaseOrder, Supplier) .join(Supplier, Supplier.id == PurchaseOrder.supplier_id) .where(PurchaseOrder.id == purchase_order_id) ).one_or_none() if row is None: raise HTTPException(status_code=404, detail="采购订单不存在") order, supplier = row item_rows = db.execute( select(PurchaseOrderItem, Item) .join(Item, Item.id == PurchaseOrderItem.material_item_id) .where(PurchaseOrderItem.purchase_order_id == purchase_order_id) .order_by(PurchaseOrderItem.line_no) ).all() is_raw_purchase = str(order.target_warehouse_type or "").upper() == "RAW" lines = [ ArchiveLine( line_no=order_item.line_no, item_code=item.item_code, item_name=item.item_name, specification=item.specification, quantity=order_item.order_weight_kg if is_raw_purchase else order_item.order_qty, delivered_or_received_quantity=order_item.received_weight_kg if is_raw_purchase else order_item.received_qty, unit_price=order_item.unit_price, line_amount=order_item.line_amount, promised_or_expected_date=None, remark=f"kg: {order_item.remark}" if is_raw_purchase and order_item.remark else ("kg" if is_raw_purchase else order_item.remark), ) for order_item, item in item_rows ] return ArchiveContext( document_type=DOCUMENT_TYPE_PURCHASE_ORDER, business_id=order.id, document_no=order.po_no, title="采购订单归档", partner_label="供应商", partner_name=supplier.supplier_name, document_date=order.order_date, due_date_label="预计到货", due_date=order.expected_date, status=archive_status_text(order.status), tax_rate=order.tax_rate, total_amount=order.total_amount, contact_name=supplier.contact_name, contact_phone=supplier.contact_phone, address=supplier.address, remark=order.remark, lines=lines, ) def collect_purchase_receipt_archive_context(db: Session, purchase_receipt_id: int) -> ArchiveContext: row = db.execute( select(PurchaseReceipt, PurchaseOrder, Supplier, Warehouse) .join(PurchaseOrder, PurchaseOrder.id == PurchaseReceipt.purchase_order_id) .join(Supplier, Supplier.id == PurchaseOrder.supplier_id) .join(Warehouse, Warehouse.id == PurchaseReceipt.warehouse_id) .where(PurchaseReceipt.id == purchase_receipt_id) ).one_or_none() if row is None: raise HTTPException(status_code=404, detail="到货入库单不存在") receipt, order, supplier, warehouse = row item_rows = db.execute( select(PurchaseReceiptItem, Item) .join(Item, Item.id == PurchaseReceiptItem.material_item_id) .where(PurchaseReceiptItem.receipt_id == purchase_receipt_id) .order_by(PurchaseReceiptItem.line_no) ).all() is_raw_receipt = str(order.target_warehouse_type or "").upper() != "AUX" lines = [] total_amount = Decimal("0") for receipt_item, item in item_rows: received_value = receipt_item.received_weight_kg if is_raw_receipt else receipt_item.received_qty unit_cost = Decimal(str(receipt_item.unit_cost or 0)) line_amount = Decimal(str(received_value or 0)) * unit_cost total_amount += line_amount remark_parts = [f"批次:{receipt_item.lot_no}", f"状态:{archive_status_text(receipt_item.status)}"] if receipt_item.remark: remark_parts.append(str(receipt_item.remark)) lines.append( ArchiveLine( line_no=receipt_item.line_no, item_code=item.item_code, item_name=item.item_name, specification=item.specification, quantity=received_value, delivered_or_received_quantity=receipt_item.accepted_weight_kg if is_raw_receipt else receipt_item.accepted_qty, unit_price=receipt_item.unit_cost, line_amount=line_amount, promised_or_expected_date=None, remark=";".join(remark_parts), ) ) remark_parts = [] if receipt.logistics_waybill_no: remark_parts.append(f"运单号:{receipt.logistics_waybill_no}") if receipt.logistics_freight_amount is not None: remark_parts.append(f"运费:{decimal_text(receipt.logistics_freight_amount, 2)}") if receipt.logistics_photo_url: remark_parts.append("辅助照片:已上传") if receipt.remark: remark_parts.append(str(receipt.remark)) return ArchiveContext( document_type=DOCUMENT_TYPE_PURCHASE_RECEIPT, business_id=receipt.id, document_no=receipt.receipt_no, title="到货入库单归档", partner_label="供应商", partner_name=supplier.supplier_name, document_date=receipt.receipt_date, due_date_label="采购订单", due_date=order.po_no, status=archive_status_text(receipt.status), tax_rate=order.tax_rate, total_amount=total_amount, contact_name=supplier.contact_name, contact_phone=supplier.contact_phone, address=warehouse.warehouse_name, remark=";".join(remark_parts), lines=lines, ) def collect_quality_inspection_archive_context(db: Session, receipt_item_id: int) -> ArchiveContext: row = db.execute( select(PurchaseReceiptItem, PurchaseReceipt, PurchaseOrder, Supplier, Warehouse, Item) .join(PurchaseReceipt, PurchaseReceipt.id == PurchaseReceiptItem.receipt_id) .join(PurchaseOrder, PurchaseOrder.id == PurchaseReceipt.purchase_order_id) .join(Supplier, Supplier.id == PurchaseOrder.supplier_id) .join(Warehouse, Warehouse.id == PurchaseReceipt.warehouse_id) .join(Item, Item.id == PurchaseReceiptItem.material_item_id) .where(PurchaseReceiptItem.id == receipt_item_id) ).one_or_none() if row is None: raise HTTPException(status_code=404, detail="质量校验明细不存在") receipt_item, receipt, order, supplier, warehouse, item = row lot = db.scalar( select(StockLot).where( StockLot.source_doc_type == "PURCHASE_RECEIPT", StockLot.source_doc_id == receipt.id, StockLot.source_line_id == receipt_item.id, ) ) if not lot and receipt_item.merge_to_lot_id: lot = db.get(StockLot, receipt_item.merge_to_lot_id) is_raw_receipt = str(order.target_warehouse_type or "").upper() != "AUX" received_value = receipt_item.received_weight_kg if is_raw_receipt else receipt_item.received_qty accepted_value = receipt_item.accepted_weight_kg if is_raw_receipt else receipt_item.accepted_qty line_amount = Decimal(str(accepted_value or 0)) * Decimal(str(receipt_item.unit_cost or 0)) inspection_result = archive_status_text((lot.quality_status if lot else None) or receipt_item.status) inspector_name = str(lot.remark or "").strip() if lot and lot.remark else "" line_remark_parts = [f"库存批次号:{receipt_item.lot_no}", f"质检结论:{inspection_result}"] if receipt_item.remark: line_remark_parts.append(str(receipt_item.remark)) remark_parts = [f"采购订单:{order.po_no}", f"入库仓库:{warehouse.warehouse_name}"] if inspector_name: remark_parts.append(f"质检人员:{inspector_name}") if receipt.remark: remark_parts.append(str(receipt.remark)) return ArchiveContext( document_type=DOCUMENT_TYPE_QUALITY_INSPECTION, business_id=receipt_item.id, document_no=f"质检-{receipt.receipt_no}-{receipt_item.line_no}", title="质量校验单归档", partner_label="供应商", partner_name=supplier.supplier_name, document_date=getattr(receipt_item, "updated_at", None) or receipt.receipt_date, due_date_label="入库单", due_date=receipt.receipt_no, status=archive_status_text(receipt_item.status), tax_rate=order.tax_rate, total_amount=line_amount, contact_name=supplier.contact_name, contact_phone=supplier.contact_phone, address=warehouse.warehouse_name, remark=";".join(remark_parts), lines=[ ArchiveLine( line_no=receipt_item.line_no, item_code=item.item_code, item_name=item.item_name, specification=item.specification, quantity=received_value, delivered_or_received_quantity=accepted_value, unit_price=receipt_item.unit_cost, line_amount=line_amount, promised_or_expected_date=None, remark=";".join(line_remark_parts), ) ], ) def collect_production_material_out_archive_context(db: Session, production_ledger_id: int) -> ArchiveContext: material_item = aliased(Item) product_item = aliased(Item) row = db.execute( select(ProductionBatchLedger, StockLot, material_item, product_item, Warehouse) .join(StockLot, StockLot.id == ProductionBatchLedger.material_lot_id) .join(material_item, material_item.id == ProductionBatchLedger.material_item_id) .join(product_item, product_item.id == ProductionBatchLedger.product_item_id) .join(Warehouse, Warehouse.id == StockLot.warehouse_id) .where(ProductionBatchLedger.id == production_ledger_id) ).one_or_none() if row is None: raise HTTPException(status_code=404, detail="生产台账不存在") ledger, material_lot, material, product, warehouse = row issued_weight = Decimal(str(ledger.total_issued_weight_kg or 0)) unit_cost = Decimal(str(material_lot.unit_cost or 0)) total_amount = issued_weight * unit_cost issue_count = db.scalar( select(func.count(ProductionBatchLedgerTxn.id)).where( ProductionBatchLedgerTxn.production_ledger_id == ledger.id, ProductionBatchLedgerTxn.txn_type == "生产出库", ) ) or 0 line_remark_parts = [ f"库存批次号:{ledger.material_lot_no}", f"累计生产出库次数:{issue_count}", f"库外未闭环重量:{decimal_text(ledger.outside_weight_kg, 6)}kg", ] if ledger.remark: line_remark_parts.append(str(ledger.remark)) remark_parts = [ f"材料库存批次号:{ledger.material_lot_no}", f"生产产品:{product.item_name}", ] if ledger.remark: remark_parts.append(str(ledger.remark)) return ArchiveContext( document_type=DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, business_id=ledger.id, document_no=f"生产领料-{ledger.material_lot_no}-{product.item_name}", title="生产领料出库单归档", partner_label="生产产品", partner_name=product.item_name, document_date=ledger.last_issue_time or ledger.first_issue_time or ledger.updated_at, due_date_label="生产台账", due_date=ledger.id, status=archive_status_text(ledger.status), tax_rate=0, total_amount=total_amount, address=warehouse.warehouse_name, remark=";".join(remark_parts), lines=[ ArchiveLine( line_no=1, item_code=material.item_code, item_name=material.item_name, specification=material.specification, quantity=ledger.total_issued_weight_kg, delivered_or_received_quantity=ledger.outside_weight_kg, unit_price=material_lot.unit_cost, line_amount=total_amount, promised_or_expected_date=ledger.last_issue_time or ledger.first_issue_time, remark=";".join(line_remark_parts), ) ], ) def collect_production_inbound_settlement_archive_context(db: Session, inventory_txn_id: int) -> ArchiveContext: main_txn = db.get(InventoryTxn, inventory_txn_id) if main_txn is None: raise HTTPException(status_code=404, detail="生产入库库存流水不存在") if main_txn.txn_type not in PRODUCTION_INBOUND_TXN_LABELS: raise HTTPException(status_code=400, detail="该库存流水不是生产入库结算流水") ledger_row = None if main_txn.source_doc_type == "生产台账": product_item = aliased(Item) ledger_row = db.execute( select(ProductionBatchLedger, product_item) .join(product_item, product_item.id == ProductionBatchLedger.product_item_id) .where(ProductionBatchLedger.id == main_txn.source_doc_id) ).one_or_none() ledger = ledger_row[0] if ledger_row else None product = ledger_row[1] if ledger_row else None work_order_id: int | None = None if ledger is None and main_txn.source_doc_type == "FG_RECEIPT": receipt = db.get(CompletionReceipt, main_txn.source_doc_id) work_order_id = int(receipt.work_order_id) if receipt else None elif ledger is None and main_txn.source_doc_type == "WORK_ORDER": work_order_id = int(main_txn.source_doc_id or 0) or None if ledger is None and work_order_id: work_order_row = db.execute( select(WorkOrder, Item) .join(Item, Item.id == WorkOrder.product_item_id) .where(WorkOrder.id == work_order_id) ).one_or_none() product = work_order_row[1] if work_order_row else None txn_order = case( (InventoryTxn.txn_type.in_(("FG_IN", "PRODUCTION_FINISHED_IN")), 1), (InventoryTxn.txn_type == "PRODUCTION_SURPLUS_IN", 2), (InventoryTxn.txn_type == "PRODUCTION_SCRAP_IN", 3), else_=99, ) related_stmt = ( select(InventoryTxn, Item, Warehouse, StockLot) .join(Item, Item.id == InventoryTxn.item_id) .join(Warehouse, Warehouse.id == InventoryTxn.warehouse_id) .outerjoin(StockLot, StockLot.id == InventoryTxn.lot_id) .order_by(txn_order, InventoryTxn.id.asc()) ) batch_no = production_document_batch_no_from_text(main_txn.remark) if batch_no: related_stmt = related_stmt.where( InventoryTxn.remark.like(f"%{PRODUCTION_DOCUMENT_BATCH_PREFIX}{batch_no}%"), InventoryTxn.txn_type.in_(tuple(PRODUCTION_INBOUND_TXN_LABELS)), ) elif work_order_id and main_txn.source_doc_type in {"FG_RECEIPT", "WORK_ORDER"}: receipt_ids = db.scalars(select(CompletionReceipt.id).where(CompletionReceipt.work_order_id == work_order_id)).all() source_filters = [ (InventoryTxn.source_doc_type == "WORK_ORDER") & (InventoryTxn.source_doc_id == work_order_id), ] if receipt_ids: source_filters.append( (InventoryTxn.source_doc_type == "FG_RECEIPT") & (InventoryTxn.source_doc_id.in_([int(row_id) for row_id in receipt_ids])) ) related_stmt = related_stmt.where( or_(*source_filters), InventoryTxn.biz_time == main_txn.biz_time, InventoryTxn.txn_type.in_(tuple(PRODUCTION_INBOUND_TXN_LABELS)), ) else: related_stmt = related_stmt.where( InventoryTxn.source_doc_type == main_txn.source_doc_type, InventoryTxn.source_doc_id == main_txn.source_doc_id, InventoryTxn.biz_time == main_txn.biz_time, InventoryTxn.txn_type.in_(tuple(PRODUCTION_INBOUND_TXN_LABELS)), ) related_rows = db.execute(related_stmt).all() lines: list[ArchiveLine] = [] total_amount = Decimal("0") related_warehouse_names: list[str] = [] for line_no, (txn, item, warehouse, lot) in enumerate(related_rows, start=1): total_amount += Decimal(str(txn.amount or 0)) if warehouse.warehouse_name and warehouse.warehouse_name not in related_warehouse_names: related_warehouse_names.append(warehouse.warehouse_name) lot_no = lot.lot_no if lot else "" line_remark_parts = [ f"入库类型:{PRODUCTION_INBOUND_TXN_LABELS.get(txn.txn_type, txn.txn_type)}", f"入库仓库:{warehouse.warehouse_name}", ] if lot_no: line_remark_parts.append(f"库存批次号:{lot_no}") if ledger: line_remark_parts.append(f"原材料库存批次号:{ledger.material_lot_no}") if txn.remark: line_remark_parts.append(str(txn.remark)) lines.append( ArchiveLine( line_no=line_no, item_code=item.item_code, item_name=item.item_name, specification=item.specification, quantity=txn.qty_change if txn.txn_type in ("FG_IN", "PRODUCTION_FINISHED_IN") else txn.weight_change_kg, delivered_or_received_quantity=txn.weight_change_kg, unit_price=txn.unit_cost, line_amount=txn.amount, promised_or_expected_date=txn.biz_time, remark=";".join(line_remark_parts), ) ) remark_parts = [ f"库存流水号:{main_txn.txn_no}", f"入库类型:{PRODUCTION_INBOUND_TXN_LABELS.get(main_txn.txn_type, main_txn.txn_type)}", ] if ledger: remark_parts.append(f"材料库存批次号:{ledger.material_lot_no}") if related_warehouse_names: remark_parts.append(f"涉及仓库:{'、'.join(related_warehouse_names)}") if main_txn.remark: remark_parts.append(str(main_txn.remark)) due_date_label = "生产台账" if ledger else "生产工单" if work_order_id else "库存流水" due_date_value = str(ledger.id if ledger else work_order_id if work_order_id else main_txn.id) return ArchiveContext( document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, business_id=main_txn.id, document_no=f"生产入库结算-{main_txn.txn_no}", title="生产入库结算单归档", partner_label="生产产品", partner_name=product.item_name if product else "", document_date=main_txn.biz_time, due_date_label=due_date_label, due_date=due_date_value, status=PRODUCTION_INBOUND_TXN_LABELS.get(main_txn.txn_type, archive_status_text(main_txn.txn_type)), tax_rate=0, total_amount=total_amount, address_label="涉及仓库", address="、".join(related_warehouse_names), remark=";".join(remark_parts), lines=lines, ) def warehouse_operation_txn_label(txn_type: str | None) -> str: text = str(txn_type or "").strip() if not text: return "仓库操作" normalized = text.upper() label = WAREHOUSE_OPERATION_TXN_LABELS.get(normalized) if label: return label if re.fullmatch(r"[A-Za-z0-9_]+", text): return "未识别仓库操作" return text def warehouse_operation_document_title(txn_type: str | None) -> str: label = warehouse_operation_txn_label(txn_type) if label.endswith("单"): return label if label.endswith(("入库", "出库", "操作")): return f"{label}单" return f"{label}单" def warehouse_operation_document_no(title: str, txn_no: str) -> str: title_prefix = title[:-1] if title.endswith("单") else title return f"{title_prefix}-{txn_no}" def _warehouse_operation_quantity(txn: InventoryTxn) -> Decimal: qty_change = Decimal(str(txn.qty_change or 0)) weight_change = Decimal(str(txn.weight_change_kg or 0)) value = qty_change if qty_change != 0 else weight_change return abs(value) def collect_warehouse_operation_archive_context(db: Session, inventory_txn_id: int) -> ArchiveContext: source_lot = aliased(StockLot) row = db.execute( select(InventoryTxn, Item, Warehouse, WarehouseLocation, StockLot, source_lot) .join(Item, Item.id == InventoryTxn.item_id) .join(Warehouse, Warehouse.id == InventoryTxn.warehouse_id) .outerjoin(WarehouseLocation, WarehouseLocation.id == InventoryTxn.location_id) .outerjoin(StockLot, StockLot.id == InventoryTxn.lot_id) .outerjoin(source_lot, source_lot.id == StockLot.source_material_lot_id) .where(InventoryTxn.id == inventory_txn_id) ).one_or_none() if row is None: raise HTTPException(status_code=404, detail="仓库库存流水不存在") txn, item, warehouse, location, lot, source_lot_row = row document_title = warehouse_operation_document_title(txn.txn_type) operation_label = warehouse_operation_txn_label(txn.txn_type) location_name = location.location_name if location else None lot_no = lot.lot_no if lot else None source_lot_no = None if source_lot_row: source_lot_no = source_lot_row.lot_no elif lot and lot.source_material_sub_batch_no: source_lot_no = lot.source_material_sub_batch_no quantity = _warehouse_operation_quantity(txn) received_or_delivered = abs(Decimal(str(txn.weight_change_kg or 0))) or quantity line_remark_parts = [ f"库存流水号:{txn.txn_no}", f"操作类型:{operation_label}", ] if lot_no: line_remark_parts.append(f"库存批次号:{lot_no}") if source_lot_no: line_remark_parts.append(f"来源库存批次号:{source_lot_no}") if location_name: line_remark_parts.append(f"库位:{location_name}") if txn.logistics_waybill_no: line_remark_parts.append(f"运单号:{txn.logistics_waybill_no}") if txn.logistics_freight_amount is not None: line_remark_parts.append(f"运费:{decimal_text(txn.logistics_freight_amount, 2)}") if txn.logistics_photo_url: line_remark_parts.append("辅助照片:已上传") if txn.remark: line_remark_parts.append(str(txn.remark)) remark_parts = [ f"库存流水号:{txn.txn_no}", f"操作类型:{operation_label}", f"仓库:{warehouse.warehouse_name}", ] if lot_no: remark_parts.append(f"库存批次号:{lot_no}") if source_lot_no: remark_parts.append(f"来源库存批次号:{source_lot_no}") if location_name: remark_parts.append(f"库位:{location_name}") if txn.source_doc_type: remark_parts.append(f"来源单据:{txn.source_doc_type}-{txn.source_doc_id}") if txn.remark: remark_parts.append(str(txn.remark)) address_parts = [warehouse.warehouse_name] if location_name: address_parts.append(location_name) return ArchiveContext( document_type=DOCUMENT_TYPE_WAREHOUSE_OPERATION, business_id=txn.id, document_no=warehouse_operation_document_no(document_title, txn.txn_no), title=f"{document_title}归档", partner_label="仓库", partner_name=warehouse.warehouse_name, document_date=txn.biz_time, due_date_label="库存流水", due_date=txn.txn_no, status=operation_label, tax_rate=0, total_amount=txn.amount, address=" / ".join(address_parts), remark=";".join(remark_parts), lines=[ ArchiveLine( line_no=1, item_code=item.item_code, item_name=item.item_name, specification=item.specification, quantity=quantity, delivered_or_received_quantity=received_or_delivered, unit_price=txn.unit_cost, line_amount=txn.amount, promised_or_expected_date=txn.biz_time, remark=";".join(line_remark_parts), ) ], ) def collect_archive_context(db: Session, document_type: str, business_id: int) -> ArchiveContext: if document_type == DOCUMENT_TYPE_SALES_ORDER: return collect_sales_order_archive_context(db, business_id) if document_type == DOCUMENT_TYPE_PURCHASE_ORDER: return collect_purchase_order_archive_context(db, business_id) if document_type == DOCUMENT_TYPE_PURCHASE_RECEIPT: return collect_purchase_receipt_archive_context(db, business_id) if document_type == DOCUMENT_TYPE_QUALITY_INSPECTION: return collect_quality_inspection_archive_context(db, business_id) if document_type == DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT: return collect_production_material_out_archive_context(db, business_id) if document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT: return collect_production_inbound_settlement_archive_context(db, business_id) if document_type == DOCUMENT_TYPE_WAREHOUSE_OPERATION: return collect_warehouse_operation_archive_context(db, business_id) raise HTTPException(status_code=400, detail="不支持的单据类型") def _register_archive_font() -> tuple[str, str]: try: pdfmetrics.registerFont(UnicodeCIDFont("STSong-Light")) return "STSong-Light", "STSong-Light" except Exception: return "Helvetica", "Helvetica-Bold" def _cell_line_height(size: int) -> float: return size + 3 def _wrap_text_to_lines(text: str, font_name: str, size: int, max_width: float) -> list[str]: if max_width <= 0: return [""] value = str(text or "") if value == "": return [""] lines: list[str] = [] for paragraph in value.replace("\r\n", "\n").replace("\r", "\n").split("\n"): if paragraph == "": lines.append("") continue current = "" for char in paragraph: candidate = f"{current}{char}" if current and pdfmetrics.stringWidth(candidate, font_name, size) > max_width: lines.append(current) current = char else: current = candidate lines.append(current) return lines or [""] def _cell_required_height(text: str, font_name: str, size: int, width: float) -> float: lines = _wrap_text_to_lines(text, font_name, size, max(0, width - ARCHIVE_CELL_HORIZONTAL_PADDING)) return max(ARCHIVE_MIN_ROW_HEIGHT, len(lines) * _cell_line_height(size) + ARCHIVE_CELL_VERTICAL_PADDING) def _row_required_height(values: list[str], columns: list[tuple[str, int, str]], font_name: str, size: int) -> float: return max( _cell_required_height(value, font_name, size, col_w) for value, (_, col_w, _) in zip(values, columns, strict=True) ) def _draw_cell( pdf: canvas.Canvas, x: float, y: float, width: float, height: float, text: str, font_name: str, size: int = 8, align: str = "left", ) -> None: pdf.rect(x, y - height, width, height, stroke=1, fill=0) pdf.setFont(font_name, size) lines = _wrap_text_to_lines(text, font_name, size, max(0, width - ARCHIVE_CELL_HORIZONTAL_PADDING)) line_height = _cell_line_height(size) text_height = len(lines) * line_height top_padding = max(4, (height - text_height) / 2) text_y = y - top_padding - size for line in lines: if align == "right": pdf.drawRightString(x + width - 4, text_y, line) elif align == "center": pdf.drawCentredString(x + width / 2, text_y, line) else: pdf.drawString(x + 4, text_y, line) text_y -= line_height def _archive_metadata_rows(context: ArchiveContext) -> list[tuple[tuple[str, str], tuple[str, str]]]: contact_text = f"{context.contact_name or ''} 电话: {context.contact_phone or ''}" tax_text = f"{decimal_text((context.tax_rate or 0) * 100)}%" return [ (("单据编号", context.document_no), ("单据日期", date_text(context.document_date))), ((context.partner_label, context.partner_name), (context.due_date_label, date_text(context.due_date))), (("联系人", contact_text), ("状态", context.status)), ((context.address_label or "地址", context.address or ""), ("税率", tax_text)), ] def _archive_metadata_line_groups( context: ArchiveContext, font_name: str, size: int = ARCHIVE_METADATA_FONT_SIZE, ) -> list[tuple[list[str], list[str]]]: groups: list[tuple[list[str], list[str]]] = [] for (left_label, left_value), (right_label, right_value) in _archive_metadata_rows(context): left_text = f"{left_label}: {left_value or ''}" right_text = f"{right_label}: {right_value or ''}" groups.append( ( _wrap_text_to_lines(left_text, font_name, size, ARCHIVE_METADATA_LEFT_WIDTH), _wrap_text_to_lines(right_text, font_name, size, ARCHIVE_METADATA_RIGHT_WIDTH), ) ) return groups def _metadata_row_height(line_count: int, size: int = ARCHIVE_METADATA_FONT_SIZE) -> float: return max(ARCHIVE_METADATA_MIN_ROW_HEIGHT, line_count * _cell_line_height(size) + ARCHIVE_METADATA_ROW_PADDING) def _archive_table_y_for_context(context: ArchiveContext, font_name: str) -> float: _, height = A4 y = height - ARCHIVE_METADATA_TOP_Y_OFFSET for left_lines, right_lines in _archive_metadata_line_groups(context, font_name): y -= _metadata_row_height(max(len(left_lines), len(right_lines), 1)) return min(height - ARCHIVE_TABLE_TOP_Y_OFFSET, y - ARCHIVE_METADATA_TABLE_GAP) def _draw_metadata_lines( pdf: canvas.Canvas, x: float, y: float, lines: list[str], line_height: float, ) -> None: for line in lines: pdf.drawString(x, y, line) y -= line_height def _archive_table_columns() -> list[tuple[str, int, str]]: return [ ("行", 26, "center"), ("物料编码", 64, "left"), ("物料名称", 94, "left"), ("规格", 60, "left"), ("数量", 50, "right"), ("已交/收", 52, "right"), ("单价", 48, "right"), ("金额", 55, "right"), ("日期/备注", 62, "left"), ] def _draw_archive_page_frame( pdf: canvas.Canvas, context: ArchiveContext, font_name: str, bold_font_name: str, page_no: int, total_pages: int, ) -> float: width, height = A4 pdf.setFillColorRGB(0.99, 0.975, 0.92) pdf.rect(0, 0, width, height, stroke=0, fill=1) pdf.setStrokeColor(colors.black) pdf.setFillColor(colors.black) pdf.rect(28, 28, width - 56, height - 56, stroke=1, fill=0) pdf.setFont(bold_font_name, 22) title = context.title if page_no == 1 else f"{context.title}(续页)" pdf.drawCentredString(width / 2, height - 58, title) pdf.setFont(font_name, 10) pdf.drawRightString(width - 42, height - 44, "ERP留存联") pdf.drawRightString(width - 42, height - 62, "PDF归档") pdf.drawRightString(width - 42, height - 80, f"第 {page_no}/{total_pages} 页") pdf.setFont(font_name, ARCHIVE_METADATA_FONT_SIZE) line_height = _cell_line_height(ARCHIVE_METADATA_FONT_SIZE) metadata_y = height - ARCHIVE_METADATA_TOP_Y_OFFSET for left_lines, right_lines in _archive_metadata_line_groups(context, font_name): _draw_metadata_lines(pdf, ARCHIVE_METADATA_LEFT_X, metadata_y, left_lines, line_height) _draw_metadata_lines(pdf, ARCHIVE_METADATA_RIGHT_X, metadata_y, right_lines, line_height) metadata_y -= _metadata_row_height(max(len(left_lines), len(right_lines), 1)) pdf.setFont(bold_font_name, 26) pdf.setStrokeColorRGB(0.55, 0.05, 0.05) pdf.setFillColorRGB(0.65, 0.05, 0.05) pdf.rotate(12) pdf.drawString(390, 500, "PDF归档") pdf.rotate(-12) pdf.setStrokeColor(colors.black) pdf.setFillColor(colors.black) return min(height - ARCHIVE_TABLE_TOP_Y_OFFSET, metadata_y - ARCHIVE_METADATA_TABLE_GAP) def _draw_archive_table_header(pdf: canvas.Canvas, y: float, bold_font_name: str) -> None: x = ARCHIVE_TABLE_X for header, col_w, align in _archive_table_columns(): _draw_cell(pdf, x, y, col_w, ARCHIVE_MIN_ROW_HEIGHT, header, bold_font_name, ARCHIVE_CELL_FONT_SIZE, align) x += col_w def _archive_line_values(line: ArchiveLine) -> list[str]: return [ str(line.line_no), line.item_code, line.item_name, line.specification or "", decimal_text(line.quantity, 6), decimal_text(line.delivered_or_received_quantity, 6), decimal_text(line.unit_price, 4), decimal_text(line.line_amount, 2), line.remark or date_text(line.promised_or_expected_date), ] def _draw_archive_table_rows( pdf: canvas.Canvas, lines: list[ArchiveLine], y: float, font_name: str, minimum_blank_rows: int, ) -> float: columns = _archive_table_columns() for line in lines: values = _archive_line_values(line) row_h = _row_required_height(values, columns, font_name, ARCHIVE_CELL_FONT_SIZE) x = ARCHIVE_TABLE_X for value, (_, col_w, align) in zip(values, columns, strict=True): _draw_cell(pdf, x, y, col_w, row_h, value, font_name, ARCHIVE_CELL_FONT_SIZE, align) x += col_w y -= row_h for _ in range(max(0, minimum_blank_rows - len(lines))): x = ARCHIVE_TABLE_X for _, col_w, align in columns: _draw_cell(pdf, x, y, col_w, ARCHIVE_MIN_ROW_HEIGHT, "", font_name, ARCHIVE_CELL_FONT_SIZE, align) x += col_w y -= ARCHIVE_MIN_ROW_HEIGHT return y def _draw_archive_footer( pdf: canvas.Canvas, context: ArchiveContext, y: float, font_name: str, bold_font_name: str, ) -> None: width, _ = A4 pdf.setFont(bold_font_name, 10) pdf.drawRightString(width - 42, y - 20, f"合计金额: {decimal_text(context.total_amount, 2)}") pdf.setFont(font_name, 9) remark_lines = _wrap_text_to_lines(f"备注: {context.remark or ''}", font_name, 9, width - 84) remark_y = 104 for line in remark_lines: pdf.drawString(42, remark_y, line) remark_y -= _cell_line_height(9) signature_y = min(70, remark_y - 4) signature_y = max(46, signature_y) pdf.drawString(42, signature_y, f"制单: {context.prepared_by or ''}") pdf.drawString(176, signature_y, f"经办人: {context.operator_name or context.prepared_by or ''}") pdf.drawString(310, signature_y, "审核: ") pdf.drawString(444, signature_y, f"归档模板: {TEMPLATE_VERSION}") def _archive_line_height(line: ArchiveLine, font_name: str) -> float: return _row_required_height(_archive_line_values(line), _archive_table_columns(), font_name, ARCHIVE_CELL_FONT_SIZE) def _line_pages(lines: list[ArchiveLine], max_body_height: float, font_name: str) -> list[list[ArchiveLine]]: if not lines: return [[]] pages: list[list[ArchiveLine]] = [] page_lines: list[ArchiveLine] = [] used_height = 0.0 for line in lines: row_height = _archive_line_height(line, font_name) if page_lines and used_height + row_height > max_body_height: pages.append(page_lines) page_lines = [] used_height = 0.0 page_lines.append(line) used_height += row_height if page_lines: pages.append(page_lines) return pages def render_pdf_archive(context: ArchiveContext, output_path: str | Path) -> None: output = Path(output_path) output.parent.mkdir(parents=True, exist_ok=True) font_name, bold_font_name = _register_archive_font() pdf = canvas.Canvas(str(output), pagesize=A4) table_y = _archive_table_y_for_context(context, font_name) row_h = ARCHIVE_MIN_ROW_HEIGHT max_body_height = table_y - row_h - ARCHIVE_TABLE_BOTTOM_Y pages = _line_pages(context.lines, max_body_height, font_name) for page_index, page_lines in enumerate(pages, start=1): table_y = _draw_archive_page_frame(pdf, context, font_name, bold_font_name, page_index, len(pages)) _draw_archive_table_header(pdf, table_y, bold_font_name) y = _draw_archive_table_rows( pdf, page_lines, table_y - row_h, font_name, minimum_blank_rows=6 if page_index == len(pages) else 0, ) _draw_archive_footer(pdf, context, y, font_name, bold_font_name) pdf.showPage() pdf.save() def _archive_output_path(root: Path, context: ArchiveContext, archive_version: int) -> Path: type_dir = safe_filename(context.document_type) file_name = safe_filename(f"{context.document_no}_V{archive_version}") + ".pdf" return root / type_dir / file_name def _failed_archive( *, document_type: str, business_id: int, document_no: str, archive_version: int, error_message: str, created_by: int | None, ) -> DocumentArchive: return DocumentArchive( document_type=document_type, business_id=business_id, document_no=document_no, archive_version=archive_version, template_version=TEMPLATE_VERSION, file_format=FILE_FORMAT_PDF, file_name="", file_path="", file_hash=None, status=ARCHIVE_STATUS_FAILED, error_message=error_message[:2000], created_by=created_by, ) def _fallback_failed_archive_version(db: Session, document_type: str, business_id: int) -> int: try: return next_available_archive_version(db, document_type, business_id, 1) except Exception: db.rollback() return 1 def _persist_failed_archive_with_retry( db: Session, *, document_type: str, business_id: int, document_no: str, preferred_version: int, error_message: str, created_by: int | None, max_attempts: int = 3, ) -> tuple[int, str]: archive_version = preferred_version last_error = error_message for _ in range(max_attempts): try: archive_version = next_available_archive_version(db, document_type, business_id, archive_version) failed = _failed_archive( document_type=document_type, business_id=business_id, document_no=document_no, archive_version=archive_version, error_message=error_message, created_by=created_by, ) db.add(failed) db.commit() return failed.archive_version, failed.error_message or error_message except IntegrityError as exc: db.rollback() archive_version += 1 last_error = f"{error_message}; failed archive version collision: {exc}" except Exception as exc: db.rollback() return archive_version, f"{error_message}; failed archive persist error: {exc}" return archive_version, last_error def generate_document_archive( db: Session, document_type: str, business_id: int, created_by: int | None = None, archive_root: str | Path | None = None, ) -> DocumentArchiveGenerateResult: archive_version: int | None = None document_no = f"{document_type}-{business_id}" try: archive_version = next_archive_version(db, document_type, business_id) context = collect_archive_context(db, document_type, business_id) apply_archive_operator_context(db, context, created_by) document_no = context.document_no root = Path(archive_root) if archive_root is not None else Path(default_archive_root()) for _ in range(3): archive_version = next_available_archive_version(db, document_type, business_id, archive_version) output_path = _archive_output_path(root, context, archive_version) render_pdf_archive(context, output_path) archive = DocumentArchive( document_type=document_type, business_id=business_id, document_no=context.document_no, archive_version=archive_version, template_version=TEMPLATE_VERSION, file_format=FILE_FORMAT_PDF, file_name=output_path.name, file_path=str(output_path), file_hash=sha256_file(output_path), status=ARCHIVE_STATUS_READY, error_message=None, created_by=created_by, ) db.add(archive) try: db.commit() db.refresh(archive) return DocumentArchiveGenerateResult( business_id=business_id, document_type=document_type, document_no=context.document_no, archive_status=ARCHIVE_STATUS_READY, archive_version=archive.archive_version, archive_error_message=None, ) except IntegrityError: db.rollback() archive_version += 1 raise RuntimeError("archive version collision after retries") except HTTPException: db.rollback() raise except Exception as exc: db.rollback() failed_archive_version, failed_error_message = _persist_failed_archive_with_retry( db, document_type=document_type, business_id=business_id, document_no=document_no, preferred_version=archive_version or _fallback_failed_archive_version(db, document_type, business_id), error_message=str(exc), created_by=created_by, ) return DocumentArchiveGenerateResult( business_id=business_id, document_type=document_type, document_no=document_no, archive_status=ARCHIVE_STATUS_FAILED, archive_version=failed_archive_version, archive_error_message=failed_error_message, ) def build_archive_zip( db: Session, document_type: str, business_ids: Iterable[int], output_path: str | Path, ) -> Path: output = Path(output_path) output.parent.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive_zip: for business_id in business_ids: latest = get_latest_archive(db, document_type, business_id) if latest is None or latest.status != ARCHIVE_STATUS_READY: continue file_path = Path(latest.file_path) if not file_path.exists(): continue archive_zip.write(file_path, arcname=latest.file_name or file_path.name) return output