ForgeFlow-ERP/docs/superpowers/plans/2026-06-11-sales-purchase-document-form-archive.md
2026-06-12 16:00:56 +08:00

100 KiB
Raw Blame History

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() 合同代码作为订单归档。
  • 业务保存成功后生成 PDFPDF 生成失败不能回滚业务单据,但列表必须显示“归档失败”并允许重试。
  • 归档状态、业务文案和错误信息使用中文:未生成已归档归档失败
  • 当前项目目录可能不是 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:

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:

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:

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:

from app.models.document_archive import DocumentArchive

Add "DocumentArchive" to __all__.

  • Step 5: Create archive schemas

Create backend/app/schemas/document_archives.py:

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:

    archive_status: str = "未生成"
    archive_version: int | None = None
    archive_created_at: datetime | None = None
    archive_error_message: str | None = None

In SalesOrderCreateResult, add:

    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:

    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:

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:

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:

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:

reportlab>=4.2,<5.0
  • Step 2: Write the failing service test

Create backend/tests/test_document_archive_service.py:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

api_router.include_router(document_archives.router, prefix="/document-archives", tags=["document-archives"])
  • Step 5: Run the route test to verify it passes

Run:

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:

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:

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:

from app.models.document_archive import DocumentArchive

Inside get_sales_order_query(), add this subquery after the existing subqueries:

    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:

            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:

        .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:

from app.services.document_archives import DOCUMENT_TYPE_SALES_ORDER, generate_document_archive

In create_sales_order(), after db.commit() and before returning, add:

    archive_result = generate_document_archive(db, DOCUMENT_TYPE_SALES_ORDER, int(order.id))

Update the return:

    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:

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:

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:

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:

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:

    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:

            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:

        .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:

from app.services.document_archives import DOCUMENT_TYPE_PURCHASE_ORDER, generate_document_archive

In create_purchase_order(), after db.commit(), add:

    archive_result = generate_document_archive(db, DOCUMENT_TYPE_PURCHASE_ORDER, int(order.id))

Then read the purchase order and attach archive fields:

    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:

    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:

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:

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:

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:

<template>
  <section class="document-paper-shell" :class="toneClass">
    <div class="document-paper-stack document-paper-stack-back"></div>
    <div class="document-paper-stack document-paper-stack-mid"></div>
    <article class="document-paper-sheet">
      <div class="document-paper-clip" aria-hidden="true"></div>
      <div class="document-paper-binder" aria-hidden="true">
        <span class="document-paper-binder-hole"></span>
        <span class="document-paper-binder-hole"></span>
        <span class="document-paper-binder-hole"></span>
      </div>
      <span class="document-retained-mark">ERP留存联</span>
      <header class="document-paper-head">
        <p class="document-company">{{ companyName }}</p>
        <h2>{{ title }}</h2>
        <div class="document-no-stamp">
          <span>单号</span>
          <strong>{{ documentNo || "保存后生成" }}</strong>
        </div>
      </header>
      <slot />
      <footer class="document-signature-row">
        <div v-for="label in signatureLabels" :key="label" class="document-signature-cell">
          <span>{{ label }}</span>
          <strong>签字</strong>
        </div>
      </footer>
    </article>
  </section>
</template>

<script setup>
import { computed } from "vue";

const props = defineProps({
  title: {
    type: String,
    required: true
  },
  documentNo: {
    type: String,
    default: ""
  },
  companyName: {
    type: String,
    default: "嘉恒仓库 ERP"
  },
  tone: {
    type: String,
    default: "red"
  },
  signatureLabels: {
    type: Array,
    default: () => ["制单人", "确认人", "复核人", "归档人"]
  }
});

const toneClass = computed(() => `document-paper-${props.tone === "green" ? "green" : "red"}`);
</script>
  • Step 4: Create DocumentGrid.vue

Create frontend/src/components/documentForms/DocumentGrid.vue:

