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

62 KiB
Raw Blame History

Production Warehouse Document 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: Add formal PDF archive support for the production warehouse chain: production material issue, production ledger inbound, finished production inbound, production surplus inbound, and production scrap inbound.

Architecture: Reuse the existing document_archives table, PDF renderer, and /document-archives API. Add two document types, 生产领料出库单 keyed by ProductionBatchLedger.id and 生产入库结算单 keyed by the main InventoryTxn.id; expose archive status through production ledger rows and production ledger transaction rows.

Tech Stack: FastAPI, SQLAlchemy, Pydantic, ReportLab PDF generation, Vue 3, existing document form/archive components.


File Structure

Scope Mapping

  • 原材料库 · 生产出库: implemented by 生产领料出库单, generated after raw material production outbound updates ProductionBatchLedger.
  • 生产台账入口 · 生产工单入库: implemented by 生产入库结算单, generated after /production/production-ledger-inbounds saves.
  • 成品库 · 生产入库: implemented by 生产入库结算单, generated from the branch production finished inbound main InventoryTxn.
  • 原材料库 · 生产余料入库: implemented by 生产入库结算单, generated from the branch production surplus inbound main InventoryTxn.
  • 废料库 · 生产废料入库: implemented by 生产入库结算单, generated from the branch production scrap inbound main InventoryTxn.

Backend

  • Modify: backend/app/services/document_archives.py
    • Add production warehouse document constants.
    • Add production material issue context collector.
    • Add production inbound settlement context collector.
    • Add helper to group settlement inventory transactions from a main transaction.
    • Add document type dispatch.
  • Modify: backend/app/api/routes/document_archives.py
    • Add route keys production-material-out and production-inbound-settlement.
    • Update Chinese validation error text.
  • Modify: backend/app/schemas/operations.py
    • Add archive fields to ProductionBatchLedgerRead.
    • Add archive fields to ProductionBatchLedgerTxnRead.
    • Add archive fields to ProductionBatchLedgerInboundRead.
    • Add archive fields to ProductionWorkOrderInboundRead.
    • Add optional archive fields to StockLotRead so warehouse branch entry responses can carry the archive result.
  • Modify: backend/app/api/routes/production.py
    • Generate 生产入库结算单 after /production/production-ledger-inbounds saves.
    • Generate 生产入库结算单 after /production/work-order-inbounds saves through the existing service response.
    • Add latest archive joins to /production/production-ledger.
  • Modify: backend/app/services/production_work_order_inbound.py
    • Generate 生产入库结算单 after old work-order inbound saves.
    • Return archive status fields without rolling back saved business data.
  • Modify: backend/app/api/routes/inventory.py
    • Generate 生产领料出库单 after raw material production outbound creates or updates a production ledger.
    • Generate 生产入库结算单 after branch production inbound creates a main inventory transaction.
    • Return archive status fields in existing response models.
  • Test: backend/tests/test_production_warehouse_document_archive.py
    • New focused backend tests for production warehouse archive collection and generation.
  • Modify: backend/tests/test_document_archive_routes.py
    • Add route key normalization assertions for production warehouse document types.

Frontend

  • Modify: frontend/src/views/ProductionLedgerView.vue
    • Import and render DocumentArchiveActions.
    • Show production material issue archive actions in the production ledger detail header.
    • Show production inbound settlement archive actions for production ledger transaction rows that came from inventory transactions.
    • Add preview/download/regenerate handlers using the two new route keys.
  • Modify: frontend/src/views/InventoryLedgerView.vue
    • Update production ledger inbound success feedback to include archive status.
    • Update generic production inbound/outbound success feedback to include archive status where the backend returns it.
    • Add archive action helpers only if needed by the visible warehouse flow; do not duplicate DocumentArchiveActions if production ledger already covers the detail action.
  • Test: frontend/src/views/ProductionLedgerView.test.js
    • New static tests for archive actions and route keys.
  • Modify: frontend/src/views/InventoryLedgerView.test.js if present; otherwise no new inventory static test is required.

Task 1: Backend Tests For Production Archive Types And Contexts

Files:

  • Create: backend/tests/test_production_warehouse_document_archive.py

  • Modify: backend/tests/test_document_archive_routes.py

  • Step 1: Create the focused backend test file

Create backend/tests/test_production_warehouse_document_archive.py with this complete structure. The seed intentionally uses only the tables needed by document archive context collection.

from __future__ import annotations

import unittest
from datetime import datetime
from decimal import Decimal
from tempfile import TemporaryDirectory
from unittest.mock import patch

from sqlalchemy import BigInteger, create_engine, select
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session, sessionmaker


@compiles(BigInteger, "sqlite")
def _compile_big_integer_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, Warehouse  # noqa: E402
from app.models.operations import InventoryTxn, ProductionBatchLedger, ProductionBatchLedgerTxn, StockLot  # noqa: E402
from app.services.document_archives import (  # noqa: E402
    ARCHIVE_STATUS_READY,
    DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
    DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
    collect_production_inbound_settlement_archive_context,
    collect_production_material_out_archive_context,
    generate_document_archive,
)


