# 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. ```python 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: ```bash 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: ```python 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: ```bash 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: ```bash 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: ```python from app.models.operations import ( InventoryTxn, ProductionBatchLedger, ProductionBatchLedgerTxn, PurchaseOrder, PurchaseOrderItem, PurchaseReceipt, PurchaseReceiptItem, StockLot, Supplier, ) ``` Add constants below `DOCUMENT_TYPE_QUALITY_INSPECTION`: ```python 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`. ```python 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: ```python from sqlalchemy.orm import Session, aliased ``` - [ ] **Step 3: Add `collect_production_material_out_archive_context`** Add: ```python 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: ```python 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: ```python from sqlalchemy import case, func, or_, select ``` - [ ] **Step 5: Register new collectors** Modify `collect_archive_context`: ```python 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: ```python DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, ``` Add to `DOCUMENT_TYPE_KEY_MAP`: ```python "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: ```python raise HTTPException(status_code=400, detail="单据类型只能选择销售订单、采购订单、到货入库单、质量校验单、生产领料出库单或生产入库结算单") ``` - [ ] **Step 7: Run archive tests** Run: ```bash 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: ```bash 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: ```bash 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`: ```python 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`: ```python 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`: ```python 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: ```python 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: ```python 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: ```python 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: ```python **_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: ```python 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: ```python 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: ```python 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`: ```python 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: ```python 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: ```python production_material_out_archive_ledger_id = None ``` When `biz_type == "PRODUCTION_OUT"` creates or updates a `ProductionBatchLedger`, set: ```python production_material_out_archive_ledger_id = ledger.id ``` After the existing `db.commit()`, before returning `StockLotRead`, generate: ```python 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: ```python 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: ```python 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: ```python 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: ```python 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`: ```python 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: ```bash 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: ```bash 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: ```bash 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: ```python 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: ```python 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: ```python material_out_archives = _latest_archive_map(db, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, ledger_ids) ``` After `txn_rows` is loaded, collect settlement business IDs: ```python 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: ```python 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: ```python 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: ```python material_archive = material_out_archives.get(int(ledger.id)) ``` Add fields: ```python 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: ```python 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: ```bash 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: ```bash 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`: ```javascript 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: ```bash 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: ```javascript import DocumentArchiveActions from "../components/documentForms/DocumentArchiveActions.vue"; import { downloadResource, fetchResource, openResource, postResource } from "../services/api"; ``` Replace the current import: ```javascript 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: ```vue