# Sales Purchase Document Form Archive Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 将销售订单、采购订单新增/编辑抽屉升级为真实纸质业务单据风格,并在保存成功后由后端生成 PDF 归档快照,支持预览、下载、批量下载和归档失败重试。 **Architecture:** 后端新增通用 `document_archives` 归档底座,销售/采购路由在业务保存后调用归档服务生成 PDF,归档失败不阻断业务保存但必须产生可见状态。前端新增单据纸面组件族,销售订单、采购订单只替换抽屉内部表现形式,不改变字段、计算和提交语义。 **Tech Stack:** FastAPI, SQLAlchemy 2, Pydantic, ReportLab PDF, Python `zipfile`, Vue 3 Composition API, Vite, CSS design tokens. --- ## Scope And Non-Negotiables - 第一版只覆盖销售订单和采购订单,不扩展到入库单、质检单、仓库出入库单、盘库单。 - 外层仍使用现有 `FormDrawer` 入口,内层替换为 v3 确认过的“工厂联单/归档单”视觉。 - 不删除销售订单、采购订单现有字段,不改变 payload 字段名和计算逻辑。 - PDF 是主归档文件,Word 不在本计划中实现为主留档。 - 合同生成逻辑保持独立,不复用当前 `window.print()` 合同代码作为订单归档。 - 业务保存成功后生成 PDF;PDF 生成失败不能回滚业务单据,但列表必须显示“归档失败”并允许重试。 - 归档状态、业务文案和错误信息使用中文:`未生成`、`已归档`、`归档失败`。 - 当前项目目录可能不是 git 工作区。执行任务时若 `git status` 可用,每个任务结束提交一次;若不可用,记录变更文件和验证结果。 ## Visual Benchmark 已确认的视觉基准: - `outputs/document-form-mockups/sales-order-document-form-mockup-v3-real-paper.png` - `outputs/document-form-mockups/purchase-order-document-form-mockup-v3-real-paper.png` - `outputs/document-form-mockups/order-form-document-mockup-v3.html` 必须保留的视觉特征: - 真实米白纸张、轻纸纹、黑色表格线。 - 金属夹、叠纸阴影、装订孔。 - 标题居中大黑字:`销售订单`、`采购订单`。 - 右上角红/绿单据编号章。 - 竖向 `ERP留存联` 标记。 - PDF 归档章不能遮挡核心数据。 - 明细区域是纸面表格行,不再是多张卡片堆叠。 - 底部有签字/确认栏。 ## File Structure ### Backend - Create: `backend/app/models/document_archive.py` - SQLAlchemy model for `document_archives`. - Modify: `backend/app/models/__init__.py` - Export `DocumentArchive` so `Base.metadata.create_all()` includes the table in tests. - Create: `backend/app/schemas/document_archives.py` - Pydantic read models and batch download request. - Modify: `backend/app/schemas/database.py` - Add archive fields to `SalesOrderRead` and `SalesOrderCreateResult`. - Modify: `backend/app/schemas/operations.py` - Add archive fields to `PurchaseOrderRead`. - Create: `backend/sql/add_document_archives_patch.sql` - MySQL migration for the archive table. - Modify: `backend/requirements.txt` - Add ReportLab dependency. - Create: `backend/app/services/document_archives.py` - Collect sales/purchase data, render PDF, persist archive rows, return latest archive, build zip. - Create: `backend/app/api/routes/document_archives.py` - Generic generate/preview/download/batch download endpoints. - Modify: `backend/app/api/router.py` - Register document archive router. - Modify: `backend/app/services/sales_planning.py` - Enrich sales order query with latest archive metadata. - Modify: `backend/app/services/operations.py` - Enrich purchase order query with latest archive metadata. - Modify: `backend/app/api/routes/sales.py` - Generate archive after sales order save. - Modify: `backend/app/api/routes/purchase.py` - Generate archive after purchase order create/update. - Create: `backend/tests/test_document_archive_model.py` - Create: `backend/tests/test_document_archive_service.py` - Create: `backend/tests/test_document_archive_routes.py` - Create: `backend/tests/test_sales_order_document_archive.py` - Create: `backend/tests/test_purchase_order_document_archive.py` ### Frontend - Create: `frontend/src/components/documentForms/DocumentPaper.vue` - Paper shell: clip, stacked paper, binder holes, title, document number stamp, retained-copy mark. - Create: `frontend/src/components/documentForms/DocumentGrid.vue` - Paper-style label/value grid for header fields. - Create: `frontend/src/components/documentForms/DocumentLineTable.vue` - Paper-style editable line table wrapper. - Create: `frontend/src/components/documentForms/DocumentArchiveActions.vue` - Archive status, preview, download, retry, batch download buttons. - Modify: `frontend/src/styles/main.css` - Add document paper CSS tokens and responsive layout. - Modify: `frontend/src/services/api.js` - Keep `downloadResource()` and `openResource()`; add `postDownloadResource()` for batch zip. - Modify: `frontend/src/views/SalesPlanningView.vue` - Replace sales order drawer internals with document form components and add archive actions to list. - Modify: `frontend/src/views/PurchaseOrderView.vue` - Replace purchase order drawer internals with document form components and add archive actions to list. - Create: `frontend/scripts/test-document-form-components.mjs` - Create: `frontend/scripts/test-sales-purchase-document-archive-ui.mjs` --- ### Task 1: Archive Data Model And SQL Foundation **Files:** - Create: `backend/app/models/document_archive.py` - Modify: `backend/app/models/__init__.py` - Create: `backend/app/schemas/document_archives.py` - Modify: `backend/app/schemas/database.py` - Modify: `backend/app/schemas/operations.py` - Create: `backend/sql/add_document_archives_patch.sql` - Test: `backend/tests/test_document_archive_model.py` - [ ] **Step 1: Write the failing model test** Create `backend/tests/test_document_archive_model.py`: ```python from __future__ import annotations import unittest from datetime import datetime from sqlalchemy import BigInteger, create_engine, select from sqlalchemy.ext.compiler import compiles from sqlalchemy.orm import sessionmaker @compiles(BigInteger, "sqlite") def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: _ = type_, compiler, kw return "INTEGER" import app.models.document_archive # noqa: E402,F401 import app.models.master_data # noqa: E402,F401 import app.models.miniapp # noqa: E402,F401 import app.models.operations # noqa: E402,F401 import app.models.org # noqa: E402,F401 import app.models.planning # noqa: E402,F401 import app.models.sales # noqa: E402,F401 from app.models.base import Base # noqa: E402 from app.models.document_archive import DocumentArchive # noqa: E402 class DocumentArchiveModelTest(unittest.TestCase): def setUp(self) -> None: engine = create_engine("sqlite+pysqlite:///:memory:", future=True) Base.metadata.create_all(engine) self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) self.db = self.SessionLocal() def tearDown(self) -> None: self.db.close() def test_document_archive_round_trips_chinese_business_status(self) -> None: row = DocumentArchive( document_type="销售订单", business_id=101, document_no="销售2026-00001", archive_version=1, template_version="单据纸面V1", file_format="PDF", file_name="销售订单-销售2026-00001-v1.pdf", file_path="outputs/document_archives/sales_order/销售订单-销售2026-00001-v1.pdf", file_hash="abc123", status="已归档", error_message=None, created_by=None, created_at=datetime(2026, 6, 11, 10, 0, 0), updated_at=datetime(2026, 6, 11, 10, 0, 0), ) self.db.add(row) self.db.commit() saved = self.db.scalar(select(DocumentArchive).where(DocumentArchive.document_type == "销售订单")) self.assertIsNotNone(saved) self.assertEqual(saved.status, "已归档") self.assertEqual(saved.template_version, "单据纸面V1") self.assertEqual(saved.archive_version, 1) ``` - [ ] **Step 2: Run the model test to verify it fails** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP PYTHONPATH=backend pytest backend/tests/test_document_archive_model.py -q ``` Expected: FAIL with `ModuleNotFoundError: No module named 'app.models.document_archive'`. - [ ] **Step 3: Create the SQLAlchemy model** Create `backend/app/models/document_archive.py`: ```python from __future__ import annotations from sqlalchemy import BigInteger, DateTime, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column from app.models.base import Base class DocumentArchive(Base): __tablename__ = "document_archives" id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) document_type: Mapped[str] = mapped_column(String(50)) business_id: Mapped[int] = mapped_column(BigInteger) document_no: Mapped[str] = mapped_column(String(100)) archive_version: Mapped[int] = mapped_column(Integer) template_version: Mapped[str] = mapped_column(String(50)) file_format: Mapped[str] = mapped_column(String(20)) file_name: Mapped[str] = mapped_column(String(255)) file_path: Mapped[str] = mapped_column(String(500)) file_hash: Mapped[str | None] = mapped_column(String(128), nullable=True) status: Mapped[str] = mapped_column(String(32)) error_message: Mapped[str | None] = mapped_column(Text, nullable=True) created_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True) created_at: Mapped[object] = mapped_column(DateTime) updated_at: Mapped[object] = mapped_column(DateTime) ``` - [ ] **Step 4: Export the model** Modify `backend/app/models/__init__.py`: ```python from app.models.document_archive import DocumentArchive ``` Add `"DocumentArchive"` to `__all__`. - [ ] **Step 5: Create archive schemas** Create `backend/app/schemas/document_archives.py`: ```python from __future__ import annotations from datetime import datetime from pydantic import BaseModel, Field class DocumentArchiveRead(BaseModel): archive_id: int document_type: str business_id: int document_no: str archive_version: int template_version: str file_format: str file_name: str status: str error_message: str | None = None created_at: datetime class DocumentArchiveGenerateResult(BaseModel): business_id: int document_type: str document_no: str archive_status: str archive_version: int | None = None archive_error_message: str | None = None class DocumentArchiveBatchDownloadRequest(BaseModel): document_type: str business_ids: list[int] = Field(default_factory=list, min_length=1) ``` - [ ] **Step 6: Add archive fields to sales schemas** Modify `backend/app/schemas/database.py`. In `SalesOrderRead`, add: ```python archive_status: str = "未生成" archive_version: int | None = None archive_created_at: datetime | None = None archive_error_message: str | None = None ``` In `SalesOrderCreateResult`, add: ```python archive_status: str = "未生成" archive_version: int | None = None archive_error_message: str | None = None ``` If `datetime` is not already imported in `backend/app/schemas/database.py`, extend the existing datetime import to include it. - [ ] **Step 7: Add archive fields to purchase schemas** Modify `backend/app/schemas/operations.py`. In `PurchaseOrderRead`, add: ```python archive_status: str = "未生成" archive_version: int | None = None archive_created_at: datetime | None = None archive_error_message: str | None = None ``` If `datetime` is not already imported in `backend/app/schemas/operations.py`, extend the existing datetime import to include it. - [ ] **Step 8: Create the MySQL SQL patch** Create `backend/sql/add_document_archives_patch.sql`: ```sql CREATE TABLE IF NOT EXISTS document_archives ( id BIGINT PRIMARY KEY AUTO_INCREMENT, document_type VARCHAR(50) NOT NULL COMMENT '单据类型:销售订单/采购订单', business_id BIGINT NOT NULL COMMENT '业务主表ID', document_no VARCHAR(100) NOT NULL COMMENT '业务单号', archive_version INT NOT NULL COMMENT '归档版本', template_version VARCHAR(50) NOT NULL DEFAULT '单据纸面V1' COMMENT '模板版本', file_format VARCHAR(20) NOT NULL DEFAULT 'PDF' COMMENT '文件格式', file_name VARCHAR(255) NOT NULL COMMENT '文件名', file_path VARCHAR(500) NOT NULL COMMENT '文件路径', file_hash VARCHAR(128) NULL COMMENT '文件哈希', status VARCHAR(32) NOT NULL COMMENT '归档状态:已归档/归档失败', error_message TEXT NULL COMMENT '归档失败原因', created_by BIGINT NULL COMMENT '生成用户ID', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_document_archives_version (document_type, business_id, archive_version, file_format), KEY idx_document_archives_business_latest (document_type, business_id, archive_version), KEY idx_document_archives_document_no (document_no), KEY idx_document_archives_status (status, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ``` - [ ] **Step 9: Run the model test to verify it passes** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP PYTHONPATH=backend pytest backend/tests/test_document_archive_model.py -q ``` Expected: PASS. - [ ] **Step 10: Record checkpoint** Run: ```bash git status --short || true ``` Expected in this workspace: either changed files are listed, or git reports this directory is not a git repository. --- ### Task 2: PDF Archive Rendering Service **Files:** - Modify: `backend/requirements.txt` - Create: `backend/app/services/document_archives.py` - Test: `backend/tests/test_document_archive_service.py` - [ ] **Step 1: Add a PDF dependency** Modify `backend/requirements.txt` and append: ```text reportlab>=4.2,<5.0 ``` - [ ] **Step 2: Write the failing service test** Create `backend/tests/test_document_archive_service.py`: ```python from __future__ import annotations import unittest from datetime import date, datetime from pathlib import Path from tempfile import TemporaryDirectory from sqlalchemy import BigInteger, create_engine from sqlalchemy.ext.compiler import compiles from sqlalchemy.orm import sessionmaker @compiles(BigInteger, "sqlite") def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: _ = type_, compiler, kw return "INTEGER" import app.models.document_archive # noqa: E402,F401 import app.models.master_data # noqa: E402,F401 import app.models.miniapp # noqa: E402,F401 import app.models.operations # noqa: E402,F401 import app.models.org # noqa: E402,F401 import app.models.planning # noqa: E402,F401 import app.models.sales # noqa: E402,F401 from app.models.base import Base # noqa: E402 from app.models.document_archive import DocumentArchive # noqa: E402 from app.models.master_data import Item # noqa: E402 from app.models.sales import Customer, SalesOrder, SalesOrderItem # noqa: E402 from app.services.document_archives import ( # noqa: E402 DOCUMENT_TYPE_SALES_ORDER, generate_document_archive, get_latest_archive, ) class DocumentArchiveServiceTest(unittest.TestCase): def setUp(self) -> None: engine = create_engine("sqlite+pysqlite:///:memory:", future=True) Base.metadata.create_all(engine) self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) self.db = self.SessionLocal() now = datetime(2026, 6, 11, 10, 0, 0) self.customer = Customer( id=1, customer_code="CUS001", customer_name="嘉恒客户", short_name="嘉恒", status="ACTIVE", created_at=now, updated_at=now, ) self.product = Item( id=10, item_code="CP00010", item_name="钢制碗", item_type="FINISHED", unit_id=None, status="ACTIVE", created_at=now, updated_at=now, ) self.order = SalesOrder( id=100, order_no="销售2026-00001", customer_id=1, order_date=date(2026, 6, 11), promised_date=date(2026, 6, 20), sales_employee_id=None, delivery_address="宁波市测试路1号", tax_rate=0.13, total_amount=600, status="OPEN", created_at=now, updated_at=now, ) self.item = SalesOrderItem( id=1000, sales_order_id=100, line_no=1, product_item_id=10, customer_part_no="KH-001", order_qty=100, delivered_qty=0, unit_price=6, line_amount=600, promised_date=date(2026, 6, 20), status="OPEN", created_at=now, updated_at=now, ) self.db.add_all([self.customer, self.product, self.order, self.item]) self.db.commit() self.tempdir = TemporaryDirectory() def tearDown(self) -> None: self.tempdir.cleanup() self.db.close() def test_generate_sales_order_archive_creates_pdf_and_archive_row(self) -> None: result = generate_document_archive( self.db, document_type=DOCUMENT_TYPE_SALES_ORDER, business_id=100, created_by=None, archive_root=Path(self.tempdir.name), ) self.assertEqual(result.archive_status, "已归档") self.assertEqual(result.archive_version, 1) archive = get_latest_archive(self.db, DOCUMENT_TYPE_SALES_ORDER, 100) self.assertIsNotNone(archive) self.assertEqual(archive.document_no, "销售2026-00001") pdf_path = Path(archive.file_path) self.assertTrue(pdf_path.exists()) self.assertEqual(pdf_path.read_bytes()[:4], b"%PDF") def test_generate_archive_uses_version_sequence(self) -> None: generate_document_archive(self.db, DOCUMENT_TYPE_SALES_ORDER, 100, archive_root=Path(self.tempdir.name)) second = generate_document_archive(self.db, DOCUMENT_TYPE_SALES_ORDER, 100, archive_root=Path(self.tempdir.name)) self.assertEqual(second.archive_version, 2) rows = self.db.query(DocumentArchive).filter(DocumentArchive.business_id == 100).all() self.assertEqual(len(rows), 2) ``` - [ ] **Step 3: Run the service test to verify it fails** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP PYTHONPATH=backend pytest backend/tests/test_document_archive_service.py -q ``` Expected: FAIL with `ModuleNotFoundError: No module named 'app.services.document_archives'`. - [ ] **Step 4: Create service constants and helpers** Create `backend/app/services/document_archives.py` with these top-level constants and helpers: ```python from __future__ import annotations import hashlib import re from dataclasses import dataclass from datetime import datetime from decimal import Decimal from pathlib import Path from zipfile import ZIP_DEFLATED, ZipFile 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 func, select from sqlalchemy.orm import Session, aliased from app.models.document_archive import DocumentArchive from app.models.master_data import Item from app.models.operations import PurchaseOrder, PurchaseOrderItem, PurchaseOrderSalesOrderLink, Supplier from app.models.org import Employee from app.models.sales import Customer, SalesOrder, SalesOrderItem from app.schemas.document_archives import DocumentArchiveGenerateResult DOCUMENT_TYPE_SALES_ORDER = "销售订单" DOCUMENT_TYPE_PURCHASE_ORDER = "采购订单" ARCHIVE_STATUS_READY = "已归档" ARCHIVE_STATUS_FAILED = "归档失败" ARCHIVE_STATUS_MISSING = "未生成" TEMPLATE_VERSION = "单据纸面V1" FILE_FORMAT_PDF = "PDF" @dataclass(frozen=True) class ArchiveLine: line_no: int name: str code: str spec: str qty_text: str price_text: str amount_text: str date_text: str remark: str @dataclass(frozen=True) class ArchiveContext: document_type: str document_no: str title: str party_label: str party_name: str owner_label: str owner_name: str date_label: str date_text: str promised_label: str promised_text: str tax_rate_text: str address_or_target_text: str remark: str total_label: str total_text: str lines: list[ArchiveLine] signature_labels: tuple[str, str, str, str] def default_archive_root() -> Path: return Path(__file__).resolve().parents[3] / "outputs" / "document_archives" def safe_filename(value: str) -> str: normalized = re.sub(r"[\\/:*?\"<>|\\s]+", "-", str(value or "").strip()) return normalized.strip("-") or "单据" def sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def decimal_text(value: object, places: int = 2) -> str: amount = Decimal(str(value or 0)) return f"{amount:,.{places}f}" def date_text(value: object | None) -> str: return str(value or "-")[:10] ``` - [ ] **Step 5: Add latest archive and version helpers** Append to `backend/app/services/document_archives.py`: ```python def get_latest_archive(db: Session, document_type: str, business_id: int) -> DocumentArchive | None: return db.scalar( select(DocumentArchive) .where( DocumentArchive.document_type == document_type, DocumentArchive.business_id == business_id, DocumentArchive.file_format == FILE_FORMAT_PDF, ) .order_by(DocumentArchive.archive_version.desc(), DocumentArchive.id.desc()) ) def next_archive_version(db: Session, document_type: str, business_id: int) -> int: latest = db.scalar( select(func.max(DocumentArchive.archive_version)).where( DocumentArchive.document_type == document_type, DocumentArchive.business_id == business_id, DocumentArchive.file_format == FILE_FORMAT_PDF, ) ) return int(latest or 0) + 1 ``` - [ ] **Step 6: Add sales context collection** Append to `backend/app/services/document_archives.py`: ```python def collect_sales_order_archive_context(db: Session, sales_order_id: int) -> ArchiveContext: sales_employee = aliased(Employee) row = db.execute( select( SalesOrder, Customer.customer_name.label("customer_name"), sales_employee.employee_name.label("sales_employee_name"), ) .join(Customer, Customer.id == SalesOrder.customer_id) .outerjoin(sales_employee, sales_employee.id == SalesOrder.sales_employee_id) .where(SalesOrder.id == sales_order_id) ).first() if not row: raise HTTPException(status_code=404, detail="销售订单不存在") order = row[0] item_rows = db.execute( select(SalesOrderItem, Item.item_code, Item.item_name) .join(Item, Item.id == SalesOrderItem.product_item_id) .where(SalesOrderItem.sales_order_id == sales_order_id) .order_by(SalesOrderItem.line_no.asc()) ).all() lines = [ ArchiveLine( line_no=int(item.line_no or index), name=str(item_name or "-"), code=str(item_code or "-"), spec=str(item.customer_part_no or "-"), qty_text=decimal_text(item.order_qty, 3), price_text=decimal_text(item.unit_price, 4), amount_text=decimal_text(item.line_amount, 2), date_text=date_text(item.promised_date), remark="客户料号" if item.customer_part_no else "", ) for index, (item, item_code, item_name) in enumerate(item_rows, start=1) ] return ArchiveContext( document_type=DOCUMENT_TYPE_SALES_ORDER, document_no=order.order_no, title="销售订单", party_label="客户", party_name=str(row.customer_name or "-"), owner_label="销售人员", owner_name=str(row.sales_employee_name or "-"), date_label="下单日期", date_text=date_text(order.order_date), promised_label="交期", promised_text=date_text(order.promised_date), tax_rate_text=f"{Decimal(str(order.tax_rate or 0)) * 100:.2f}%", address_or_target_text=str(order.delivery_address or "-"), remark=str(getattr(order, "remark", "") or ""), total_label="订单金额", total_text=decimal_text(order.total_amount, 2), lines=lines, signature_labels=("销售确认", "客户确认", "财务复核", "制单人"), ) ``` - [ ] **Step 7: Add purchase context collection** Append to `backend/app/services/document_archives.py`: ```python def collect_purchase_order_archive_context(db: Session, purchase_order_id: int) -> ArchiveContext: purchaser = aliased(Employee) row = db.execute( select( PurchaseOrder, Supplier.supplier_name.label("supplier_name"), purchaser.employee_name.label("purchaser_name"), ) .join(Supplier, Supplier.id == PurchaseOrder.supplier_id) .outerjoin(purchaser, purchaser.id == PurchaseOrder.purchaser_employee_id) .where(PurchaseOrder.id == purchase_order_id) ).first() if not row: raise HTTPException(status_code=404, detail="采购订单不存在") order = row[0] sales_nos = db.scalars( select(SalesOrder.order_no) .join(PurchaseOrderSalesOrderLink, PurchaseOrderSalesOrderLink.sales_order_id == SalesOrder.id) .where(PurchaseOrderSalesOrderLink.purchase_order_id == purchase_order_id) .order_by(SalesOrder.id.asc()) ).all() item_rows = db.execute( select(PurchaseOrderItem, Item.item_code, Item.item_name) .join(Item, Item.id == PurchaseOrderItem.material_item_id) .where(PurchaseOrderItem.purchase_order_id == purchase_order_id) .order_by(PurchaseOrderItem.line_no.asc()) ).all() is_aux = str(order.target_warehouse_type or "").upper() == "AUX" lines = [ ArchiveLine( line_no=int(item.line_no or index), name=str(item_name or "-"), code=str(item_code or "-"), spec=str(item.remark or "-"), qty_text=decimal_text(item.order_qty if is_aux else item.order_weight_kg, 3), price_text=decimal_text(item.unit_price, 4), amount_text=decimal_text(item.line_amount, 2), date_text=date_text(order.expected_date), remark="数量" if is_aux else "重量kg", ) for index, (item, item_code, item_name) in enumerate(item_rows, start=1) ] target_text = "辅料库" if is_aux else "原材料库" if sales_nos: target_text = f"{target_text};关联销售订单:{'、'.join(str(no) for no in sales_nos)}" return ArchiveContext( document_type=DOCUMENT_TYPE_PURCHASE_ORDER, document_no=order.po_no, title="采购订单", party_label="供应商", party_name=str(row.supplier_name or "-"), owner_label="采购人员", owner_name=str(row.purchaser_name or "-"), date_label="采购日期", date_text=date_text(order.order_date), promised_label="预计到货", promised_text=date_text(order.expected_date), tax_rate_text=f"{Decimal(str(order.tax_rate or 0)) * 100:.2f}%", address_or_target_text=target_text, remark=str(order.remark or ""), total_label="采购金额", total_text=decimal_text(order.total_amount, 2), lines=lines, signature_labels=("采购确认", "供应商确认", "仓库接收", "制单人"), ) ``` - [ ] **Step 8: Add PDF rendering** Append to `backend/app/services/document_archives.py`: ```python def _register_pdf_fonts() -> None: try: pdfmetrics.registerFont(UnicodeCIDFont("STSong-Light")) except Exception: pass def draw_text(pdf: canvas.Canvas, x: float, y: float, text: object, size: int = 9, bold: bool = False) -> None: _ = bold pdf.setFont("STSong-Light", size) pdf.drawString(x, y, str(text or "-")) def render_pdf_archive(context: ArchiveContext, output_path: Path) -> None: _register_pdf_fonts() output_path.parent.mkdir(parents=True, exist_ok=True) pdf = canvas.Canvas(str(output_path), pagesize=A4) width, height = A4 pdf.setTitle(f"{context.title}-{context.document_no}") pdf.setFillColor(colors.HexColor("#fbf3df")) pdf.rect(24, 24, width - 48, height - 48, fill=1, stroke=0) pdf.setStrokeColor(colors.HexColor("#1d1d1d")) pdf.setLineWidth(1) pdf.rect(42, 56, width - 84, height - 112, fill=0, stroke=1) pdf.setFillColor(colors.black) pdf.setFont("STSong-Light", 22) pdf.drawCentredString(width / 2, height - 92, context.title) pdf.setStrokeColor(colors.HexColor("#b91c1c")) pdf.setFillColor(colors.HexColor("#b91c1c")) pdf.roundRect(width - 205, height - 116, 150, 28, 4, fill=0, stroke=1) draw_text(pdf, width - 195, height - 107, f"单号:{context.document_no}", 8) pdf.setFillColor(colors.HexColor("#6b1f1f")) pdf.rotate(90) draw_text(pdf, height - 245, -34, "ERP留存联", 10) pdf.rotate(-90) y = height - 145 row_h = 27 left = 54 right = width - 54 col_w = (right - left) / 4 header_cells = [ (context.party_label, context.party_name), (context.owner_label, context.owner_name), (context.date_label, context.date_text), (context.promised_label, context.promised_text), ("税率", context.tax_rate_text), ("地址/到货库", context.address_or_target_text), ("备注", context.remark or "-"), (context.total_label, context.total_text), ] for row_index in range(2): for col_index in range(4): x = left + col_index * col_w pdf.rect(x, y - row_h, col_w, row_h, fill=0, stroke=1) label, value = header_cells[row_index * 4 + col_index] draw_text(pdf, x + 6, y - 12, label, 7) draw_text(pdf, x + 6, y - 23, value, 8) y -= row_h y -= 24 columns = [ ("序号", 34), ("编码", 78), ("名称", 120), ("规格/料号", 95), ("数量/重量", 70), ("单价", 70), ("金额", 70), ("交期", 72), ] x = left for label, col_width in columns: pdf.rect(x, y - row_h, col_width, row_h, fill=0, stroke=1) draw_text(pdf, x + 4, y - 18, label, 8) x += col_width y -= row_h for line in context.lines[:12]: values = [ line.line_no, line.code, line.name, line.spec, line.qty_text, line.price_text, line.amount_text, line.date_text, ] x = left for value, (_, col_width) in zip(values, columns, strict=True): pdf.rect(x, y - row_h, col_width, row_h, fill=0, stroke=1) draw_text(pdf, x + 4, y - 18, str(value)[:24], 8) x += col_width y -= row_h while y > 166: x = left for _, col_width in columns: pdf.rect(x, y - row_h, col_width, row_h, fill=0, stroke=1) x += col_width y -= row_h pdf.setStrokeColor(colors.HexColor("#991b1b")) pdf.circle(width - 122, 148, 34, fill=0, stroke=1) draw_text(pdf, width - 151, 148, "PDF归档", 10) pdf.setStrokeColor(colors.HexColor("#1d1d1d")) sign_y = 104 sign_w = (right - left) / 4 for index, label in enumerate(context.signature_labels): x = left + index * sign_w pdf.rect(x, sign_y, sign_w, 34, fill=0, stroke=1) draw_text(pdf, x + 6, sign_y + 20, label, 8) draw_text(pdf, x + 6, sign_y + 8, "签字:", 8) draw_text(pdf, left, 72, f"模板版本:{TEMPLATE_VERSION}", 7) draw_text(pdf, right - 150, 72, f"生成时间:{datetime.now():%Y-%m-%d %H:%M}", 7) pdf.showPage() pdf.save() ``` - [ ] **Step 9: Add generation and zip functions** Append to `backend/app/services/document_archives.py`: ```python 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) raise HTTPException(status_code=400, detail="单据类型只能选择销售订单或采购订单") def generate_document_archive( db: Session, document_type: str, business_id: int, created_by: int | None = None, archive_root: Path | None = None, ) -> DocumentArchiveGenerateResult: root = archive_root or default_archive_root() version = next_archive_version(db, document_type, business_id) now = datetime.now() try: context = collect_archive_context(db, document_type, business_id) type_dir = "sales_order" if document_type == DOCUMENT_TYPE_SALES_ORDER else "purchase_order" file_name = f"{safe_filename(context.title)}-{safe_filename(context.document_no)}-v{version}.pdf" file_path = root / type_dir / file_name render_pdf_archive(context, file_path) archive = DocumentArchive( document_type=document_type, business_id=business_id, document_no=context.document_no, archive_version=version, template_version=TEMPLATE_VERSION, file_format=FILE_FORMAT_PDF, file_name=file_name, file_path=str(file_path), file_hash=sha256_file(file_path), status=ARCHIVE_STATUS_READY, error_message=None, created_by=created_by, created_at=now, updated_at=now, ) db.add(archive) db.commit() return DocumentArchiveGenerateResult( business_id=business_id, document_type=document_type, document_no=context.document_no, archive_status=ARCHIVE_STATUS_READY, archive_version=version, archive_error_message=None, ) except HTTPException: db.rollback() raise except Exception as exc: db.rollback() failed = DocumentArchive( document_type=document_type, business_id=business_id, document_no=f"{document_type}-{business_id}", archive_version=version, template_version=TEMPLATE_VERSION, file_format=FILE_FORMAT_PDF, file_name="", file_path="", file_hash=None, status=ARCHIVE_STATUS_FAILED, error_message=f"归档失败:{exc}", created_by=created_by, created_at=now, updated_at=now, ) db.add(failed) db.commit() return DocumentArchiveGenerateResult( business_id=business_id, document_type=document_type, document_no=failed.document_no, archive_status=ARCHIVE_STATUS_FAILED, archive_version=version, archive_error_message=failed.error_message, ) def build_archive_zip(db: Session, document_type: str, business_ids: list[int], output_path: Path) -> Path: output_path.parent.mkdir(parents=True, exist_ok=True) with ZipFile(output_path, "w", ZIP_DEFLATED) as archive_zip: for business_id in business_ids: archive = get_latest_archive(db, document_type, int(business_id)) if not archive or archive.status != ARCHIVE_STATUS_READY: continue pdf_path = Path(archive.file_path) if pdf_path.exists(): archive_zip.write(pdf_path, arcname=archive.file_name) return output_path ``` - [ ] **Step 10: Run the service test to verify it passes** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP PYTHONPATH=backend pytest backend/tests/test_document_archive_service.py -q ``` Expected: PASS. - [ ] **Step 11: Install dependency if the local venv lacks ReportLab** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend python -m pip install -r requirements.txt ``` Expected: `reportlab` installs successfully or is already satisfied. --- ### Task 3: Archive API Routes **Files:** - Create: `backend/app/api/routes/document_archives.py` - Modify: `backend/app/api/router.py` - Test: `backend/tests/test_document_archive_routes.py` - [ ] **Step 1: Write the failing route test** Create `backend/tests/test_document_archive_routes.py`: ```python from __future__ import annotations import unittest from pathlib import Path from tempfile import TemporaryDirectory from fastapi import FastAPI from fastapi.testclient import TestClient from sqlalchemy import BigInteger, create_engine from sqlalchemy.ext.compiler import compiles from sqlalchemy.orm import sessionmaker @compiles(BigInteger, "sqlite") def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: _ = type_, compiler, kw return "INTEGER" import app.models.document_archive # noqa: E402,F401 import app.models.master_data # noqa: E402,F401 import app.models.miniapp # noqa: E402,F401 import app.models.operations # noqa: E402,F401 import app.models.org # noqa: E402,F401 import app.models.planning # noqa: E402,F401 import app.models.sales # noqa: E402,F401 from app.api.routes import document_archives # noqa: E402 from app.db.session import get_db # noqa: E402 from app.models.base import Base # noqa: E402 from app.services.auth import require_authenticated_user # noqa: E402 class DocumentArchiveRoutesTest(unittest.TestCase): def setUp(self) -> None: self.tempdir = TemporaryDirectory() engine = create_engine("sqlite+pysqlite:///:memory:", future=True) Base.metadata.create_all(engine) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) self.db = SessionLocal() app = FastAPI() app.include_router(document_archives.router, prefix="/api/document-archives") def override_db(): yield self.db app.dependency_overrides[get_db] = override_db app.dependency_overrides[require_authenticated_user] = lambda: {"user_id": 1, "username": "admin"} self.client = TestClient(app) def tearDown(self) -> None: self.db.close() self.tempdir.cleanup() def test_document_type_key_maps_to_chinese_type(self) -> None: self.assertEqual(document_archives.normalize_document_type_key("sales-order"), "销售订单") self.assertEqual(document_archives.normalize_document_type_key("purchase-order"), "采购订单") def test_preview_missing_archive_returns_chinese_error(self) -> None: response = self.client.get("/api/document-archives/sales-order/999/latest/preview") self.assertEqual(response.status_code, 404) self.assertIn("归档文件不存在", response.json()["detail"]) ``` - [ ] **Step 2: Run the route test to verify it fails** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP PYTHONPATH=backend pytest backend/tests/test_document_archive_routes.py -q ``` Expected: FAIL with import error for `document_archives`. - [ ] **Step 3: Create archive routes** Create `backend/app/api/routes/document_archives.py`: ```python from __future__ import annotations from datetime import datetime from pathlib import Path from urllib.parse import quote from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import FileResponse from sqlalchemy.orm import Session from app.db.session import get_db from app.schemas.document_archives import DocumentArchiveBatchDownloadRequest, DocumentArchiveGenerateResult from app.services.auth import AuthContext, require_authenticated_user from app.services.document_archives import ( ARCHIVE_STATUS_READY, DOCUMENT_TYPE_PURCHASE_ORDER, DOCUMENT_TYPE_SALES_ORDER, build_archive_zip, default_archive_root, generate_document_archive, get_latest_archive, ) router = APIRouter(dependencies=[Depends(require_authenticated_user)]) def normalize_document_type_key(document_type_key: str) -> str: mapping = { "sales-order": DOCUMENT_TYPE_SALES_ORDER, "purchase-order": DOCUMENT_TYPE_PURCHASE_ORDER, DOCUMENT_TYPE_SALES_ORDER: DOCUMENT_TYPE_SALES_ORDER, DOCUMENT_TYPE_PURCHASE_ORDER: DOCUMENT_TYPE_PURCHASE_ORDER, } try: return mapping[document_type_key] except KeyError as exc: raise HTTPException(status_code=400, detail="单据类型只能选择销售订单或采购订单") from exc def _archive_file_response(path: Path, filename: str, inline: bool) -> FileResponse: if not path.exists(): raise HTTPException(status_code=404, detail="归档文件不存在,请重新生成") disposition = "inline" if inline else "attachment" encoded_filename = quote(filename) return FileResponse( path, media_type="application/pdf", filename=filename, headers={"Content-Disposition": f"{disposition}; filename=\"archive.pdf\"; filename*=UTF-8''{encoded_filename}"}, ) @router.post("/{document_type_key}/{business_id}/generate", response_model=DocumentArchiveGenerateResult) def generate_archive( document_type_key: str, business_id: int, db: Session = Depends(get_db), auth: AuthContext = Depends(require_authenticated_user), ) -> DocumentArchiveGenerateResult: document_type = normalize_document_type_key(document_type_key) user_id = getattr(auth, "user_id", None) if not isinstance(auth, dict) else auth.get("user_id") return generate_document_archive(db, document_type=document_type, business_id=business_id, created_by=user_id) @router.get("/{document_type_key}/{business_id}/latest/preview") def preview_latest_archive(document_type_key: str, business_id: int, db: Session = Depends(get_db)) -> FileResponse: document_type = normalize_document_type_key(document_type_key) archive = get_latest_archive(db, document_type, business_id) if not archive or archive.status != ARCHIVE_STATUS_READY: raise HTTPException(status_code=404, detail="归档文件不存在,请重新生成") return _archive_file_response(Path(archive.file_path), archive.file_name, inline=True) @router.get("/{document_type_key}/{business_id}/latest/download") def download_latest_archive(document_type_key: str, business_id: int, db: Session = Depends(get_db)) -> FileResponse: document_type = normalize_document_type_key(document_type_key) archive = get_latest_archive(db, document_type, business_id) if not archive or archive.status != ARCHIVE_STATUS_READY: raise HTTPException(status_code=404, detail="归档文件不存在,请重新生成") return _archive_file_response(Path(archive.file_path), archive.file_name, inline=False) @router.post("/batch-download") def batch_download_archives(payload: DocumentArchiveBatchDownloadRequest, db: Session = Depends(get_db)) -> FileResponse: document_type = normalize_document_type_key(payload.document_type) timestamp = datetime.now().strftime("%Y%m%d%H%M%S") zip_name = f"{document_type}-归档-{timestamp}.zip" zip_path = default_archive_root() / "batch" / zip_name build_archive_zip(db, document_type, payload.business_ids, zip_path) encoded_filename = quote(zip_name) return FileResponse( zip_path, media_type="application/zip", filename=zip_name, headers={"Content-Disposition": f"attachment; filename=\"archives.zip\"; filename*=UTF-8''{encoded_filename}"}, ) ``` - [ ] **Step 4: Register the router** Modify `backend/app/api/router.py`: ```python from app.api.routes import ( auth, dashboard, document_archives, equipment, finance, inventory, master_data, planning, production, purchase, quality, returns, sales, system, system_extension, system_permissions, ) ``` Add: ```python api_router.include_router(document_archives.router, prefix="/document-archives", tags=["document-archives"]) ``` - [ ] **Step 5: Run the route test to verify it passes** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP PYTHONPATH=backend pytest backend/tests/test_document_archive_routes.py -q ``` Expected: PASS. --- ### Task 4: Sales Order Archive Integration **Files:** - Modify: `backend/app/services/sales_planning.py` - Modify: `backend/app/api/routes/sales.py` - Test: `backend/tests/test_sales_order_document_archive.py` - [ ] **Step 1: Write the failing sales integration test** Create `backend/tests/test_sales_order_document_archive.py`: ```python from __future__ import annotations import unittest from datetime import date, datetime from unittest.mock import patch from sqlalchemy import BigInteger, create_engine from sqlalchemy.ext.compiler import compiles from sqlalchemy.orm import sessionmaker @compiles(BigInteger, "sqlite") def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: _ = type_, compiler, kw return "INTEGER" import app.models.document_archive # noqa: E402,F401 import app.models.master_data # noqa: E402,F401 import app.models.miniapp # noqa: E402,F401 import app.models.operations # noqa: E402,F401 import app.models.org # noqa: E402,F401 import app.models.planning # noqa: E402,F401 import app.models.sales # noqa: E402,F401 from app.api.routes.sales import create_sales_order, list_sales_orders # noqa: E402 from app.models.base import Base # noqa: E402 from app.models.master_data import Item # noqa: E402 from app.models.sales import Customer # noqa: E402 from app.schemas.database import SalesOrderCreate, SalesOrderItemCreate # noqa: E402 class SalesOrderDocumentArchiveTest(unittest.TestCase): def setUp(self) -> None: engine = create_engine("sqlite+pysqlite:///:memory:", future=True) Base.metadata.create_all(engine) self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) self.db = self.SessionLocal() now = datetime(2026, 6, 11, 10, 0, 0) self.db.add(Customer(id=1, customer_code="CUS001", customer_name="测试客户", short_name="客户", status="ACTIVE", created_at=now, updated_at=now)) self.db.add(Item(id=10, item_code="CP00010", item_name="钢制碗", item_type="FINISHED", unit_id=None, status="ACTIVE", created_at=now, updated_at=now)) self.db.commit() def tearDown(self) -> None: self.db.close() def test_create_sales_order_returns_archive_status(self) -> None: payload = SalesOrderCreate( customer_id=1, sales_employee_id=None, promised_date=date(2026, 6, 20), delivery_address="测试地址", tax_rate=0.13, remark="测试备注", items=[SalesOrderItemCreate(product_item_id=10, order_qty=10, unit_price=5, promised_date=date(2026, 6, 20), customer_part_no="KH-01")], ) with patch("app.api.routes.sales.generate_document_archive") as mocked: mocked.return_value.archive_status = "已归档" mocked.return_value.archive_version = 1 mocked.return_value.archive_error_message = None result = create_sales_order(payload, self.db) self.assertEqual(result.archive_status, "已归档") self.assertEqual(result.archive_version, 1) mocked.assert_called_once() def test_list_sales_orders_exposes_archive_fields(self) -> None: rows = list_sales_orders(db=self.db) self.assertEqual(rows, []) ``` - [ ] **Step 2: Run the sales integration test to verify it fails** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP PYTHONPATH=backend pytest backend/tests/test_sales_order_document_archive.py -q ``` Expected: FAIL because `generate_document_archive` is not imported in `sales.py`, and list query does not expose archive metadata yet. - [ ] **Step 3: Enrich sales order query with latest archive metadata** Modify `backend/app/services/sales_planning.py`. Import: ```python from app.models.document_archive import DocumentArchive ``` Inside `get_sales_order_query()`, add this subquery after the existing subqueries: ```python latest_archive_version_subquery = ( select( DocumentArchive.business_id.label("sales_order_id"), func.max(DocumentArchive.archive_version).label("archive_version"), ) .where(DocumentArchive.document_type == "销售订单") .group_by(DocumentArchive.business_id) .subquery() ) latest_archive_subquery = ( select( DocumentArchive.business_id.label("sales_order_id"), DocumentArchive.archive_version.label("archive_version"), DocumentArchive.status.label("archive_status"), DocumentArchive.created_at.label("archive_created_at"), DocumentArchive.error_message.label("archive_error_message"), ) .join( latest_archive_version_subquery, (latest_archive_version_subquery.c.sales_order_id == DocumentArchive.business_id) & (latest_archive_version_subquery.c.archive_version == DocumentArchive.archive_version), ) .where(DocumentArchive.document_type == "销售订单") .subquery() ) ``` Add selected columns: ```python func.coalesce(latest_archive_subquery.c.archive_status, "未生成").label("archive_status"), latest_archive_subquery.c.archive_version.label("archive_version"), latest_archive_subquery.c.archive_created_at.label("archive_created_at"), latest_archive_subquery.c.archive_error_message.label("archive_error_message"), ``` Add outer join: ```python .outerjoin(latest_archive_subquery, latest_archive_subquery.c.sales_order_id == SalesOrder.id) ``` - [ ] **Step 4: Generate archive after creating a sales order** Modify `backend/app/api/routes/sales.py`. Import: ```python from app.services.document_archives import DOCUMENT_TYPE_SALES_ORDER, generate_document_archive ``` In `create_sales_order()`, after `db.commit()` and before returning, add: ```python archive_result = generate_document_archive(db, DOCUMENT_TYPE_SALES_ORDER, int(order.id)) ``` Update the return: ```python return SalesOrderCreateResult( order_id=order.id, order_no=order.order_no, total_amount=float(total_amount), item_count=len(payload.items), status=order.status, archive_status=archive_result.archive_status, archive_version=archive_result.archive_version, archive_error_message=archive_result.archive_error_message, ) ``` The service catches PDF render failures and writes `归档失败`, so this route does not need to swallow archive exceptions except for unexpected `HTTPException`. - [ ] **Step 5: Run the sales integration test to verify it passes** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP PYTHONPATH=backend pytest backend/tests/test_sales_order_document_archive.py backend/tests/test_sales_order_no_generation.py -q ``` Expected: PASS. --- ### Task 5: Purchase Order Archive Integration **Files:** - Modify: `backend/app/services/operations.py` - Modify: `backend/app/api/routes/purchase.py` - Test: `backend/tests/test_purchase_order_document_archive.py` - [ ] **Step 1: Write the failing purchase integration test** Create `backend/tests/test_purchase_order_document_archive.py`: ```python from __future__ import annotations import unittest from datetime import date, datetime from unittest.mock import patch from sqlalchemy import BigInteger, create_engine from sqlalchemy.ext.compiler import compiles from sqlalchemy.orm import sessionmaker @compiles(BigInteger, "sqlite") def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: _ = type_, compiler, kw return "INTEGER" import app.models.document_archive # noqa: E402,F401 import app.models.master_data # noqa: E402,F401 import app.models.miniapp # noqa: E402,F401 import app.models.operations # noqa: E402,F401 import app.models.org # noqa: E402,F401 import app.models.planning # noqa: E402,F401 import app.models.sales # noqa: E402,F401 from app.api.routes.purchase import create_purchase_order # noqa: E402 from app.models.base import Base # noqa: E402 from app.models.master_data import Item, Material # noqa: E402 from app.models.operations import Supplier # noqa: E402 from app.schemas.operations import PurchaseOrderCreate, PurchaseOrderItemCreate # noqa: E402 class PurchaseOrderDocumentArchiveTest(unittest.TestCase): def setUp(self) -> None: engine = create_engine("sqlite+pysqlite:///:memory:", future=True) Base.metadata.create_all(engine) self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) self.db = self.SessionLocal() now = datetime(2026, 6, 11, 10, 0, 0) self.db.add(Supplier(id=1, supplier_code="SUP001", supplier_name="测试供应商", short_name="供应商", lead_time_days=0, default_tax_rate=0.13, status="ACTIVE", created_at=now, updated_at=now)) self.db.add(Item(id=10, item_code="YL00010", item_name="冷轧钢", item_type="RAW_MATERIAL", unit_id=None, status="ACTIVE", created_at=now, updated_at=now)) self.db.add(Material(id=1, item_id=10, material_grade="Q235", specification="1.0mm", density=7.85, purchase_calc_mode="WEIGHT", default_supplier_id=1, status="ACTIVE", created_at=now, updated_at=now)) self.db.commit() def tearDown(self) -> None: self.db.close() def test_create_purchase_order_returns_archive_status(self) -> None: payload = PurchaseOrderCreate( supplier_id=1, order_date=date(2026, 6, 11), expected_date=date(2026, 6, 18), warning_lead_days=3, purchaser_employee_id=None, target_warehouse_type="RAW", sales_order_ids=[], tax_rate=0.13, remark="采购备注", items=[PurchaseOrderItemCreate(material_item_id=10, order_weight_kg=100, unit_price=4.5, remark="性能要求")], ) with patch("app.api.routes.purchase.generate_document_archive") as mocked: mocked.return_value.archive_status = "已归档" mocked.return_value.archive_version = 1 mocked.return_value.archive_error_message = None result = create_purchase_order(payload, self.db) self.assertEqual(result.archive_status, "已归档") self.assertEqual(result.archive_version, 1) mocked.assert_called_once() ``` - [ ] **Step 2: Run the purchase integration test to verify it fails** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP PYTHONPATH=backend pytest backend/tests/test_purchase_order_document_archive.py -q ``` Expected: FAIL because purchase route does not generate archive and purchase query does not include archive metadata. - [ ] **Step 3: Enrich purchase order query with latest archive metadata** Modify `backend/app/services/operations.py`. Import: ```python from app.models.document_archive import DocumentArchive ``` Inside `get_purchase_order_query()`, add latest archive subqueries using `PurchaseOrder.id` as `business_id` and `document_type == "采购订单"`. Use this pattern: ```python latest_archive_version_subquery = ( select( DocumentArchive.business_id.label("purchase_order_id"), func.max(DocumentArchive.archive_version).label("archive_version"), ) .where(DocumentArchive.document_type == "采购订单") .group_by(DocumentArchive.business_id) .subquery() ) latest_archive_subquery = ( select( DocumentArchive.business_id.label("purchase_order_id"), DocumentArchive.archive_version.label("archive_version"), DocumentArchive.status.label("archive_status"), DocumentArchive.created_at.label("archive_created_at"), DocumentArchive.error_message.label("archive_error_message"), ) .join( latest_archive_version_subquery, (latest_archive_version_subquery.c.purchase_order_id == DocumentArchive.business_id) & (latest_archive_version_subquery.c.archive_version == DocumentArchive.archive_version), ) .where(DocumentArchive.document_type == "采购订单") .subquery() ) ``` Add selected columns: ```python func.coalesce(latest_archive_subquery.c.archive_status, "未生成").label("archive_status"), latest_archive_subquery.c.archive_version.label("archive_version"), latest_archive_subquery.c.archive_created_at.label("archive_created_at"), latest_archive_subquery.c.archive_error_message.label("archive_error_message"), ``` Add outer join: ```python .outerjoin(latest_archive_subquery, latest_archive_subquery.c.purchase_order_id == PurchaseOrder.id) ``` - [ ] **Step 4: Generate archive after creating a purchase order** Modify `backend/app/api/routes/purchase.py`. Import: ```python from app.services.document_archives import DOCUMENT_TYPE_PURCHASE_ORDER, generate_document_archive ``` In `create_purchase_order()`, after `db.commit()`, add: ```python archive_result = generate_document_archive(db, DOCUMENT_TYPE_PURCHASE_ORDER, int(order.id)) ``` Then read the purchase order and attach archive fields: ```python result = _read_purchase_order(db, order.id) return result.model_copy( update={ "archive_status": archive_result.archive_status, "archive_version": archive_result.archive_version, "archive_error_message": archive_result.archive_error_message, } ) ``` - [ ] **Step 5: Generate a new archive version after editable purchase update** In `update_purchase_order()`, after the existing `db.commit()`, add: ```python archive_result = generate_document_archive(db, DOCUMENT_TYPE_PURCHASE_ORDER, int(order.id)) result = _read_purchase_order(db, order.id) return result.model_copy( update={ "archive_status": archive_result.archive_status, "archive_version": archive_result.archive_version, "archive_error_message": archive_result.archive_error_message, } ) ``` Remove the old direct `return _read_purchase_order(db, order.id)` at the end of `update_purchase_order()`. - [ ] **Step 6: Run the purchase integration test to verify it passes** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP PYTHONPATH=backend pytest backend/tests/test_purchase_order_document_archive.py backend/tests/test_purchase_order_no_generation.py backend/tests/test_purchase_order_multi_sales_links.py -q ``` Expected: PASS. --- ### Task 6: Frontend Document Paper Components **Files:** - Create: `frontend/src/components/documentForms/DocumentPaper.vue` - Create: `frontend/src/components/documentForms/DocumentGrid.vue` - Create: `frontend/src/components/documentForms/DocumentLineTable.vue` - Create: `frontend/src/components/documentForms/DocumentArchiveActions.vue` - Modify: `frontend/src/styles/main.css` - Create: `frontend/scripts/test-document-form-components.mjs` - [ ] **Step 1: Write the failing static component test** Create `frontend/scripts/test-document-form-components.mjs`: ```javascript import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; const root = process.cwd(); const files = [ "src/components/documentForms/DocumentPaper.vue", "src/components/documentForms/DocumentGrid.vue", "src/components/documentForms/DocumentLineTable.vue", "src/components/documentForms/DocumentArchiveActions.vue" ]; for (const file of files) { const source = readFileSync(resolve(root, file), "utf8"); assert.ok(source.length > 200, `${file} should contain a real component`); } const paper = readFileSync(resolve(root, "src/components/documentForms/DocumentPaper.vue"), "utf8"); assert.match(paper, /ERP留存联/); assert.match(paper, /document-paper-clip/); assert.match(paper, /document-paper-binder-hole/); const actions = readFileSync(resolve(root, "src/components/documentForms/DocumentArchiveActions.vue"), "utf8"); assert.match(actions, /预览PDF/); assert.match(actions, /下载PDF/); assert.match(actions, /重新生成/); const css = readFileSync(resolve(root, "src/styles/main.css"), "utf8"); assert.match(css, /\.document-paper-shell/); assert.match(css, /\.document-paper-sheet/); assert.match(css, /\.document-line-table/); console.log("document form component static tests passed"); ``` - [ ] **Step 2: Run the static component test to verify it fails** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend node scripts/test-document-form-components.mjs ``` Expected: FAIL with missing component file. - [ ] **Step 3: Create `DocumentPaper.vue`** Create `frontend/src/components/documentForms/DocumentPaper.vue`: ```vue ``` - [ ] **Step 4: Create `DocumentGrid.vue`** Create `frontend/src/components/documentForms/DocumentGrid.vue`: ```vue ``` - [ ] **Step 5: Create `DocumentLineTable.vue`** Create `frontend/src/components/documentForms/DocumentLineTable.vue`: ```vue ``` - [ ] **Step 6: Create `DocumentArchiveActions.vue`** Create `frontend/src/components/documentForms/DocumentArchiveActions.vue`: ```vue ``` - [ ] **Step 7: Add document paper CSS** Append to `frontend/src/styles/main.css`: ```css /* Document-form paper system */ .document-paper-shell { position: relative; max-width: 1060px; margin: 0 auto; padding: 34px 22px 22px; } .document-paper-stack { position: absolute; inset: 42px 34px 18px; border: 1px solid rgba(60, 44, 28, 0.18); background: #efe3c9; box-shadow: 0 20px 44px rgba(72, 48, 24, 0.16); } .document-paper-stack-back { transform: rotate(-1.4deg) translateY(8px); } .document-paper-stack-mid { transform: rotate(0.8deg) translateY(3px); background: #f6ecd7; } .document-paper-sheet { position: relative; min-height: 720px; padding: 54px 42px 36px; border: 1px solid rgba(34, 24, 12, 0.42); background: linear-gradient(90deg, rgba(80, 54, 25, 0.035) 1px, transparent 1px) 0 0 / 18px 18px, linear-gradient(rgba(80, 54, 25, 0.03) 1px, transparent 1px) 0 0 / 18px 18px, #fbf3df; box-shadow: 0 28px 78px rgba(71, 49, 23, 0.22); } .document-paper-clip { position: absolute; top: -22px; left: 50%; width: 154px; height: 42px; transform: translateX(-50%); border-radius: 14px 14px 8px 8px; background: linear-gradient(180deg, #d9e0e6, #7c8791 48%, #cbd3da); box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.65), 0 12px 20px rgba(30, 41, 59, 0.22); } .document-paper-binder { position: absolute; top: 26px; left: 18px; display: grid; gap: 42px; } .document-paper-binder-hole { width: 12px; height: 12px; border-radius: 999px; background: #d8c59e; box-shadow: inset 0 2px 5px rgba(56, 39, 18, 0.28); } .document-retained-mark { position: absolute; right: 14px; top: 180px; writing-mode: vertical-rl; letter-spacing: 6px; color: rgba(127, 29, 29, 0.68); font-weight: 900; } .document-paper-head { position: relative; display: grid; justify-items: center; gap: 6px; margin-bottom: 26px; color: #17120b; } .document-company { margin: 0; font-size: 13px; letter-spacing: 0.3em; } .document-paper-head h2 { margin: 0; font-size: 34px; letter-spacing: 0.18em; } .document-no-stamp { position: absolute; right: 0; top: 4px; display: grid; gap: 2px; min-width: 156px; padding: 8px 12px; border: 2px solid #991b1b; color: #991b1b; transform: rotate(-1.5deg); } .document-paper-green .document-no-stamp { border-color: #166534; color: #166534; } .document-grid { display: grid; grid-template-columns: repeat(var(--document-grid-columns), minmax(0, 1fr)); border-top: 1px solid #2c2418; border-left: 1px solid #2c2418; } .document-grid-cell { min-height: 58px; padding: 8px 10px; border-right: 1px solid #2c2418; border-bottom: 1px solid #2c2418; background: rgba(255, 255, 255, 0.16); } .document-grid-cell-wide { grid-column: span 2; } .document-grid-cell span, .document-line-table th { color: rgba(23, 18, 11, 0.72); font-size: 12px; font-weight: 800; } .document-grid-cell input, .document-grid-cell select, .document-grid-cell textarea, .document-line-table input, .document-line-table select { width: 100%; min-height: 32px; border: 0; border-bottom: 1px solid rgba(44, 36, 24, 0.42); border-radius: 0; background: transparent; color: #17120b; } .document-line-block { margin-top: 24px; } .document-line-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } .document-line-table-wrap { overflow-x: auto; border: 1px solid #2c2418; } .document-line-table { width: 100%; min-width: 920px; border-collapse: collapse; } .document-line-table th, .document-line-table td { border: 1px solid #2c2418; padding: 8px; text-align: left; vertical-align: top; } .document-signature-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin-top: 24px; border-top: 1px solid #2c2418; border-left: 1px solid #2c2418; } .document-signature-cell { min-height: 58px; padding: 8px 10px; border-right: 1px solid #2c2418; border-bottom: 1px solid #2c2418; } .document-archive-actions { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; } .document-archive-status { display: inline-flex; align-items: center; min-height: 30px; padding: 0 10px; border-radius: 999px; font-weight: 900; } .document-archive-status-ready { color: #166534; background: #dcfce7; } .document-archive-status-failed { color: #991b1b; background: #fee2e2; } .document-archive-status-missing { color: #92400e; background: #fef3c7; } @media (max-width: 860px) { .document-paper-shell { padding: 26px 0 12px; } .document-paper-sheet { padding: 48px 18px 26px; } .document-paper-head h2 { font-size: 26px; } .document-no-stamp { position: static; margin-top: 8px; } .document-grid { grid-template-columns: 1fr; } .document-grid-cell-wide { grid-column: auto; } .document-signature-row { grid-template-columns: 1fr 1fr; } } ``` - [ ] **Step 8: Run the static component test to verify it passes** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend node scripts/test-document-form-components.mjs ``` Expected: PASS. --- ### Task 7: Sales Order Document UI And Archive Actions **Files:** - Modify: `frontend/src/services/api.js` - Modify: `frontend/src/views/SalesPlanningView.vue` - Create: `frontend/scripts/test-sales-purchase-document-archive-ui.mjs` - [ ] **Step 1: Write the failing sales UI static test** Create `frontend/scripts/test-sales-purchase-document-archive-ui.mjs`: ```javascript import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; const sales = readFileSync(resolve(process.cwd(), "src/views/SalesPlanningView.vue"), "utf8"); const purchase = readFileSync(resolve(process.cwd(), "src/views/PurchaseOrderView.vue"), "utf8"); const api = readFileSync(resolve(process.cwd(), "src/services/api.js"), "utf8"); for (const source of [sales, purchase]) { assert.match(source, /DocumentPaper/); assert.match(source, /DocumentGrid/); assert.match(source, /DocumentLineTable/); assert.match(source, /DocumentArchiveActions/); assert.match(source, /archive_status/); } assert.match(sales, /title="销售订单"/); assert.match(sales, /signature-labels="\['销售确认', '客户确认', '财务复核', '制单人'\]"/); assert.match(sales, /previewDocumentArchive\('sales-order'/); assert.match(sales, /downloadDocumentArchive\('sales-order'/); assert.match(sales, /regenerateDocumentArchive\('sales-order'/); assert.match(api, /postDownloadResource/); console.log("sales and purchase document archive UI static tests passed"); ``` This test is expected to fail until both sales and purchase views are migrated. Task 7 makes the sales-related assertions pass; Task 8 makes the full script pass. - [ ] **Step 2: Add batch download helper** Modify `frontend/src/services/api.js` and add after `downloadResource()`: ```javascript export async function postDownloadResource(path, payload, fallbackFilename = "download.zip") { const session = readSession(); const authHeaders = session?.access_token && !isSessionExpired(session) ? { Authorization: `Bearer ${session.access_token}` } : {}; const response = await fetch(`${API_BASE_URL}${path}`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders }, body: JSON.stringify(payload) }); if ((response.status === 401 || response.status === 403) && typeof window !== "undefined" && window.location.pathname !== "/login") { clearSession(); window.location.href = "/login"; } if (!response.ok) { return parseResponse(response); } const blob = await response.blob(); const filename = getDownloadFilename(response, fallbackFilename); const url = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url); return { filename }; } ``` - [ ] **Step 3: Import document components and archive helpers in sales view** Modify `frontend/src/views/SalesPlanningView.vue`. Add imports: ```javascript import DocumentArchiveActions from "../components/documentForms/DocumentArchiveActions.vue"; import DocumentGrid from "../components/documentForms/DocumentGrid.vue"; import DocumentLineTable from "../components/documentForms/DocumentLineTable.vue"; import DocumentPaper from "../components/documentForms/DocumentPaper.vue"; import { downloadResource, openResource, postDownloadResource, postResource, putResource } from "../services/api"; ``` If the file already imports these API functions separately, merge them into one import without duplicates. - [ ] **Step 4: Add sales document computed fields** Inside `