<template>
  <section class="document-grid" :style="{ '--document-grid-columns': columns }">
    <div v-for="field in fields" :key="field.key || field.label" class="document-grid-cell" :class="{ 'document-grid-cell-wide': field.wide }">
      <span>{{ field.label }}</span>
      <slot :name="field.key" :field="field">
        <strong>{{ field.value || "-" }}</strong>
      </slot>
    </div>
  </section>
</template>

<script setup>
defineProps({
  fields: {
    type: Array,
    required: true
  },
  columns: {
    type: [String, Number],
    default: 4
  }
});
</script>
  • Step 5: Create DocumentLineTable.vue

Create frontend/src/components/documentForms/DocumentLineTable.vue:

<template>
  <section class="document-line-block">
    <div class="document-line-toolbar">
      <strong>{{ title }}</strong>
      <slot name="actions" />
    </div>
    <div class="document-line-table-wrap">
      <table class="document-line-table">
        <thead>
          <tr>
            <th v-for="column in columns" :key="column.key" :style="{ width: column.width || 'auto' }">
              {{ column.label }}
            </th>
          </tr>
        </thead>
        <tbody>
          <slot />
        </tbody>
      </table>
    </div>
  </section>
</template>

<script setup>
defineProps({
  title: {
    type: String,
    default: "单据明细"
  },
  columns: {
    type: Array,
    required: true
  }
});
</script>
  • Step 6: Create DocumentArchiveActions.vue

Create frontend/src/components/documentForms/DocumentArchiveActions.vue:

<template>
  <div class="document-archive-actions">
    <span class="document-archive-status" :class="statusClass">{{ normalizedStatus }}</span>
    <button class="action-button action-button-secondary" type="button" :disabled="!canUseArchive" @click="$emit('preview')">
      预览PDF
    </button>
    <button class="action-button action-button-primary" type="button" :disabled="!canUseArchive" @click="$emit('download')">
      下载PDF
    </button>
    <button class="action-button action-button-warning" type="button" @click="$emit('regenerate')">
      重新生成
    </button>
  </div>
</template>

<script setup>
import { computed } from "vue";

const props = defineProps({
  status: {
    type: String,
    default: "未生成"
  }
});

defineEmits(["preview", "download", "regenerate"]);

const normalizedStatus = computed(() => props.status || "未生成");
const canUseArchive = computed(() => normalizedStatus.value === "已归档");
const statusClass = computed(() => ({
  "document-archive-status-ready": normalizedStatus.value === "已归档",
  "document-archive-status-failed": normalizedStatus.value === "归档失败",
  "document-archive-status-missing": normalizedStatus.value === "未生成"
}));
</script>
  • Step 7: Add document paper CSS

Append to frontend/src/styles/main.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:

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:

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

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:

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 <script setup> of SalesPlanningView.vue, add:

const salesDocumentHeaderFields = computed(() => [
  { key: "order_no", label: "销售订单号" },
  { key: "customer", label: "客户" },
  { key: "sales_employee", label: "销售人员" },
  { key: "promised_date", label: "交期" },
  { key: "tax_rate", label: "税率" },
  { key: "delivery_address", label: "送货地址", wide: true },
  { key: "remark", label: "备注", wide: true }
]);

const salesDocumentLineColumns = [
  { key: "index", label: "序号", width: "56px" },
  { key: "product", label: "产品", width: "240px" },
  { key: "customer_part_no", label: "客户料号", width: "140px" },
  { key: "order_qty", label: "订单数量", width: "120px" },
  { key: "unit_price", label: "销售单价", width: "120px" },
  { key: "promised_date", label: "明细交期", width: "150px" },
  { key: "actions", label: "操作", width: "98px" }
];
  • Step 5: Replace sales order drawer internals with document paper

In the FormDrawer whose title="新增销售订单", replace the internal <form class="stack-form" ...> body with:

<form class="stack-form document-form-stack" @submit.prevent="submitSalesOrder">
  <DocumentPaper
    title="销售订单"
    :document-no="salesOrderNoPreview"
    :signature-labels="['销售确认', '客户确认', '财务复核', '制单人']"
    tone="red"
  >
    <DocumentGrid :fields="salesDocumentHeaderFields" :columns="4">
      <template #order_no>
        <input :value="salesOrderNoPreview" type="text" disabled readonly />
      </template>
      <template #customer>
        <select v-model.number="salesOrderForm.customer_id" required @change="applySalesCustomerDefaults">
          <option disabled value="">请选择客户</option>
          <option v-for="customer in customers" :key="customer.id" :value="customer.id">
            {{ customer.customer_name }}
          </option>
        </select>
      </template>
      <template #sales_employee>
        <select v-model.number="salesOrderForm.sales_employee_id" required>
          <option disabled value="">请选择销售人员</option>
          <option v-for="employee in salesEmployeeOptions" :key="employee.employee_id" :value="employee.employee_id">
            {{ employeeOptionLabel(employee) }}
          </option>
        </select>
      </template>
      <template #promised_date>
        <input v-model="salesOrderForm.promised_date" type="date" />
      </template>
      <template #tax_rate>
        <input v-model.number="salesOrderForm.tax_rate" type="number" min="0" step="0.0001" />
      </template>
      <template #delivery_address>
        <input v-model.trim="salesOrderForm.delivery_address" type="text" />
      </template>
      <template #remark>
        <textarea v-model.trim="salesOrderForm.remark" rows="2"></textarea>
      </template>
    </DocumentGrid>

    <DocumentLineTable title="订单明细" :columns="salesDocumentLineColumns">
      <template #actions>
        <button class="ghost-button" type="button" @click="addSalesOrderItem">增加一行</button>
      </template>
      <tr v-for="(item, index) in salesOrderForm.items" :key="item.row_uid">
        <td>{{ index + 1 }}</td>
        <td>
          <select v-model.number="item.product_item_id" required>
            <option disabled value="">请选择产品</option>
            <option v-for="product in products" :key="product.item_id" :value="product.item_id">
              {{ product.item_code }} · {{ product.item_name }} · {{ product.version_no }}
            </option>
          </select>
        </td>
        <td><input v-model.trim="item.customer_part_no" type="text" /></td>
        <td><input v-model.number="item.order_qty" type="number" min="0.001" step="0.001" required /></td>
        <td><input v-model.number="item.unit_price" type="number" min="0" step="0.0001" /></td>
        <td><input v-model="item.promised_date" type="date" @input="markSalesOrderItemPromisedDateTouched(item)" /></td>
        <td>
          <button v-if="salesOrderForm.items.length > 1" class="ghost-button danger-ghost" type="button" @click="removeSalesOrderItem(index)">
            删除
          </button>
        </td>
      </tr>
    </DocumentLineTable>
  </DocumentPaper>

  <div class="drawer-form-actions document-form-actions">
    <button class="primary-button" type="submit" :disabled="salesOrderSubmitting">
      {{ salesOrderSubmitting ? "保存并归档中..." : "保存并生成PDF归档" }}
    </button>
  </div>
</form>
  • Step 6: Add sales archive action methods

Inside SalesPlanningView.vue script, add:

async function previewDocumentArchive(typeKey, businessId) {
  try {
    await openResource(`/document-archives/${typeKey}/${businessId}/latest/preview`);
  } catch (error) {
    errorMessage.value = error.message || "预览归档失败";
  }
}

async function downloadDocumentArchive(typeKey, businessId, fallbackFilename) {
  try {
    await downloadResource(`/document-archives/${typeKey}/${businessId}/latest/download`, fallbackFilename);
  } catch (error) {
    errorMessage.value = error.message || "下载归档失败";
  }
}

async function regenerateDocumentArchive(typeKey, businessId) {
  try {
    const result = await postResource(`/document-archives/${typeKey}/${businessId}/generate`, {});
    feedbackMessage.value = `${result.document_no} ${result.archive_status}`;
    await loadAll();
  } catch (error) {
    errorMessage.value = error.message || "重新生成归档失败";
  }
}