class ProductionWarehouseDocumentArchiveTest(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: Session = self.SessionLocal()
        self.now = datetime(2026, 6, 11, 10, 30, 0)
        self._seed_data()

    def tearDown(self) -> None:
        self.db.close()

    def _seed_data(self) -> None:
        self.raw_warehouse = Warehouse(
            id=1,
            warehouse_code="WH-RAW",
            warehouse_name="原材料库",
            warehouse_type="RAW",
            status="ACTIVE",
            created_at=self.now,
            updated_at=self.now,
        )
        self.finished_warehouse = Warehouse(
            id=2,
            warehouse_code="WH-FIN",
            warehouse_name="成品库",
            warehouse_type="FINISHED",
            status="ACTIVE",
            created_at=self.now,
            updated_at=self.now,
        )
        self.scrap_warehouse = Warehouse(
            id=3,
            warehouse_code="WH-SCRAP",
            warehouse_name="废料库",
            warehouse_type="SCRAP",
            status="ACTIVE",
            created_at=self.now,
            updated_at=self.now,
        )
        self.material = Item(
            id=10,
            item_code="RM-00010",
            item_name="测试冷轧板",
            item_type="RAW_MATERIAL",
            specification="1.2mm",
            material_grade="SPCC",
            unit_weight_kg=Decimal("0"),
            status="ACTIVE",
            created_at=self.now,
            updated_at=self.now,
        )
        self.product = Item(
            id=20,
            item_code="FG-00020",
            item_name="测试钢制碗",
            item_type="FINISHED",
            specification="φ180",
            material_grade=None,
            unit_weight_kg=Decimal("0.5"),
            status="ACTIVE",
            created_at=self.now,
            updated_at=self.now,
        )
        self.material_lot = StockLot(
            id=100,
            lot_no="YL0001",
            parent_lot_id=None,
            lot_role="INBOUND_RAW",
            material_sub_batch_no=None,
            item_id=self.material.id,
            warehouse_id=self.raw_warehouse.id,
            location_id=None,
            source_doc_type="PURCHASE_RECEIPT",
            source_doc_id=900,
            source_line_id=901,
            source_material_lot_id=None,
            source_material_sub_batch_no=None,
            source_material_summary=None,
            inbound_qty=0,
            inbound_weight_kg=500,
            remaining_qty=0,
            remaining_weight_kg=300,
            locked_qty=0,
            locked_weight_kg=0,
            unit_cost=Decimal("6.6"),
            production_date=None,
            expire_date=None,
            quality_status="PASS",
            status="AVAILABLE",
            remark="测试材料批次",
            created_at=self.now,
            updated_at=self.now,
        )
        self.ledger = ProductionBatchLedger(
            id=200,
            material_lot_id=self.material_lot.id,
            material_lot_no=self.material_lot.lot_no,
            material_item_id=self.material.id,
            product_item_id=self.product.id,
            status="在生产",
            miniapp_selectable=1,
            total_issued_weight_kg=Decimal("200"),
            outside_weight_kg=Decimal("80"),
            finished_inbound_qty=Decimal("198"),
            surplus_inbound_weight_kg=Decimal("60"),
            scrap_inbound_weight_kg=Decimal("2"),
            writeoff_weight_kg=Decimal("0"),
            first_issue_time=self.now,
            last_issue_time=self.now,
            locked_at=None,
            reopened_at=None,
            lock_count=0,
            remark="生产台账测试",
            created_at=self.now,
            updated_at=self.now,
        )
        self.issue_ledger_txn = ProductionBatchLedgerTxn(
            id=300,
            production_ledger_id=self.ledger.id,
            txn_type="生产出库",
            qty_delta=0,
            weight_delta_kg=Decimal("200"),
            outside_weight_after_kg=Decimal("200"),
            source_doc_type="库存流水",
            source_doc_id=400,
            source_line_id=self.material_lot.id,
            biz_time=self.now,
            operator_user_id=1,
            remark="原材料库生产出库形成生产台账",
            created_at=self.now,
            updated_at=self.now,
        )
        self.issue_inventory_txn = InventoryTxn(
            id=400,
            txn_no="TXN-ISSUE-001",
            txn_type="PRODUCTION_OUT",
            item_id=self.material.id,
            warehouse_id=self.raw_warehouse.id,
            location_id=None,
            lot_id=self.material_lot.id,
            qty_change=0,
            weight_change_kg=Decimal("-200"),
            unit_cost=Decimal("6.6"),
            amount=Decimal("1320"),
            source_doc_type="生产台账",
            source_doc_id=self.ledger.id,
            source_line_id=self.material_lot.id,
            biz_time=self.now,
            operator_user_id=1,
            remark="生产出库",
            created_at=self.now,
            updated_at=self.now,
        )
        self.finished_lot = StockLot(
            id=101,
            lot_no="FGL20260611001",
            parent_lot_id=self.material_lot.id,
            lot_role="FINISHED_FROM_RAW_BATCH",
            material_sub_batch_no=None,
            item_id=self.product.id,
            warehouse_id=self.finished_warehouse.id,
            location_id=None,
            source_doc_type="PRODUCTION_LEDGER_IN",
            source_doc_id=self.ledger.id,
            source_line_id=None,
            source_material_lot_id=self.material_lot.id,
            source_material_sub_batch_no=self.material_lot.lot_no,
            source_material_summary=f"{self.material.item_name}{self.material_lot.lot_no}",
            inbound_qty=198,
            inbound_weight_kg=99,
            remaining_qty=198,
            remaining_weight_kg=99,
            locked_qty=0,
            locked_weight_kg=0,
            unit_cost=Decimal("3.3"),
            production_date=self.now.date(),
            expire_date=None,
            quality_status="PASS",
            status="AVAILABLE",
            remark="生产台账入库;成品入库;来源库存批次已记录",
            created_at=self.now,
            updated_at=self.now,
        )
        self.finished_txn = InventoryTxn(
            id=401,
            txn_no="TXN-FG-001",
            txn_type="FG_IN",
            item_id=self.product.id,
            warehouse_id=self.finished_warehouse.id,
            location_id=None,
            lot_id=self.finished_lot.id,
            qty_change=198,
            weight_change_kg=99,
            unit_cost=Decimal("3.3"),
            amount=Decimal("653.4"),
            source_doc_type="生产台账",
            source_doc_id=self.ledger.id,
            source_line_id=self.finished_lot.id,
            biz_time=self.now,
            operator_user_id=1,
            remark="生产台账入库;成品入库;偏差说明:称重复核通过",
            created_at=self.now,
            updated_at=self.now,
        )
        self.surplus_txn = InventoryTxn(
            id=402,
            txn_no="TXN-SURPLUS-001",
            txn_type="PRODUCTION_SURPLUS_IN",
            item_id=self.material.id,
            warehouse_id=self.raw_warehouse.id,
            location_id=None,
            lot_id=self.material_lot.id,
            qty_change=0,
            weight_change_kg=60,
            unit_cost=Decimal("6.6"),
            amount=Decimal("396"),
            source_doc_type="生产台账",
            source_doc_id=self.ledger.id,
            source_line_id=self.material_lot.id,
            biz_time=self.now,
            operator_user_id=1,
            remark="生产台账入库;生产余料入库;偏差说明:称重复核通过",
            created_at=self.now,
            updated_at=self.now,
        )
        self.db.add_all(
            [
                self.raw_warehouse,
                self.finished_warehouse,
                self.scrap_warehouse,
                self.material,
                self.product,
                self.material_lot,
                self.ledger,
                self.issue_ledger_txn,
                self.issue_inventory_txn,
                self.finished_lot,
                self.finished_txn,
                self.surplus_txn,
            ]
        )
        self.db.commit()

    def test_collect_material_out_context_uses_production_ledger(self) -> None:
        context = collect_production_material_out_archive_context(self.db, self.ledger.id)

        self.assertEqual(context.document_type, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT)
        self.assertEqual(context.business_id, self.ledger.id)
        self.assertEqual(context.document_no, "生产领料-YL0001-测试钢制碗")
        self.assertEqual(context.partner_label, "生产产品")
        self.assertEqual(context.partner_name, "测试钢制碗")
        self.assertEqual(context.address, "原材料库")
        self.assertIn("材料库存批次号YL0001", context.remark)
        self.assertEqual(len(context.lines), 1)
        self.assertEqual(context.lines[0].item_name, "测试冷轧板")
        self.assertEqual(Decimal(str(context.lines[0].quantity)), Decimal("200"))
        self.assertEqual(Decimal(str(context.lines[0].line_amount)), Decimal("1320.00"))

    def test_collect_inbound_settlement_context_groups_same_save_action(self) -> None:
        context = collect_production_inbound_settlement_archive_context(self.db, self.finished_txn.id)

        self.assertEqual(context.document_type, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT)
        self.assertEqual(context.business_id, self.finished_txn.id)
        self.assertEqual(context.document_no, "生产入库结算-TXN-FG-001")
        self.assertEqual(context.partner_label, "生产产品")
        self.assertEqual(context.partner_name, "测试钢制碗")
        self.assertIn("材料库存批次号YL0001", context.remark)
        self.assertEqual(len(context.lines), 2)
        self.assertEqual([line.remark.split("")[0] for line in context.lines], ["入库类型:成品入库", "入库类型:生产余料入库"])

    def test_generate_production_material_out_archive_pdf(self) -> None:
        with TemporaryDirectory() as tmp_dir, patch("app.services.document_archives.default_archive_root", return_value=tmp_dir):
            result = generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, self.ledger.id, created_by=1)

        self.assertEqual(result.status, ARCHIVE_STATUS_READY)
        saved = self.db.scalar(select(DocumentArchive).where(DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT))
        self.assertIsNotNone(saved)
        self.assertEqual(saved.business_id, self.ledger.id)
        self.assertEqual(saved.archive_version, 1)

    def test_generate_production_inbound_settlement_archive_pdf(self) -> None:
        with TemporaryDirectory() as tmp_dir, patch("app.services.document_archives.default_archive_root", return_value=tmp_dir):
            result = generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, self.finished_txn.id, created_by=1)

        self.assertEqual(result.status, ARCHIVE_STATUS_READY)
        saved = self.db.scalar(select(DocumentArchive).where(DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT))
        self.assertIsNotNone(saved)
        self.assertEqual(saved.business_id, self.finished_txn.id)
        self.assertEqual(saved.archive_version, 1)


