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