async function batchDownloadSalesArchives() {
  const businessIds = selectedOrderIds.value.map((id) => Number(id)).filter(Boolean);
  if (!businessIds.length) {
    errorMessage.value = "请先勾选要下载归档的销售订单";
    return;
  }
  try {
    await postDownloadResource("/document-archives/batch-download", { document_type: "sales-order", business_ids: businessIds }, "销售订单归档.zip");
  } catch (error) {
    errorMessage.value = error.message || "批量下载销售订单归档失败";
  }
}
  • Step 7: Add sales list archive actions

In the sales order table, add an archive status column near status:

<th>归档</th>

Add the corresponding cell:

<td>
  <DocumentArchiveActions
    :status="order.archive_status"
    @preview="previewDocumentArchive('sales-order', order.order_id)"
    @download="downloadDocumentArchive('sales-order', order.order_id, `${order.order_no}.pdf`)"
    @regenerate="regenerateDocumentArchive('sales-order', order.order_id)"
  />
</td>

Add a batch download button near existing batch actions:

<button class="ghost-button" type="button" :disabled="!selectedOrderIds.length" @click="batchDownloadSalesArchives">
  批量下载PDF归档
</button>

Update empty-state colspan by adding one to the existing value.

  • Step 8: Run a partial static check

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node -e "const fs=require('fs'); const s=fs.readFileSync('src/views/SalesPlanningView.vue','utf8'); for (const x of ['DocumentPaper','保存并生成PDF归档','previewDocumentArchive(\\'sales-order\\'']) { if (!s.includes(x)) throw new Error(x); } console.log('sales document UI snippets passed')"

Expected: PASS.


Task 8: Purchase Order Document UI And Archive Actions

Files:

  • Modify: frontend/src/views/PurchaseOrderView.vue

  • Test: frontend/scripts/test-sales-purchase-document-archive-ui.mjs

  • Step 1: Import document components and archive helpers in purchase view

Modify frontend/src/views/PurchaseOrderView.vue.

Add imports:

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";

Merge with existing imports to avoid duplicate imports from ../services/api.

  • Step 2: Add purchase document computed fields

Inside <script setup> of PurchaseOrderView.vue, add:

const purchaseDocumentHeaderFields = computed(() => [
  { key: "po_no", label: "采购单号" },
  { key: "target_warehouse_type", label: "到货库" },
  { key: "purchase_type", label: "采购方式" },
  { key: "supplier", label: "供应商" },
  { key: "purchaser", label: "采购人员" },
  { key: "expected_date", label: "预计到货" },
  { key: "warning_lead_days", label: "提前预警天数" },
  { key: "tax_rate", label: "税率" },
  { key: "sales_orders", label: "关联销售订单", wide: true },
  { key: "remark", label: "备注", wide: true }
]);

const purchaseDocumentLineColumns = [
  { key: "index", label: "序号", width: "56px" },
  { key: "material", label: "原材料/辅料", width: "260px" },
  { key: "weight_or_qty", label: "采购重量/数量", width: "150px" },
  { key: "ton_helper", label: "折合吨", width: "110px" },
  { key: "unit_price", label: "单价", width: "120px" },
  { key: "price_ton", label: "元/吨", width: "110px" },
  { key: "remark", label: "性能要求/备注", width: "190px" },
  { key: "actions", label: "操作", width: "98px" }
];
  • Step 3: Wrap purchase drawer content in document paper

In the FormDrawer for purchase order, keep the existing path selection, sales order picker, calculator and item logic, but place them inside:

<form class="stack-form document-form-stack" @submit.prevent="submitOrder">
  <DocumentPaper
    title="采购订单"
    :document-no="editingOrderId ? editingOrderNo : '保存后生成'"
    :signature-labels="['采购确认', '供应商确认', '仓库接收', '制单人']"
    tone="green"
  >
    <DocumentGrid :fields="purchaseDocumentHeaderFields" :columns="4">
      <template #po_no>
        <input :value="editingOrderId ? editingOrderNo : '保存后生成'" type="text" disabled readonly />
      </template>
      <template #target_warehouse_type>
        <select v-model="form.target_warehouse_type" @change="setTargetWarehouseType(form.target_warehouse_type)">
          <option value="RAW">原材料库</option>
          <option value="AUX">辅料库</option>
        </select>
      </template>
      <template #purchase_type>
        <select v-model="form.purchase_type" :disabled="form.target_warehouse_type === 'AUX'" @change="setPurchaseType(form.purchase_type)">
          <option value="STOCKING">备料采购</option>
          <option v-if="form.target_warehouse_type === 'RAW'" value="SALES_ORDER">销售订单采购</option>
        </select>
      </template>
      <template #supplier>
        <select v-model.number="form.supplier_id" required>
          <option disabled value="">请选择供应商</option>
          <option v-for="supplier in suppliers" :key="supplier.id" :value="supplier.id">
            {{ supplier.supplier_name }}
          </option>
        </select>
      </template>
      <template #purchaser>
        <select v-model.number="form.purchaser_employee_id">
          <option value="">请选择采购人员</option>
          <option v-for="employee in purchaserEmployeeOptions" :key="employee.employee_id" :value="employee.employee_id">
            {{ employeeOptionLabel(employee) }}
          </option>
        </select>
      </template>
      <template #expected_date>
        <input v-model="form.expected_date" type="date" />
      </template>
      <template #warning_lead_days>
        <input v-model.number="form.warning_lead_days" type="number" min="0" step="1" />
      </template>
      <template #tax_rate>
        <input v-model.number="form.tax_rate" type="number" min="0" step="0.0001" />
      </template>
      <template #sales_orders>
        <div v-if="form.purchase_type === 'SALES_ORDER'" ref="salesOrderPickerRef" class="order-tag-picker document-embedded-picker" @pointerdown.stop @mousedown.stop @click.stop>
          <input
            v-model.trim="salesOrderKeyword"
            type="text"
            placeholder="搜索销售订单号、客户、产品、状态"
            @focus="salesOrderPickerOpen = true"
            @keydown.esc.prevent="salesOrderPickerOpen = false"
          />
          <div class="selected-order-tags">
            <span v-for="order in selectedSalesOrders" :key="order.order_id" class="selected-lot-tag">
              {{ order.order_no }}
              <small>{{ order.customer_name || "-" }}</small>
              <button type="button" class="tag-remove" aria-label="移除销售订单" @click.stop="removeSalesOrder(order.order_id)">×</button>
            </span>
            <span v-if="!selectedSalesOrders.length" class="empty-inline">请选择至少一张销售订单</span>
          </div>
          <div v-if="salesOrderPickerOpen" class="stock-lot-dropdown order-tag-dropdown">
            <button
              v-for="order in filteredSalesOrders"
              :key="order.order_id"
              type="button"
              class="stock-lot-option"
              :class="{ 'stock-lot-option-selected': isSalesOrderSelected(order.order_id) }"
              @click.stop="toggleSalesOrder(order.order_id)"
            >
              <span class="lot-main">{{ order.order_no }} · {{ order.customer_name || "-" }}</span>
              <span class="lot-meta">{{ salesOrderProductNames(order) }} · {{ formatStatusLabel(order.status) }}</span>
              <em>{{ isSalesOrderSelected(order.order_id) ? "已选择,点击取消" : "点击选择" }}</em>
            </button>
            <div v-if="!filteredSalesOrders.length" class="stock-lot-empty">没有匹配的销售订单</div>
          </div>
        </div>
        <span v-else>备料采购不关联销售订单</span>
      </template>
      <template #remark>
        <textarea v-model.trim="form.remark" rows="2"></textarea>
      </template>
    </DocumentGrid>

    <DocumentLineTable title="采购明细" :columns="purchaseDocumentLineColumns">
      <template #actions>
        <button class="ghost-button" type="button" @click="addItem">增加一行</button>
      </template>
      <tr v-for="(item, index) in form.items" :key="item.row_uid">
        <td>{{ index + 1 }}</td>
        <td>
          <select v-model.number="item.material_item_id" required @change="handleMaterialChange(index)">
            <option disabled value="">请选择{{ form.target_warehouse_type === 'AUX' ? '辅料' : '原材料' }}</option>
            <option v-for="material in materialOptions" :key="material.item_id" :value="material.item_id">
              {{ formatMaterialOptionLabel(material, item) }}
            </option>
          </select>
        </td>
        <td>
          <input
            v-if="form.target_warehouse_type === 'AUX'"
            v-model.number="item.order_qty"
            type="number"
            min="0"
            step="0.001"
            required
          />
          <input
            v-else
            v-model.number="item.order_weight_kg"
            type="number"
            min="0"
            step="0.001"
            required
          />
        </td>
        <td>{{ form.target_warehouse_type === 'AUX' ? "-" : formatTon(item.order_weight_kg) }}</td>
        <td><input v-model.number="item.unit_price" type="number" min="0" step="0.0001" /></td>
        <td>{{ form.target_warehouse_type === 'AUX' ? "-" : formatPricePerTon(item.unit_price) }}</td>
        <td><input v-model.trim="item.remark" type="text" /></td>
        <td>
          <button v-if="form.items.length > 1" class="ghost-button danger-ghost" type="button" @click="removeItem(index)">
            删除
          </button>
        </td>
      </tr>
    </DocumentLineTable>

    <aside v-if="form.purchase_type === 'STOCKING' && form.target_warehouse_type === 'RAW'" class="purchase-demand-calculator document-demand-calculator">
      <div class="subsection-head">
        <span class="title-with-help">
          <strong>采购需求计算器</strong>
          <FieldHint text="根据当前明细原材料反查产品需规,按预计成品数量和产品毛重计算原材料需求。" />
        </span>
        <span class="panel-tag">参考</span>
      </div>

      <label class="form-field">
        <span>关联采购明细</span>
        <select v-model.number="calculator.line_index">
          <option v-for="(_, index) in form.items" :key="index" :value="index">
            明细 {{ index + 1 }}
          </option>
        </select>
      </label>

      <div class="calculator-selected-material">
        <span>当前原材料</span>
        <strong>{{ selectedCalculatorMaterial ? `${selectedCalculatorMaterial.item_code} · ${selectedCalculatorMaterial.item_name}` : "请先在右侧选择原材料" }}</strong>
      </div>

      <label class="form-field">
        <span>对应产品</span>
        <select v-model="calculator.product_key" :disabled="!calculatorProductOptions.length">
          <option disabled value="">请选择产品</option>
          <option v-for="option in calculatorProductOptions" :key="option.key" :value="option.key">
            {{ option.product_code }} · {{ option.product_name }} · {{ option.version_no }}
          </option>
        </select>
      </label>

      <p v-if="selectedCalculatorMaterial && !calculatorProductOptions.length" class="calculator-warning">
        未在 BOM 或小程序产品清单中找到使用该原材料的产品。
      </p>
      <p v-else-if="selectedCalculatorProduct && calculatorGrossWeight <= 0" class="calculator-warning">
        已匹配到产品,但该产品未维护产品毛重,无法计算需求量。
      </p>

      <label class="form-field">
        <span>预计成品数量</span>
        <input v-model.number="calculator.planned_finished_qty" type="number" min="0" step="1" />
      </label>

      <div class="calculator-result-grid">
        <div>
          <span>产品毛重</span>
          <strong>{{ formatWeight(calculatorGrossWeight) }}</strong>
          <small>{{ formatTonHint(calculatorGrossWeight) }}</small>
        </div>
        <div>
          <span>理论需求</span>
          <strong>{{ formatWeight(calculatorDemandWeight) }}</strong>
          <small>{{ formatTonHint(calculatorDemandWeight) }}</small>
        </div>
        <div>
          <span>当前库存</span>
          <strong>{{ formatWeight(calculatorStockWeight) }}</strong>
          <small>{{ formatTonHint(calculatorStockWeight) }}</small>
        </div>
        <div>
          <span>建议采购</span>
          <strong>{{ formatWeight(calculatorMinPurchaseWeight) }}</strong>
          <small>{{ formatTonHint(calculatorMinPurchaseWeight) }}</small>
        </div>
      </div>

      <button class="ghost-button" type="button" :disabled="!canApplyCalculator" @click="applyCalculatorToLine">
        填入当前明细
      </button>
    </aside>
  </DocumentPaper>

  <div class="drawer-form-actions document-form-actions">
    <button class="primary-button" type="submit" :disabled="submitting">
      {{ submitting ? "保存并归档中..." : editingOrderId ? "更新并生成PDF归档" : "保存并生成PDF归档" }}
    </button>
  </div>