if __name__ == "__main__":
    unittest.main()
  • Step 2: Run the new test file and verify it fails for missing symbols

Run:

PYTHONPATH=backend backend/.venv/bin/pytest backend/tests/test_production_warehouse_document_archive.py -q

Expected: FAIL with import errors for DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, collect_production_inbound_settlement_archive_context, and collect_production_material_out_archive_context.

  • Step 3: Extend document archive route key tests

Modify backend/tests/test_document_archive_routes.py::DocumentArchiveRouteTest.test_normalize_document_type_key_supports_route_keys to include:

self.assertEqual(normalize_document_type_key("production-material-out"), "生产领料出库单")
self.assertEqual(normalize_document_type_key("production-inbound-settlement"), "生产入库结算单")
  • Step 4: Run route tests and verify they fail

Run:

PYTHONPATH=backend backend/.venv/bin/pytest backend/tests/test_document_archive_routes.py::DocumentArchiveRouteTest::test_normalize_document_type_key_supports_route_keys -q

Expected: FAIL because the new document type keys are not mapped yet.

  • Step 5: Commit tests

Run:

git add backend/tests/test_production_warehouse_document_archive.py backend/tests/test_document_archive_routes.py
git commit -m "test: cover production warehouse document archives"

Expected: commit succeeds in a git worktree. If the workspace reports fatal: not a git repository, record that commit was skipped because this project copy has no .git directory.


Task 2: Add Production Document Archive Contexts And Route Keys

Files:

  • Modify: backend/app/services/document_archives.py

  • Modify: backend/app/api/routes/document_archives.py

  • Test: backend/tests/test_production_warehouse_document_archive.py

  • Test: backend/tests/test_document_archive_routes.py

  • Step 1: Add imports and constants in document_archives.py

Modify imports:

from app.models.operations import (
    InventoryTxn,
    ProductionBatchLedger,
    ProductionBatchLedgerTxn,
    PurchaseOrder,
    PurchaseOrderItem,
    PurchaseReceipt,
    PurchaseReceiptItem,
    StockLot,
    Supplier,
)

Add constants below DOCUMENT_TYPE_QUALITY_INSPECTION:

DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT = "生产领料出库单"
DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT = "生产入库结算单"
  • Step 2: Add production helper functions

Add these helper functions below collect_quality_inspection_archive_context.

PRODUCTION_INBOUND_TXN_LABELS = {
    "FG_IN": "成品入库",
    "PRODUCTION_SURPLUS_IN": "生产余料入库",
    "PRODUCTION_SCRAP_IN": "生产废料入库",
}


def _production_document_no_part(value: str | None) -> str:
    text = str(value or "").strip()
    return text or "-"


def _production_amount(quantity_or_weight: Decimal | float | int | None, unit_cost: Decimal | float | int | None) -> Decimal:
    return Decimal(str(quantity_or_weight or 0)) * Decimal(str(unit_cost or 0))


def _production_ledger_row(db: Session, production_ledger_id: int):
    material_item = aliased(Item)
    product_item = aliased(Item)
    row = db.execute(
        select(
            ProductionBatchLedger,
            StockLot,
            material_item,
            product_item,
            Warehouse,
        )
        .join(StockLot, StockLot.id == ProductionBatchLedger.material_lot_id)
        .join(material_item, material_item.id == ProductionBatchLedger.material_item_id)
        .join(product_item, product_item.id == ProductionBatchLedger.product_item_id)
        .join(Warehouse, Warehouse.id == StockLot.warehouse_id)
        .where(ProductionBatchLedger.id == production_ledger_id)
    ).one_or_none()
    if row is None:
        raise HTTPException(status_code=404, detail="生产台账不存在")
    return row

Also add aliased to the SQLAlchemy import line:

from sqlalchemy.orm import Session, aliased
  • Step 3: Add collect_production_material_out_archive_context

Add:

def collect_production_material_out_archive_context(db: Session, production_ledger_id: int) -> ArchiveContext:
    ledger, material_lot, material, product, warehouse = _production_ledger_row(db, production_ledger_id)
    issue_txns = db.scalars(
        select(ProductionBatchLedgerTxn)
        .where(
            ProductionBatchLedgerTxn.production_ledger_id == production_ledger_id,
            ProductionBatchLedgerTxn.txn_type == "生产出库",
        )
        .order_by(ProductionBatchLedgerTxn.biz_time.asc(), ProductionBatchLedgerTxn.id.asc())
    ).all()
    issued_weight = Decimal(str(ledger.total_issued_weight_kg or 0))
    unit_cost = Decimal(str(material_lot.unit_cost or 0))
    amount = issued_weight * unit_cost
    latest_issue_time = ledger.last_issue_time or ledger.updated_at
    issue_count = len(issue_txns)
    line_remark_parts = [
        f"库存批次号:{ledger.material_lot_no}",
        f"累计生产出库次数:{issue_count}",
        f"库外未闭环重量:{decimal_text(ledger.outside_weight_kg, 3)}kg",
    ]
    if ledger.remark:
        line_remark_parts.append(str(ledger.remark))

    return ArchiveContext(
        document_type=DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
        business_id=ledger.id,
        document_no=f"生产领料-{_production_document_no_part(ledger.material_lot_no)}-{_production_document_no_part(product.item_name)}",
        title="生产领料出库单归档",
        partner_label="生产产品",
        partner_name=product.item_name,
        document_date=latest_issue_time,
        due_date_label="生产台账",
        due_date=ledger.id,
        status=archive_status_text(ledger.status),
        tax_rate=Decimal("0"),
        total_amount=amount,
        contact_name=None,
        contact_phone=None,
        address=warehouse.warehouse_name,
        remark=f"生产台账号:{ledger.id};材料库存批次号:{ledger.material_lot_no};产品编码:{product.item_code}",
        lines=[
            ArchiveLine(
                line_no=1,
                item_code=material.item_code,
                item_name=material.item_name,
                specification=material.specification,
                quantity=issued_weight,
                delivered_or_received_quantity=ledger.outside_weight_kg,
                unit_price=unit_cost,
                line_amount=amount,
                promised_or_expected_date=latest_issue_time,
                remark="".join(line_remark_parts),
            )
        ],
    )
  • Step 4: Add settlement grouping and collect_production_inbound_settlement_archive_context

Add:

def _production_inbound_related_txns(db: Session, main_txn: InventoryTxn) -> list[InventoryTxn]:
    source_doc_type = str(main_txn.source_doc_type or "")
    source_doc_id = int(main_txn.source_doc_id or 0)
    if source_doc_id <= 0:
        return [main_txn]
    rows = db.scalars(
        select(InventoryTxn)
        .where(
            InventoryTxn.source_doc_type == source_doc_type,
            InventoryTxn.source_doc_id == source_doc_id,
            InventoryTxn.biz_time == main_txn.biz_time,
            InventoryTxn.txn_type.in_(tuple(PRODUCTION_INBOUND_TXN_LABELS.keys())),
        )
        .order_by(
            case(
                (InventoryTxn.txn_type == "FG_IN", 1),
                (InventoryTxn.txn_type == "PRODUCTION_SURPLUS_IN", 2),
                (InventoryTxn.txn_type == "PRODUCTION_SCRAP_IN", 3),
                else_=9,
            ),
            InventoryTxn.id.asc(),
        )
    ).all()
    return rows or [main_txn]


def _production_ledger_from_inbound_txn(db: Session, txn: InventoryTxn) -> ProductionBatchLedger | None:
    if str(txn.source_doc_type or "") == "生产台账" and txn.source_doc_id:
        return db.get(ProductionBatchLedger, int(txn.source_doc_id))
    linked = db.scalar(
        select(ProductionBatchLedger)
        .join(StockLot, StockLot.id == ProductionBatchLedger.material_lot_id)
        .where(
            or_(
                StockLot.id == txn.lot_id,
                StockLot.id == db.scalar(select(StockLot.source_material_lot_id).where(StockLot.id == txn.lot_id)),
            )
        )
        .order_by(ProductionBatchLedger.updated_at.desc(), ProductionBatchLedger.id.desc())
        .limit(1)
    )
    return linked


def collect_production_inbound_settlement_archive_context(db: Session, inventory_txn_id: int) -> ArchiveContext:
    main_txn = db.get(InventoryTxn, inventory_txn_id)
    if main_txn is None:
        raise HTTPException(status_code=404, detail="生产入库库存流水不存在")
    if main_txn.txn_type not in PRODUCTION_INBOUND_TXN_LABELS:
        raise HTTPException(status_code=400, detail="该库存流水不是生产入库结算流水")

    txns = _production_inbound_related_txns(db, main_txn)
    ledger = _production_ledger_from_inbound_txn(db, main_txn)
    if ledger:
        ledger_row = _production_ledger_row(db, ledger.id)
        ledger, material_lot, material, product, _warehouse = ledger_row
        partner_name = product.item_name
        remark_prefix = f"生产台账号:{ledger.id};材料库存批次号:{ledger.material_lot_no};产品编码:{product.item_code}"
        status_text = archive_status_text(ledger.status)
    else:
        material_lot = db.get(StockLot, main_txn.lot_id) if main_txn.lot_id else None
        item = db.get(Item, main_txn.item_id)
        material = item
        product = item
        partner_name = item.item_name if item else "生产入库"
        remark_prefix = f"来源:{main_txn.source_doc_type} #{main_txn.source_doc_id}"
        status_text = "已入库"

    lines: list[ArchiveLine] = []
    total_amount = Decimal("0")
    for index, txn in enumerate(txns, start=1):
        item = db.get(Item, txn.item_id)
        lot = db.get(StockLot, txn.lot_id) if txn.lot_id else None
        warehouse = db.get(Warehouse, txn.warehouse_id)
        txn_label = PRODUCTION_INBOUND_TXN_LABELS.get(txn.txn_type, archive_status_text(txn.txn_type))
        qty_or_weight = txn.qty_change if txn.txn_type == "FG_IN" else txn.weight_change_kg
        amount = Decimal(str(txn.amount or 0))
        total_amount += amount
        remark_parts = [
            f"入库类型:{txn_label}",
            f"仓库:{warehouse.warehouse_name if warehouse else '-'}",
            f"库存批次号:{lot.lot_no if lot else '-'}",
        ]
        if lot and lot.source_material_lot_id:
            source_lot = db.get(StockLot, lot.source_material_lot_id)
            if source_lot:
                remark_parts.append(f"原材料库存批次号:{source_lot.lot_no}")
        elif ledger:
            remark_parts.append(f"原材料库存批次号:{ledger.material_lot_no}")
        if txn.remark:
            remark_parts.append(str(txn.remark))
        lines.append(
            ArchiveLine(
                line_no=index,
                item_code=item.item_code if item else "-",
                item_name=item.item_name if item else "-",
                specification=item.specification if item else None,
                quantity=qty_or_weight,
                delivered_or_received_quantity=txn.weight_change_kg,
                unit_price=txn.unit_cost,
                line_amount=amount,
                promised_or_expected_date=txn.biz_time,
                remark="".join(remark_parts),
            )
        )

    return ArchiveContext(
        document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
        business_id=main_txn.id,
        document_no=f"生产入库结算-{main_txn.txn_no}",
        title="生产入库结算单归档",
        partner_label="生产产品",
        partner_name=partner_name,
        document_date=main_txn.biz_time,
        due_date_label="主库存流水",
        due_date=main_txn.txn_no,
        status=status_text,
        tax_rate=Decimal("0"),
        total_amount=total_amount,
        contact_name=None,
        contact_phone=None,
        address=material_lot.lot_no if material_lot else "",
        remark=remark_prefix,
        lines=lines,
    )

