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_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_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,
) -> None:
    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, 10)
    pdf.drawString(42, height - 92, f"单据编号: {context.document_no}")
    pdf.drawString(42, height - 112, f"{context.partner_label}: {context.partner_name}")
    pdf.drawString(42, height - 132, f"联系人: {context.contact_name or ''}  电话: {context.contact_phone or ''}")
    pdf.drawString(42, height - 152, f"{context.address_label or '地址'}: {context.address or ''}")
    pdf.drawString(340, height - 92, f"单据日期: {date_text(context.document_date)}")
    pdf.drawString(340, height - 112, f"{context.due_date_label}: {date_text(context.due_date)}")
    pdf.drawString(340, height - 132, f"状态: {context.status}")
    pdf.drawString(340, height - 152, f"税率: {decimal_text((context.tax_rate or 0) * 100)}%")

    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)


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)
    height = A4[1]
    table_y = height - ARCHIVE_TABLE_TOP_Y_OFFSET
    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):
        _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