</form>

During implementation, if the current PurchaseOrderView.vue calculator contains extra fields beyond the block above, copy those existing fields into this same <aside> and keep their current v-model, computed values, methods and conditions unchanged.

  • Step 4: Add purchase archive action methods

Inside PurchaseOrderView.vue script, add:

async function previewDocumentArchive(typeKey, businessId) {
  try {
    await openResource(`/document-archives/${typeKey}/${businessId}/latest/preview`);
  } catch (error) {
    errorMessage.value = error.message || "预览归档失败";
  }
}

async function downloadDocumentArchive(typeKey, businessId, fallbackFilename) {
  try {
    await downloadResource(`/document-archives/${typeKey}/${businessId}/latest/download`, fallbackFilename);
  } catch (error) {
    errorMessage.value = error.message || "下载归档失败";
  }
}

async function regenerateDocumentArchive(typeKey, businessId) {
  try {
    const result = await postResource(`/document-archives/${typeKey}/${businessId}/generate`, {});
    feedbackMessage.value = `${result.document_no} ${result.archive_status}`;
    await loadAll();
  } catch (error) {
    errorMessage.value = error.message || "重新生成归档失败";
  }
}

async function batchDownloadPurchaseArchives() {
  const businessIds = selectedOrderIds.value.map((id) => Number(id)).filter(Boolean);
  if (!businessIds.length) {
    errorMessage.value = "请先勾选要下载归档的采购订单";
    return;
  }
  try {
    await postDownloadResource("/document-archives/batch-download", { document_type: "purchase-order", business_ids: businessIds }, "采购订单归档.zip");
  } catch (error) {
    errorMessage.value = error.message || "批量下载采购订单归档失败";
  }
}
  • Step 5: Add purchase list archive actions