Also add case and or_ to the SQLAlchemy import line:

from sqlalchemy import case, func, or_, select
  • Step 5: Register new collectors

Modify collect_archive_context:

if document_type == DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT:
    return collect_production_material_out_archive_context(db, business_id)
if document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT:
    return collect_production_inbound_settlement_archive_context(db, business_id)
  • Step 6: Add document route keys

Modify backend/app/api/routes/document_archives.py imports:

DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,

Add to DOCUMENT_TYPE_KEY_MAP:

"production-material-out": DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
"production-inbound-settlement": DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT: DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT: DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,

Update the validation error:

raise HTTPException(status_code=400, detail="单据类型只能选择销售订单、采购订单、到货入库单、质量校验单、生产领料出库单或生产入库结算单")
  • Step 7: Run archive tests

Run:

PYTHONPATH=backend backend/.venv/bin/pytest backend/tests/test_production_warehouse_document_archive.py backend/tests/test_document_archive_routes.py::DocumentArchiveRouteTest::test_normalize_document_type_key_supports_route_keys -q

Expected: PASS.

  • Step 8: Run existing document archive tests

Run:

PYTHONPATH=backend backend/.venv/bin/pytest backend/tests/test_document_archive_service.py backend/tests/test_quality_inspection_document_archive.py backend/tests/test_document_archive_routes.py -q

Expected: PASS. This confirms sales, purchase, receipt, and quality archive paths still work.

  • Step 9: Commit backend archive service changes

Run:

git add backend/app/services/document_archives.py backend/app/api/routes/document_archives.py backend/tests/test_production_warehouse_document_archive.py backend/tests/test_document_archive_routes.py
git commit -m "feat: add production warehouse document archive types"

Expected: commit succeeds in a git worktree. If no .git exists, record the skipped commit.


Task 3: Generate Archives From Production Save Points

Files:

  • Modify: backend/app/schemas/operations.py

  • Modify: backend/app/api/routes/production.py

  • Modify: backend/app/services/production_work_order_inbound.py

  • Modify: backend/app/api/routes/inventory.py

  • Test: backend/tests/test_production_warehouse_document_archive.py

  • Step 1: Add archive fields to response schemas

In backend/app/schemas/operations.py, add these fields to StockLotRead, ProductionWorkOrderInboundRead, and ProductionBatchLedgerInboundRead:

archive_status: str | None = None
archive_business_id: int | None = None
archive_document_type: str | None = None
archive_error_message: str | None = None

Add these fields to ProductionBatchLedgerTxnRead:

archive_status: str | None = None
archive_business_id: int | None = None
archive_document_type: str | None = None
archive_error_message: str | None = None

Add these fields to ProductionBatchLedgerRead:

material_out_archive_status: str | None = None
material_out_archive_business_id: int | None = None
material_out_archive_document_type: str | None = None
material_out_archive_error_message: str | None = None
  • Step 2: Add a small archive-result helper in production.py

In backend/app/api/routes/production.py, import:

from app.schemas.document_archives import DocumentArchiveGenerateResult
from app.services.document_archives import (
    DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
    generate_document_archive,
)

Add helper near route helpers:

def _archive_fields_from_result(result: DocumentArchiveGenerateResult | None, *, document_type: str, business_id: int | None) -> dict[str, object]:
    if result is None:
        return {
            "archive_status": "未生成",
            "archive_business_id": business_id,
            "archive_document_type": document_type,
            "archive_error_message": None,
        }
    return {
        "archive_status": result.status,
        "archive_business_id": business_id,
        "archive_document_type": document_type,
        "archive_error_message": result.error_message,
    }
  • Step 3: Generate settlement archive after /production/production-ledger-inbounds

In create_production_batch_ledger_inbound, after db.commit() and before returning, determine the main transaction ID:

main_txn_id = finished_txn_id or surplus_txn_id or scrap_txn_id
archive_result = None
if main_txn_id:
    try:
        archive_result = generate_document_archive(
            db,
            DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
            int(main_txn_id),
            created_by=context.user.id,
        )
    except Exception as exc:
        archive_result = DocumentArchiveGenerateResult(
            document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
            business_id=int(main_txn_id),
            document_no=f"库存流水#{main_txn_id}",
            archive_version=0,
            status="归档失败",
            file_name="",
            file_path="",
            error_message=str(exc),
        )

In the returned ProductionBatchLedgerInboundRead, include:

**_archive_fields_from_result(
    archive_result,
    document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
    business_id=int(main_txn_id) if main_txn_id else None,
)

To keep the current constructor style easy to review, replace the direct constructor return for ProductionBatchLedgerInboundRead with this explicit response object:

response = ProductionBatchLedgerInboundRead(
    production_ledger_id=ledger.id,
    material_lot_no=ledger.material_lot_no,
    finished_qty=float(finished_qty),
    surplus_weight_kg=float(surplus_weight),
    scrap_weight_kg=float(scrap_weight),
    finished_lot_id=finished_lot_id,
    surplus_lot_id=surplus_lot_id,
    scrap_lot_id=scrap_lot_id,
    finished_txn_id=finished_txn_id,
    surplus_txn_id=surplus_txn_id,
    scrap_txn_id=scrap_txn_id,
    locked=bool(payload.lock_material_batch),
    status=ledger.status,
    remark=payload.remark,
)
for key, value in _archive_fields_from_result(
    archive_result,
    document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
    business_id=int(main_txn_id) if main_txn_id else None,
).items():
    setattr(response, key, value)
return response
  • Step 4: Generate settlement archive after old /production/work-order-inbounds

In backend/app/services/production_work_order_inbound.py, import:

from app.schemas.document_archives import DocumentArchiveGenerateResult
from app.services.document_archives import DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, generate_document_archive

After db.commit(), before returning, add:

main_txn_id = finished_txn_id or surplus_txn_id or scrap_txn_id
archive_status = "未生成"
archive_error_message = None
if main_txn_id:
    try:
        archive_result = generate_document_archive(
            db,
            DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
            int(main_txn_id),
            created_by=operator_user_id,
        )
        archive_status = archive_result.status
        archive_error_message = archive_result.error_message
    except Exception as exc:
        archive_status = "归档失败"
        archive_error_message = str(exc)

Include these fields in ProductionWorkOrderInboundRead:

archive_status=archive_status,
archive_business_id=int(main_txn_id) if main_txn_id else None,
archive_document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT if main_txn_id else None,
archive_error_message=archive_error_message,
  • Step 5: Generate material issue archive after raw material production outbound

In backend/app/api/routes/inventory.py, import:

from app.schemas.document_archives import DocumentArchiveGenerateResult
from app.services.document_archives import (
    DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
    DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
    generate_document_archive,
)

In create_warehouse_outbound, track production ledger ID for production outbound. After the branch that posts production issue to ledger, store:

production_material_out_archive_ledger_id = None

When biz_type == "PRODUCTION_OUT" creates or updates a ProductionBatchLedger, set:

production_material_out_archive_ledger_id = ledger.id

After the existing db.commit(), before returning StockLotRead, generate:

archive_status = None
archive_business_id = None
archive_document_type = None
archive_error_message = None
if production_material_out_archive_ledger_id:
    archive_business_id = int(production_material_out_archive_ledger_id)
    archive_document_type = DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT
    try:
        archive_result = generate_document_archive(
            db,
            DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
            archive_business_id,
            created_by=context.user.id,
        )
        archive_status = archive_result.status
        archive_error_message = archive_result.error_message
    except Exception as exc:
        archive_status = "归档失败"
        archive_error_message = str(exc)

When returning the StockLotRead, assign:

response = StockLotRead.model_validate(dict(row))
response.archive_status = archive_status
response.archive_business_id = archive_business_id
response.archive_document_type = archive_document_type
response.archive_error_message = archive_error_message
return response
  • Step 6: Generate settlement archive after branch production inbound

In create_warehouse_inbound, track the main inventory transaction ID for these biz_type values:

PRODUCTION_ARCHIVE_INBOUND_BIZ_TYPES = {"PRODUCTION_FINISHED", "PRODUCTION_SURPLUS", "PRODUCTION_SCRAP_IN"}

For the source-lot return branch, after the existing assignment to return_txn from create_inventory_txn, set:

production_inbound_archive_main_txn_id = return_txn.id if biz_type in PRODUCTION_ARCHIVE_INBOUND_BIZ_TYPES else None

For the non-return branches, capture the InventoryTxn created for finished production inbound and production scrap inbound. Use the same priority rule:

production_inbound_archive_main_txn_id = finished_txn.id or surplus_txn.id or scrap_txn.id

After db.commit(), generate DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT with production_inbound_archive_main_txn_id, catch exceptions, and set the same four archive fields on StockLotRead.

  • Step 7: Add integration tests for archive generation not rolling back business data

Append to backend/tests/test_production_warehouse_document_archive.py:

def test_archive_generation_failure_keeps_existing_business_rows(self) -> None:
    with patch("app.services.document_archives.render_pdf_archive", side_effect=RuntimeError("PDF服务异常")):
        result = generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, self.finished_txn.id, created_by=1)

    self.assertEqual(result.status, "归档失败")
    self.assertIsNotNone(self.db.get(InventoryTxn, self.finished_txn.id))
    saved = self.db.scalar(select(DocumentArchive).where(DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT))
    self.assertIsNotNone(saved)
    self.assertEqual(saved.status, "归档失败")
  • Step 8: Run focused backend tests

Run:

PYTHONPATH=backend backend/.venv/bin/pytest backend/tests/test_production_warehouse_document_archive.py backend/tests/test_production_batch_ledger.py backend/tests/test_production_work_order_inbound.py -q

Expected: PASS.

  • Step 9: Run production inventory regression tests

Run:

PYTHONPATH=backend backend/.venv/bin/pytest backend/tests/test_selected_stock_lot_production_issue.py backend/tests/test_production_work_order_ledger.py backend/tests/test_scrap_warehouse_flow.py -q

Expected: PASS.

  • Step 10: Commit production save-point archive generation

Run:

git add backend/app/schemas/operations.py backend/app/api/routes/production.py backend/app/services/production_work_order_inbound.py backend/app/api/routes/inventory.py backend/tests/test_production_warehouse_document_archive.py
git commit -m "feat: archive production warehouse save points"

Expected: commit succeeds in a git worktree. If no .git exists, record the skipped commit.


Task 4: Expose Archive Status In Production Ledger API

Files:

  • Modify: backend/app/api/routes/production.py

  • Test: backend/tests/test_production_warehouse_document_archive.py

  • Step 1: Import archive model and status constants

In backend/app/api/routes/production.py, import:

from app.models.document_archive import DocumentArchive
from app.services.document_archives import ARCHIVE_STATUS_MISSING, FILE_FORMAT_PDF

Keep the production document type imports from Task 3.

  • Step 2: Add helper to get latest archive rows by business IDs

Add near route helpers:

def _latest_archive_map(db: Session, document_type: str, business_ids: list[int]) -> dict[int, DocumentArchive]:
    if not business_ids:
        return {}
    latest_version_subquery = (
        select(
            DocumentArchive.business_id.label("business_id"),
            func.max(DocumentArchive.archive_version).label("archive_version"),
        )
        .where(
            DocumentArchive.document_type == document_type,
            DocumentArchive.file_format == FILE_FORMAT_PDF,
            DocumentArchive.business_id.in_(business_ids),
        )
        .group_by(DocumentArchive.business_id)
        .subquery()
    )
    latest_id_subquery = (
        select(
            DocumentArchive.business_id.label("business_id"),
            func.max(DocumentArchive.id).label("archive_id"),
        )
        .join(
            latest_version_subquery,
            (latest_version_subquery.c.business_id == DocumentArchive.business_id)
            & (latest_version_subquery.c.archive_version == DocumentArchive.archive_version),
        )
        .where(
            DocumentArchive.document_type == document_type,
            DocumentArchive.file_format == FILE_FORMAT_PDF,
        )
        .group_by(DocumentArchive.business_id)
        .subquery()
    )
    rows = db.scalars(
        select(DocumentArchive).join(latest_id_subquery, latest_id_subquery.c.archive_id == DocumentArchive.id)
    ).all()
    return {int(row.business_id): row for row in rows}
  • Step 3: Populate production ledger archive fields

Inside list_production_batch_ledger, after ledger_ids is built:

material_out_archives = _latest_archive_map(db, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, ledger_ids)

After txn_rows is loaded, collect settlement business IDs:

settlement_business_ids = [
    int(txn.source_doc_id)
    for txn in txn_rows
    if txn.source_doc_type == "库存流水" and txn.source_doc_id and txn.txn_type in {"成品入库", "生产余料入库", "生产废料入库"}
]
settlement_archives = _latest_archive_map(db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, settlement_business_ids)

If current ProductionBatchLedgerTxn.txn_type values for inbound rows are exactly 成品入库, 生产余料入库, 生产废料入库, keep the set above. If the code uses post_finished_inbound_to_ledger, post_surplus_inbound_to_ledger, and post_scrap_inbound_to_ledger with these names, do not change them.

  • Step 4: Extend _production_ledger_txn_read

Modify _production_ledger_txn_read(txn) to compute archive fields:

def _production_ledger_txn_read(txn: ProductionBatchLedgerTxn, archive: DocumentArchive | None = None) -> dict:
    archive_business_id = int(txn.source_doc_id) if txn.source_doc_type == "库存流水" and txn.source_doc_id else None
    return {
        "production_ledger_txn_id": txn.id,
        "production_ledger_id": txn.production_ledger_id,
        "txn_type": txn.txn_type,
        "qty_delta": float(to_decimal(txn.qty_delta)),
        "weight_delta_kg": float(to_decimal(txn.weight_delta_kg)),
        "outside_weight_after_kg": float(to_decimal(txn.outside_weight_after_kg)),
        "source_doc_type": txn.source_doc_type,
        "source_doc_id": txn.source_doc_id,
        "source_line_id": txn.source_line_id,
        "biz_time": txn.biz_time,
        "operator_user_id": txn.operator_user_id,
        "remark": txn.remark,
        "archive_status": archive.status if archive else (ARCHIVE_STATUS_MISSING if archive_business_id else None),
        "archive_business_id": archive_business_id,
        "archive_document_type": DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT if archive_business_id else None,
        "archive_error_message": archive.error_message if archive else None,
    }

Update all call sites to pass the archive:

txns=[
    _production_ledger_txn_read(
        txn,
        settlement_archives.get(int(txn.source_doc_id)) if txn.source_doc_type == "库存流水" and txn.source_doc_id else None,
    )
    for txn in txns_by_ledger_id.get(ledger.id, [])
]
  • Step 5: Extend ProductionBatchLedgerRead construction

When appending ProductionBatchLedgerRead, get:

material_archive = material_out_archives.get(int(ledger.id))

Add fields:

material_out_archive_status=material_archive.status if material_archive else ARCHIVE_STATUS_MISSING,
material_out_archive_business_id=ledger.id,
material_out_archive_document_type=DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
material_out_archive_error_message=material_archive.error_message if material_archive else None,
  • Step 6: Add a production ledger API test

Add to backend/tests/test_production_warehouse_document_archive.py a direct helper-level assertion if full FastAPI setup is too heavy:

def test_latest_archive_rows_can_be_joined_by_business_id(self) -> None:
    with TemporaryDirectory() as tmp_dir, patch("app.services.document_archives.default_archive_root", return_value=tmp_dir):
        generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, self.ledger.id, created_by=1)
        generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, self.finished_txn.id, created_by=1)

    material_archive = self.db.scalar(select(DocumentArchive).where(DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT))
    settlement_archive = self.db.scalar(select(DocumentArchive).where(DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT))
    self.assertEqual(material_archive.business_id, self.ledger.id)
    self.assertEqual(settlement_archive.business_id, self.finished_txn.id)

The execution agent should also run the full production API tests after implementation.

  • Step 7: Run production API and archive tests

Run:

PYTHONPATH=backend backend/.venv/bin/pytest backend/tests/test_production_warehouse_document_archive.py backend/tests/test_production_batch_ledger.py -q

Expected: PASS.

  • Step 8: Commit production ledger archive status API

Run:

git add backend/app/api/routes/production.py backend/app/schemas/operations.py backend/tests/test_production_warehouse_document_archive.py
git commit -m "feat: expose production archive status"

Expected: commit succeeds in a git worktree. If no .git exists, record the skipped commit.


Task 5: Frontend Archive Actions For Production Ledger And Warehouse Feedback

Files:

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

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

  • Create: frontend/src/views/ProductionLedgerView.test.js

  • Step 1: Add static frontend test for production ledger archive UI

Create frontend/src/views/ProductionLedgerView.test.js:

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const __dirname = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(join(__dirname, "ProductionLedgerView.vue"), "utf8");

describe("ProductionLedgerView archive actions", () => {
  it("uses DocumentArchiveActions for material out and inbound settlement archives", () => {
    assert.match(source, /DocumentArchiveActions/);
    assert.match(source, /production-material-out/);
    assert.match(source, /production-inbound-settlement/);
  });

  it("has preview, download and regenerate archive handlers", () => {
    assert.match(source, /previewDocumentArchive/);
    assert.match(source, /downloadDocumentArchive/);
    assert.match(source, /regenerateDocumentArchive/);
  });
});
  • Step 2: Run the frontend test and verify it fails

Run:

node --test frontend/src/views/ProductionLedgerView.test.js

Expected: FAIL because the archive component and route keys are not yet used in ProductionLedgerView.vue.

  • Step 3: Import archive component and API helpers

In frontend/src/views/ProductionLedgerView.vue, add:

import DocumentArchiveActions from "../components/documentForms/DocumentArchiveActions.vue";
import { downloadResource, fetchResource, openResource, postResource } from "../services/api";

Replace the current import:

import { fetchResource, postResource } from "../services/api";

with the combined import above.

  • Step 4: Render material issue archive actions in the detail header

Inside the first detail drawer, below the drawer-row-detail-grid, add:

<div v-if="activeLedger" class="production-archive-panel">
  <div>
    <span>生产领料出库单</span>
    <strong>{{ activeLedger.material_lot_no || "-" }}</strong>
  </div>
  <DocumentArchiveActions
    :status="activeLedger.material_out_archive_status"
    @preview="previewDocumentArchive('production-material-out', activeLedger.material_out_archive_business_id || activeLedger.production_ledger_id)"
    @download="downloadDocumentArchive('production-material-out', activeLedger.material_out_archive_business_id || activeLedger.production_ledger_id, `${activeLedger.material_lot_no || '生产领料出库单'}-${activeLedger.product_name || '产品'}归档.pdf`)"
    @regenerate="regenerateDocumentArchive('production-material-out', activeLedger.material_out_archive_business_id || activeLedger.production_ledger_id)"
  />
</div>
  • Step 5: Render inbound settlement archive actions in transaction detail

Inside the DrawerDataTable #detail slot, after the existing drawer-row-detail-grid, add:

<div v-if="row.archive_business_id" class="production-archive-panel production-archive-panel-inline">
  <div>
    <span>生产入库结算单</span>
    <strong>{{ row.txn_type || "-" }}</strong>
  </div>
  <DocumentArchiveActions
    :status="row.archive_status"
    @preview="previewDocumentArchive('production-inbound-settlement', row.archive_business_id)"
    @download="downloadDocumentArchive('production-inbound-settlement', row.archive_business_id, `${activeLedger?.material_lot_no || '生产入库结算单'}-${row.txn_type || row.production_ledger_txn_id}归档.pdf`)"
    @regenerate="regenerateDocumentArchive('production-inbound-settlement', row.archive_business_id)"
  />
</div>
  • Step 6: Add handlers

In ProductionLedgerView.vue script, add:

async function previewDocumentArchive(typeKey, businessId) {
  feedbackMessage.value = "";
  errorMessage.value = "";
  if (!businessId) {
    errorMessage.value = "缺少归档业务ID无法预览";
    return;
  }
  try {
    await openResource(`/document-archives/${typeKey}/${businessId}/latest/preview`);
  } catch (error) {
    errorMessage.value = error.message || "PDF归档预览失败";
  }
}