In the purchase order table, add a 归档 column near status:

<th>归档</th>

Add the corresponding cell:

<td>
  <DocumentArchiveActions
    :status="order.archive_status"
    @preview="previewDocumentArchive('purchase-order', order.purchase_order_id)"
    @download="downloadDocumentArchive('purchase-order', order.purchase_order_id, `${order.po_no}.pdf`)"
    @regenerate="regenerateDocumentArchive('purchase-order', order.purchase_order_id)"
  />
</td>

Add a batch download button near existing batch actions:

<button class="ghost-button" type="button" :disabled="!selectedOrderIds.length" @click="batchDownloadPurchaseArchives">
  批量下载PDF归档
</button>

Update empty-state colspan by adding one to the existing value.

  • Step 6: Run the full frontend static test

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-document-form-components.mjs
node scripts/test-sales-purchase-document-archive-ui.mjs

Expected: PASS.


Task 9: End-To-End Verification And SQL Application

Files:

  • No new files expected.

  • Step 1: Run backend targeted tests

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
PYTHONPATH=backend pytest \
  backend/tests/test_document_archive_model.py \
  backend/tests/test_document_archive_service.py \
  backend/tests/test_document_archive_routes.py \
  backend/tests/test_sales_order_document_archive.py \
  backend/tests/test_purchase_order_document_archive.py \
  backend/tests/test_sales_order_no_generation.py \
  backend/tests/test_purchase_order_no_generation.py \
  backend/tests/test_purchase_order_multi_sales_links.py \
  -q

Expected: PASS.

  • Step 2: Run frontend static tests

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-document-form-components.mjs
node scripts/test-sales-purchase-document-archive-ui.mjs

Expected: PASS.

  • Step 3: Build frontend

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build

Expected: Vite build completes. Existing chunk-size warning is acceptable; syntax or module errors are not acceptable.

  • Step 4: Apply SQL patch to the active test database

Use the project's current database connection settings from the backend environment. If running locally with a MySQL shell:

mysql --default-character-set=utf8mb4 -h <数据库IP> -P <端口> -u <用户名> -p jiaheng_erp < backend/sql/add_document_archives_patch.sql

Expected: SQL completes without error and SHOW TABLES LIKE 'document_archives'; returns one row.

  • Step 5: Manual browser verification

Open http://127.0.0.1:5173/ after backend and frontend are running.

Verify sales order:

  • Open 订单与计划 -> 销售订单列表.
  • Click新增销售订单.
  • Drawer still opens from the same place.
  • Inner form looks like the approved v3 real-paper document.
  • Create one sales order.
  • List row shows 已归档.
  • Click 预览PDF; browser opens PDF.
  • Click 下载PDF; file downloads.
  • Select one or more orders and click 批量下载PDF归档; zip downloads.

Verify purchase order:

  • Open 采购与库存 -> 采购订单.

  • Click新增采购订单.

  • Drawer still opens from the same place.

  • Inner form looks like the approved v3 real-paper document.

  • Create one purchase order.

  • List row shows 已归档.

  • Edit an editable purchase order and save.

  • Archive version increments.

  • Click 预览PDF and 下载PDF.

  • Select one or more orders and click 批量下载PDF归档; zip downloads.

  • Step 6: Failure-path manual verification

Temporarily make outputs/document_archives unwritable or monkeypatch render_pdf_archive() to raise RuntimeError("测试归档失败") in a local test run.

Expected:

  • Sales/purchase business order is still saved.
  • List shows 归档失败.
  • 重新生成 retries and changes status to 已归档 after the failure condition is removed.

Self-Review Checklist

  • Spec coverage:

    • v3 paper visual: Task 6, Task 7, Task 8.
    • Sales order document form: Task 7.
    • Purchase order document form: Task 8.
    • Backend PDF generation, not browser print: Task 2.
    • Archive table with version, template version, hash, status, error: Task 1 and Task 2.
    • Save-success archive generation: Task 4 and Task 5.
    • Failure does not block business save: Task 2 service behavior and Task 9 failure-path verification.
    • Preview, download, batch download: Task 3, Task 7, Task 8.
    • Word not primary archive: scope section fixes the boundary; no Word code is introduced.
  • Placeholder scan:

    • No placeholder markers remain in implementation steps.
    • Task 8 keeps the existing calculator field bindings, computed values, methods and conditions fixed while moving it into the document paper area.
  • Type consistency:

    • document_type values are Chinese: 销售订单, 采购订单.
    • Route keys are URL-safe: sales-order, purchase-order, mapped to Chinese business values.
    • Archive statuses are Chinese: 未生成, 已归档, 归档失败.
    • Frontend uses existing order_id for sales and purchase_order_id for purchase.
    • Backend service returns DocumentArchiveGenerateResult, and route/list schemas use archive_status, archive_version, archive_error_message.

Execution Recommendation

This plan is best executed with Subagent-Driven Development because the backend archive foundation, sales UI, and purchase UI can be reviewed in clean checkpoints. Use one subagent per backend foundation/API task and one subagent per frontend migration task, then run the verification task inline.