async function downloadDocumentArchive(typeKey, businessId, fallbackFilename) {
  feedbackMessage.value = "";
  errorMessage.value = "";
  if (!businessId) {
    errorMessage.value = "缺少归档业务ID无法下载";
    return;
  }
  try {
    await downloadResource(`/document-archives/${typeKey}/${businessId}/latest/download`, fallbackFilename);
    feedbackMessage.value = "PDF归档已下载";
  } catch (error) {
    errorMessage.value = error.message || "PDF归档下载失败";
  }
}

async function regenerateDocumentArchive(typeKey, businessId) {
  feedbackMessage.value = "";
  errorMessage.value = "";
  if (!businessId) {
    errorMessage.value = "缺少归档业务ID无法重新生成";
    return;
  }
  try {
    const result = await postResource(`/document-archives/${typeKey}/${businessId}/generate`, {});
    feedbackMessage.value = result.message || "PDF归档已重新生成";
    await loadAll();
  } catch (error) {
    errorMessage.value = error.message || "PDF归档重新生成失败";
  }
}
  • Step 7: Add production archive styles

Add scoped styles to ProductionLedgerView.vue:

.production-archive-panel {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  margin: 14px 0;
  padding: 12px 14px;
  border: 1px solid rgba(59, 130, 246, 0.18);
  border-radius: 16px;
  background: linear-gradient(135deg, rgba(239, 246, 255, 0.96), rgba(255, 255, 255, 0.9));
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
}

.production-archive-panel > div:first-child {
  display: grid;
  gap: 4px;
}

.production-archive-panel span {
  color: var(--muted);
  font-size: 12px;
}

.production-archive-panel strong {
  color: var(--ink);
  font-size: 14px;
}

.production-archive-panel-inline {
  margin-top: 12px;
}
  • Step 8: Update warehouse feedback messages

In InventoryLedgerView.vue, add helper:

function formatArchiveFeedback(result) {
  if (!result?.archive_status) {
    return "";
  }
  if (result.archive_status === "已归档") {
    return "PDF归档已生成";
  }
  if (result.archive_status === "归档失败") {
    return `PDF归档失败${result.archive_error_message ? `${result.archive_error_message}` : ""}`;
  }
  return `PDF归档状态${result.archive_status}`;
}

Update submitProductionWorkOrderInbound success message:

feedbackMessage.value = `生产台账入库 ${result.material_lot_no || ""} 已保存${formatArchiveFeedback(result)}`;

Update submitGenericOperation success messages for production branch operations so the saved result appends formatArchiveFeedback(result). For inbound and outbound requests currently calling postResource without keeping the response, keep the payload unchanged and store the response:

const result = await postResource("/inventory/inbound", payload);
feedbackMessage.value = `${activeOperation.value?.label || "入库"}已保存${formatArchiveFeedback(result)}`;

and:

const result = await postResource("/inventory/outbound", payload);
feedbackMessage.value = `${activeOperation.value?.label || "出库"}已保存${formatArchiveFeedback(result)}`;

Do not change validation or payload fields.

  • Step 9: Run frontend static test

Run:

node --test frontend/src/views/ProductionLedgerView.test.js

Expected: PASS.

  • Step 10: Run frontend build

Run:

cd frontend
npm run build

Expected: PASS. A Vite chunk-size warning is acceptable.

  • Step 11: Commit frontend archive UI

Run:

git add frontend/src/views/ProductionLedgerView.vue frontend/src/views/InventoryLedgerView.vue frontend/src/views/ProductionLedgerView.test.js
git commit -m "feat: show production warehouse archive actions"

Expected: commit succeeds in a git worktree. If no .git exists, record the skipped commit.


Task 6: Full Verification And Manual Browser Check

Files:

  • No code changes expected.

  • Step 1: Run full focused backend verification

Run:

PYTHONPATH=backend backend/.venv/bin/pytest \
  backend/tests/test_document_archive_service.py \
  backend/tests/test_quality_inspection_document_archive.py \
  backend/tests/test_document_archive_routes.py \
  backend/tests/test_production_warehouse_document_archive.py \
  backend/tests/test_production_batch_ledger.py \
  backend/tests/test_production_work_order_inbound.py \
  backend/tests/test_selected_stock_lot_production_issue.py \
  backend/tests/test_production_work_order_ledger.py \
  backend/tests/test_scrap_warehouse_flow.py \
  -q

Expected: PASS.

  • Step 2: Run frontend checks

Run:

node --test frontend/src/views/ProductionLedgerView.test.js
cd frontend
npm run build

Expected: static test PASS and build PASS. Vite chunk-size warning is acceptable.

  • Step 3: Browser verify production ledger archive actions

Use the in-app Browser and navigate to:

http://127.0.0.1:5173/production-ledger

Expected:

  • Production ledger list loads.

  • Clicking a material lot opens the detail drawer.

  • Detail drawer shows 生产领料出库单 archive actions.

  • Transaction detail rows that have inventory source IDs show 生产入库结算单 archive actions.

  • If old rows show 未生成, clicking 重新生成 creates the PDF or shows a Chinese error.

  • Step 4: Browser verify warehouse feedback

Use the in-app Browser and navigate to:

http://127.0.0.1:5173/inventory-ledger

Expected:

  • Production ledger inbound save success message includes PDF archive result when backend returns it.

  • Production branch inbound/outbound save success message includes PDF archive result when backend returns it.

  • Existing validations and payload behavior remain unchanged.

  • Step 5: Inspect generated PDF manually

Preview one generated 生产领料出库单 and one generated 生产入库结算单.

Expected:

  • PDF title is Chinese.

  • Status values are Chinese.

  • Long material lot numbers and remarks wrap instead of truncating.

  • No ellipsis is used in PDF cell content.

  • PDF includes material lot number, product, quantities/weights, unit cost, amount, and remarks.

  • Step 6: Final status report

Report:

  • Backend tests run and pass/fail count.
  • Frontend tests/build run and pass/fail count.
  • Browser verification result.
  • Any Vite warnings.
  • Whether commits were created or skipped because this workspace has no .git.

Self-Review Checklist

  • Spec coverage:
    • Production material issue is covered by 生产领料出库单.
    • Production ledger inbound and branch inbound are covered by 生产入库结算单.
    • PDF failure does not roll back business save; archive failure is returned in response fields.
    • History rows show 未生成 and can regenerate through existing document archive routes.
    • Batch download remains out of scope.
  • Placeholder scan:
    • No implementation step uses placeholder tokens or unresolved file names.
    • Commands and expected results are explicit.
  • Type consistency:
    • Route keys are production-material-out and production-inbound-settlement.
    • Document types are 生产领料出库单 and 生产入库结算单.
    • Archive response fields are consistently named archive_status, archive_business_id, archive_document_type, and archive_error_message.