# 全仓库出入库单据纸质化、PDF归档与流水导出 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:** 让百华仓库 6 大库所有出入库填单都采用正式纸质单据风格,保存后生成 PDF 留档,并支持在各库流水中预览、下载、重新生成 PDF,以及按当前筛选时间范围导出流水 Excel 清单。 **Architecture:** 采用“双轨归档”:生产主链路继续使用 `生产领料出库单`、`生产入库结算单`,其它仓库出入库统一使用新增 `仓库出入库单`。后端以库存流水 `wh_inventory_txn` 为核心收集归档上下文和导出 Excel;前端抽取通用纸质单据底座,在仓库流水抽屉复用现有 `DocumentArchiveActions`。 **Tech Stack:** FastAPI, SQLAlchemy, Pydantic, ReportLab, openpyxl, Vue 3, Vite, Node test runner. --- ## 设计文件 - Spec: `docs/superpowers/specs/2026-06-11-all-warehouse-document-archive-design.md` - Related spec: `docs/superpowers/specs/2026-06-11-production-warehouse-document-archive-design.md` - Related plan: `docs/superpowers/plans/2026-06-11-production-warehouse-document-archive.md` - Related plan: `docs/superpowers/plans/2026-06-11-production-inbound-archive-entrypoints.md` ## File Structure ### Backend - Modify: `backend/app/services/document_archives.py` - Add `DOCUMENT_TYPE_WAREHOUSE_OPERATION = "仓库出入库单"`. - Add generic warehouse archive context collector. - Route generic warehouse archive dispatch through `collect_archive_context`. - Keep production-specific archive logic unchanged. - Modify: `backend/app/api/routes/document_archives.py` - Add route key `warehouse-operation`. - Include `仓库出入库单` in normalized key validation. - Modify: `backend/app/services/operations.py` - Extend `get_inventory_txn_ledger_query()` to return archive fields. - Resolve archive type/business ID per row: - production material out -> `生产领料出库单`, `source_doc_id` - production inbound settlement -> `生产入库结算单`, `inventory_txn_id` - other warehouse operations -> `仓库出入库单`, `inventory_txn_id` - Modify: `backend/app/schemas/operations.py` - Add archive fields to `InventoryTxnLedgerRowRead`. - Modify: `backend/app/api/routes/inventory.py` - Add `/inventory/transaction-ledger/export`. - Reuse `get_inventory_txn_ledger_query()` filters. - Generate Chinese-header `.xlsx` with full values. - Ensure export is based on current filters, especially start/end date. - Create: `backend/tests/test_warehouse_operation_document_archive.py` - Test generic warehouse archive context and PDF generation. - Modify: `backend/tests/test_document_archive_routes.py` - Test `warehouse-operation` route key. - Create: `backend/tests/test_inventory_ledger_export.py` - Test ledger archive fields and Excel export filters. ### Frontend - Create: `frontend/src/components/documentForms/WarehouseDocumentFormShell.vue` - Shared paper-form shell for warehouse in/out forms. - Create: `frontend/src/components/documentForms/WarehouseDocumentSection.vue` - Reusable section block for paper-form grouped fields. - Create: `frontend/src/components/documentForms/WarehouseDocumentActionBar.vue` - Fixed bottom action area: save, save status, archive shortcuts. - Modify: `frontend/src/views/InventoryLedgerView.vue` - Import and use paper-form shell. - Keep `原材料库 · 客料入库` as first migrated sample and move it onto reusable shell. - Add `DocumentArchiveActions` to warehouse ledger table. - Add `导出Excel` button beside `查询`. - Add export loading state to prevent repeated clicks. - Add preview/download/regenerate handlers for `warehouse-operation`, `production-material-out`, and `production-inbound-settlement`. - Modify: `frontend/src/styles/main.css` - Add paper-form shared styles only if component scoped styles are insufficient. - Add warehouse ledger archive/export button styles. - Modify: `frontend/src/views/InventoryLedgerView.test.js` - Add static tests for warehouse archive actions, Excel export button, and route keys. --- ## Task 1: Add Warehouse Operation Archive Type **Files:** - Modify: `backend/app/services/document_archives.py` - Modify: `backend/app/api/routes/document_archives.py` - Modify: `backend/tests/test_document_archive_routes.py` - [ ] **Step 1: Write failing route-key test** Add this assertion to `test_normalize_document_type_key_supports_route_keys` in `backend/tests/test_document_archive_routes.py`: ```python self.assertEqual(normalize_document_type_key("warehouse-operation"), "仓库出入库单") ``` Also add a Chinese direct type assertion: ```python self.assertEqual(normalize_document_type_key("仓库出入库单"), "仓库出入库单") ``` - [ ] **Step 2: Run route test and verify failure** Run: ```bash cd backend python -m pytest tests/test_document_archive_routes.py::DocumentArchiveRouteTest::test_normalize_document_type_key_supports_route_keys -q ``` Expected: FAIL because `warehouse-operation` is not in `DOCUMENT_TYPE_KEY_MAP`. - [ ] **Step 3: Add archive type constant** In `backend/app/services/document_archives.py`, add the constant next to existing `DOCUMENT_TYPE_*` constants: ```python DOCUMENT_TYPE_WAREHOUSE_OPERATION = "仓库出入库单" ``` - [ ] **Step 4: Add route map entry** In `backend/app/api/routes/document_archives.py`, import the new constant: ```python DOCUMENT_TYPE_WAREHOUSE_OPERATION, ``` Add these entries to `DOCUMENT_TYPE_KEY_MAP`: ```python "warehouse-operation": DOCUMENT_TYPE_WAREHOUSE_OPERATION, DOCUMENT_TYPE_WAREHOUSE_OPERATION: DOCUMENT_TYPE_WAREHOUSE_OPERATION, ``` Update the validation message in `normalize_document_type_key()` so it includes `仓库出入库单`: ```python raise HTTPException( status_code=400, detail="单据类型只能选择销售订单、采购订单、到货入库单、质量校验单、生产领料出库单、生产入库结算单或仓库出入库单", ) ``` - [ ] **Step 5: Run route test and verify pass** Run: ```bash cd backend python -m pytest tests/test_document_archive_routes.py -q ``` Expected: PASS. --- ## Task 2: Build Generic Warehouse Archive Context **Files:** - Create: `backend/tests/test_warehouse_operation_document_archive.py` - Modify: `backend/app/services/document_archives.py` - [ ] **Step 1: Write failing service test** Create `backend/tests/test_warehouse_operation_document_archive.py` with this structure: ```python from __future__ import annotations import unittest from decimal import Decimal from tempfile import TemporaryDirectory from unittest.mock import patch from sqlalchemy import BigInteger, create_engine from sqlalchemy.ext.compiler import compiles from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool import app.models.document_archive # noqa: F401 import app.models.master_data # noqa: F401 import app.models.miniapp # noqa: F401 import app.models.operations # noqa: F401 import app.models.org # noqa: F401 import app.models.planning # noqa: F401 import app.models.sales # noqa: F401 from app.models.base import Base from app.models.master_data import Item, Warehouse, WarehouseLocation from app.models.operations import InventoryTxn, StockLot from app.services.document_archives import ( ARCHIVE_STATUS_READY, DOCUMENT_TYPE_WAREHOUSE_OPERATION, collect_warehouse_operation_archive_context, generate_document_archive, ) @compiles(BigInteger, "sqlite") def _compile_big_integer_for_sqlite(type_, compiler, **kw) -> str: return "INTEGER" class WarehouseOperationDocumentArchiveTest(unittest.TestCase): def setUp(self) -> None: engine = create_engine( "sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, future=True, ) Base.metadata.create_all(engine) self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) self.db = self.SessionLocal() def tearDown(self) -> None: self.db.close() def _seed_customer_supplied_txn(self) -> InventoryTxn: warehouse = Warehouse(warehouse_code="RAW", warehouse_name="原材料库", warehouse_type="RAW") location = WarehouseLocation(location_code="RAW-A", location_name="原材料暂存位", warehouse=warehouse) item = Item(item_code="RM00001", item_name="冷轧钢板", item_type="RAW", unit="kg", specification="1.2mm") lot = StockLot( lot_no="YL0001", item=item, warehouse=warehouse, location=location, qty_on_hand=Decimal("0"), weight_on_hand_kg=Decimal("100"), qty_available=Decimal("0"), weight_available_kg=Decimal("100"), unit_cost=Decimal("0"), status="AVAILABLE", ) txn = InventoryTxn( txn_no="TXN-CUSTOMER-001", txn_type="CUSTOMER_SUPPLIED", item=item, warehouse=warehouse, location=location, lot=lot, qty_change=Decimal("0"), weight_change_kg=Decimal("100"), unit_cost=Decimal("0"), amount=Decimal("0"), source_doc_type="客料入库", source_doc_id=1, logistics_waybill_no="SF123", logistics_freight_amount=Decimal("12.5"), logistics_photo_url="/uploads/demo.jpg", remark="客户来料测试", ) self.db.add_all([warehouse, location, item, lot, txn]) self.db.commit() self.db.refresh(txn) return txn def test_collects_customer_supplied_warehouse_context(self) -> None: txn = self._seed_customer_supplied_txn() context = collect_warehouse_operation_archive_context(self.db, txn.id) self.assertEqual(context.document_type, DOCUMENT_TYPE_WAREHOUSE_OPERATION) self.assertEqual(context.business_id, txn.id) self.assertIn("客料入库", context.title) self.assertEqual(context.partner_label, "仓库") self.assertEqual(context.partner_name, "原材料库") self.assertEqual(len(context.lines), 1) self.assertEqual(context.lines[0].item_name, "冷轧钢板") self.assertIn("库存批次号:YL0001", context.lines[0].remark) self.assertIn("运单号:SF123", context.remark) def test_generates_pdf_archive_for_warehouse_operation(self) -> None: txn = self._seed_customer_supplied_txn() 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_WAREHOUSE_OPERATION, txn.id) self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY) self.assertEqual(result.document_type, DOCUMENT_TYPE_WAREHOUSE_OPERATION) self.assertIn("仓库出入库单", result.document_type) if __name__ == "__main__": unittest.main() ``` - [ ] **Step 2: Run service test and verify failure** Run: ```bash cd backend python -m pytest tests/test_warehouse_operation_document_archive.py -q ``` Expected: FAIL because `collect_warehouse_operation_archive_context` does not exist. - [ ] **Step 3: Implement context collector** In `backend/app/services/document_archives.py`, add this helper near production collectors: ```python WAREHOUSE_OPERATION_TITLE_LABELS = { "OPENING": "期初入库单", "CUSTOMER_SUPPLIED": "客料入库单", "PRODUCTION_SURPLUS": "生产余料入库单", "PRODUCTION_SURPLUS_IN": "生产余料入库单", "OUTSOURCING_SURPLUS": "委外余料入库单", "WIP_IN": "产中入库单", "WIP_OUT": "产中出库单", "OUTSOURCING_IN": "委外入库单", "OUTSOURCING_OUT": "委外出库单", "PURCHASE_RETURN_OUT": "退货出库单", "SCRAP_OUT": "报废出库单", "REWORK_FINISHED_IN": "返工入库单", "SALES_OUT": "销售出库单", "PRODUCTION_OUT": "生产出库单", "PRODUCTION_SCRAP_IN": "生产废料入库单", "REWORK_SCRAP_IN": "返工废料入库单", "OUTSOURCING_SCRAP_IN": "委外废料入库单", "RETURN_SCRAP_IN": "退货废料入库单", "SCRAP_SALE_OUT": "售卖出库单", "RETURN_IN": "退货入库单", "RETURN_OUT": "返工出库单", "SPECIAL_IN": "特殊入库单", "SPECIAL_OUT": "特殊出库单", } def warehouse_operation_title(txn_type: str | None) -> str: normalized = str(txn_type or "").strip().upper() return WAREHOUSE_OPERATION_TITLE_LABELS.get(normalized, f"{archive_status_text(txn_type)}单") ``` Then add: ```python def collect_warehouse_operation_archive_context(db: Session, inventory_txn_id: int) -> ArchiveContext: source_material_lot = aliased(StockLot) row = db.execute( select(InventoryTxn, Item, Warehouse, WarehouseLocation, StockLot, source_material_lot) .join(Item, Item.id == InventoryTxn.item_id) .join(Warehouse, Warehouse.id == InventoryTxn.warehouse_id) .outerjoin(WarehouseLocation, WarehouseLocation.id == InventoryTxn.location_id) .outerjoin(StockLot, StockLot.id == InventoryTxn.lot_id) .outerjoin(source_material_lot, source_material_lot.id == StockLot.source_material_lot_id) .where(InventoryTxn.id == inventory_txn_id) ).one_or_none() if row is None: raise HTTPException(status_code=404, detail="库存流水不存在") txn, item, warehouse, location, lot, source_lot = row title = warehouse_operation_title(txn.txn_type) lot_no = lot.lot_no if lot else "" source_lot_no = source_lot.lot_no if source_lot else (lot.source_material_sub_batch_no if lot else "") weight_value = Decimal(str(txn.weight_change_kg or 0)) qty_value = Decimal(str(txn.qty_change or 0)) total_amount = Decimal(str(txn.amount or 0)) line_remark_parts = [ f"库存流水号:{txn.txn_no}", f"库存批次号:{lot_no or '-'}", ] if source_lot_no: line_remark_parts.append(f"来源库存批次号:{source_lot_no}") if location: line_remark_parts.append(f"库位:{location.location_name}") if txn.remark: line_remark_parts.append(str(txn.remark)) remark_parts = [ f"业务类型:{title.replace('单', '')}", f"来源单据:{archive_status_text(txn.source_doc_type)} #{txn.source_doc_id}", ] if txn.logistics_waybill_no: remark_parts.append(f"运单号:{txn.logistics_waybill_no}") if txn.logistics_freight_amount is not None: remark_parts.append(f"运费:{decimal_text(txn.logistics_freight_amount, 2)}") if txn.logistics_photo_url: remark_parts.append("辅助照片:已上传") if txn.remark: remark_parts.append(str(txn.remark)) return ArchiveContext( document_type=DOCUMENT_TYPE_WAREHOUSE_OPERATION, business_id=txn.id, document_no=f"{title.replace('单', '')}-{txn.txn_no}", title=f"{title}归档", partner_label="仓库", partner_name=warehouse.warehouse_name, document_date=txn.biz_time, due_date_label="库存流水", due_date=txn.txn_no, status=title.replace("单", ""), tax_rate=0, total_amount=total_amount, address=location.location_name if location else warehouse.warehouse_name, remark=";".join(remark_parts), lines=[ ArchiveLine( line_no=1, item_code=item.item_code, item_name=item.item_name, specification=item.specification, quantity=qty_value, delivered_or_received_quantity=weight_value, unit_price=txn.unit_cost, line_amount=txn.amount, promised_or_expected_date=txn.biz_time, remark=";".join(line_remark_parts), ) ], ) ``` - [ ] **Step 4: Wire collector into dispatch** In `collect_archive_context()` add: ```python if document_type == DOCUMENT_TYPE_WAREHOUSE_OPERATION: return collect_warehouse_operation_archive_context(db, business_id) ``` - [ ] **Step 5: Run warehouse archive tests** Run: ```bash cd backend python -m pytest tests/test_warehouse_operation_document_archive.py tests/test_document_archive_service.py -q ``` Expected: PASS. --- ## Task 3: Return Archive Fields in Warehouse Ledger **Files:** - Modify: `backend/app/services/operations.py` - Modify: `backend/app/schemas/operations.py` - Create: `backend/tests/test_inventory_ledger_export.py` - [ ] **Step 1: Write failing ledger archive-field test** In `backend/tests/test_inventory_ledger_export.py`, create a SQLite test that seeds a warehouse transaction and a latest `DocumentArchive`, then asserts `get_inventory_txn_ledger_query()` returns archive fields: ```python def test_inventory_ledger_returns_warehouse_archive_fields(self) -> None: txn = self._seed_raw_inventory_txn(txn_type="CUSTOMER_SUPPLIED") self.db.add( DocumentArchive( document_type="仓库出入库单", business_id=txn.id, document_no="客料入库-TXN-001", archive_version=1, template_version="单据纸面V1", file_format="PDF", file_name="客料入库.pdf", file_path="/tmp/客料入库.pdf", file_hash="abc", status="已归档", ) ) self.db.commit() row = self.db.execute(get_inventory_txn_ledger_query(warehouse_type="RAW")).mappings().one() self.assertEqual(row["archive_status"], "已归档") self.assertEqual(row["archive_business_id"], txn.id) self.assertEqual(row["archive_document_type"], "仓库出入库单") self.assertIsNone(row["archive_error_message"]) ``` Also add one production material out assertion: ```python def test_inventory_ledger_points_production_out_to_material_out_archive(self) -> None: txn = self._seed_raw_inventory_txn(txn_type="PRODUCTION_OUT", source_doc_type="生产台账", source_doc_id=88) row = self.db.execute(get_inventory_txn_ledger_query(warehouse_type="RAW")).mappings().one() self.assertEqual(row["archive_document_type"], "生产领料出库单") self.assertEqual(row["archive_business_id"], 88) ``` - [ ] **Step 2: Run test and verify failure** Run: ```bash cd backend python -m pytest tests/test_inventory_ledger_export.py::InventoryLedgerExportTest::test_inventory_ledger_returns_warehouse_archive_fields -q ``` Expected: FAIL because ledger rows do not expose archive fields. - [ ] **Step 3: Add schema fields** In `backend/app/schemas/operations.py`, extend `InventoryTxnLedgerRowRead`: ```python archive_status: str = "未生成" archive_business_id: int | None = None archive_document_type: str | None = None archive_error_message: str | None = None ``` - [ ] **Step 4: Extend ledger query with archive resolution** In `backend/app/services/operations.py`, import `DocumentArchive` and the document type constants: ```python from app.models.document_archive import DocumentArchive from app.services.document_archives import ( ARCHIVE_STATUS_MISSING, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, DOCUMENT_TYPE_WAREHOUSE_OPERATION, ) ``` Inside `get_inventory_txn_ledger_query()`, build these expressions before the select: ```python archive_document_type_expr = case( ( (InventoryTxn.source_doc_type == "生产台账") & (InventoryTxn.txn_type == "PRODUCTION_OUT"), DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, ), ( InventoryTxn.txn_type.in_(("FG_IN", "PRODUCTION_FINISHED_IN", "PRODUCTION_SURPLUS_IN", "PRODUCTION_SCRAP_IN")), DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, ), else_=DOCUMENT_TYPE_WAREHOUSE_OPERATION, ).label("archive_document_type") archive_business_id_expr = case( ( (InventoryTxn.source_doc_type == "生产台账") & (InventoryTxn.txn_type == "PRODUCTION_OUT"), InventoryTxn.source_doc_id, ), else_=InventoryTxn.id, ).label("archive_business_id") latest_archive = ( select( DocumentArchive.document_type.label("document_type"), DocumentArchive.business_id.label("business_id"), DocumentArchive.status.label("status"), DocumentArchive.error_message.label("error_message"), func.row_number() .over( partition_by=(DocumentArchive.document_type, DocumentArchive.business_id), order_by=(DocumentArchive.archive_version.desc(), DocumentArchive.id.desc()), ) .label("row_no"), ) .where(DocumentArchive.file_format == "PDF") .subquery() ) ``` Join the latest archive subquery after existing joins: ```python .outerjoin( latest_archive, (latest_archive.c.document_type == archive_document_type_expr) & (latest_archive.c.business_id == archive_business_id_expr) & (latest_archive.c.row_no == 1), ) ``` Add fields to the select: ```python archive_document_type_expr, archive_business_id_expr, func.coalesce(latest_archive.c.status, ARCHIVE_STATUS_MISSING).label("archive_status"), latest_archive.c.error_message.label("archive_error_message"), ``` - [ ] **Step 5: Run ledger archive-field tests** Run: ```bash cd backend python -m pytest tests/test_inventory_ledger_export.py -q ``` Expected: ledger archive-field tests PASS. --- ## Task 4: Add Warehouse Ledger Excel Export Endpoint **Files:** - Modify: `backend/app/api/routes/inventory.py` - Create: `backend/tests/test_inventory_ledger_export.py` - [ ] **Step 1: Write failing export endpoint test** Add this test to `backend/tests/test_inventory_ledger_export.py` using a FastAPI `TestClient` with the inventory router: ```python def test_transaction_ledger_export_uses_date_filter_and_chinese_headers(self) -> None: self._seed_raw_inventory_txn(txn_type="CUSTOMER_SUPPLIED", txn_no="TXN-IN-RANGE", biz_date="2026-06-10") self._seed_raw_inventory_txn(txn_type="CUSTOMER_SUPPLIED", txn_no="TXN-OUT-RANGE", biz_date="2026-05-01") response = self.client.get( "/inventory/transaction-ledger/export", params={ "warehouse_type": "RAW", "start_date": "2026-06-01", "end_date": "2026-06-30", "direction": "IN", }, ) self.assertEqual(response.status_code, 200) self.assertIn("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", response.headers["content-type"]) workbook = load_workbook(BytesIO(response.content)) sheet = workbook.active headers = [cell.value for cell in sheet[1]] self.assertIn("业务时间", headers) self.assertIn("PDF归档状态", headers) rows = [[cell.value for cell in row] for row in sheet.iter_rows(min_row=2)] flattened = "\n".join(str(value) for row in rows for value in row) self.assertIn("TXN-IN-RANGE", flattened) self.assertNotIn("TXN-OUT-RANGE", flattened) ``` - [ ] **Step 2: Run export test and verify failure** Run: ```bash cd backend python -m pytest tests/test_inventory_ledger_export.py::InventoryLedgerExportTest::test_transaction_ledger_export_uses_date_filter_and_chinese_headers -q ``` Expected: FAIL because `/inventory/transaction-ledger/export` does not exist. - [ ] **Step 3: Add export helpers** In `backend/app/api/routes/inventory.py`, add helpers near `_excel_response()`: ```python WAREHOUSE_LEDGER_EXPORT_HEADERS = [ ("biz_time", "业务时间"), ("warehouse_name", "仓库"), ("location_name", "库位"), ("direction_label", "方向"), ("txn_type_label", "流水类型"), ("txn_no", "流水号"), ("item_code", "物料/产品编码"), ("item_name", "物料/产品名称"), ("lot_no", "库存批次号"), ("source_material_lot_no", "来源库存批次号"), ("qty_change", "数量变化"), ("weight_change_kg", "重量变化(kg)"), ("unit_cost", "单价"), ("amount", "金额"), ("source_doc_label", "来源单据"), ("operator_name", "经办人"), ("logistics_waybill_no", "运单号"), ("logistics_freight_amount", "运费"), ("logistics_photo_label", "辅助照片"), ("remark", "备注"), ("archive_status", "PDF归档状态"), ("archive_business_id", "PDF归档业务ID"), ] def _ledger_direction_label(value: str | None) -> str: labels = {"IN": "入库", "OUT": "出库", "ADJUST": "调整", "ALL": "全部"} return labels.get(str(value or "").upper(), str(value or "")) def _ledger_export_value(row: dict[str, object], key: str) -> object: if key == "direction_label": return _ledger_direction_label(row.get("direction")) if key == "txn_type_label": return _inventory_txn_type_label(str(row.get("txn_type") or "")) if key == "source_doc_label": source_type = _source_doc_type_label(str(row.get("source_doc_type") or "")) source_id = row.get("source_doc_id") return f"{source_type} #{source_id}" if source_id else source_type if key == "logistics_photo_label": return "已上传" if row.get("logistics_photo_url") else "" return row.get(key) or "" ``` If `_inventory_txn_type_label` and `_source_doc_type_label` do not exist in the route module, add explicit local dictionaries with Chinese labels instead of returning English codes. - [ ] **Step 4: Add export endpoint** Add this route after `list_inventory_transaction_ledger()`: ```python @router.get("/transaction-ledger/export") def export_inventory_transaction_ledger( warehouse_type: str = Query(...), warehouse_id: int | None = None, keyword: str | None = None, direction: str = "ALL", txn_type: str | None = None, item_id: int | None = None, lot_no: str | None = None, start_date: date | None = None, end_date: date | None = None, sort_key: str = "biz_time", sort_direction: str = "desc", db: Session = Depends(get_db), ) -> Response: normalized_warehouse_type = str(warehouse_type or "").upper() if normalized_warehouse_type not in WAREHOUSE_LEDGER_TYPES: raise HTTPException(status_code=400, detail="未知仓库类型,无法导出出入库流水") normalized_direction = str(direction or "ALL").upper() if normalized_direction not in {"ALL", "IN", "OUT", "ADJUST"}: raise HTTPException(status_code=400, detail="流水方向只能是 ALL、IN、OUT、ADJUST") stmt = get_inventory_txn_ledger_query( warehouse_type=normalized_warehouse_type, warehouse_id=warehouse_id, keyword=keyword, direction=normalized_direction, txn_type=txn_type, item_id=item_id, lot_no=lot_no, start_date=start_date, end_date=end_date, sort_key=sort_key, sort_direction=sort_direction, ) rows = [dict(row) for row in db.execute(stmt).mappings().all()] workbook = Workbook() sheet = workbook.active sheet.title = "出入库流水" sheet.append([label for _, label in WAREHOUSE_LEDGER_EXPORT_HEADERS]) for row in rows: sheet.append([_ledger_export_value(row, key) for key, _ in WAREHOUSE_LEDGER_EXPORT_HEADERS]) sheet.freeze_panes = "A2" sheet.auto_filter.ref = sheet.dimensions for column_cells in sheet.columns: header = str(column_cells[0].value or "") max_length = max(len(str(cell.value or "")) for cell in column_cells) sheet.column_dimensions[column_cells[0].column_letter].width = min(max(max_length + 2, len(header) + 2), 42) date_range = f"{start_date or '全部'}_{end_date or '全部'}" warehouse_label = WAREHOUSE_TYPE_LABELS.get(normalized_warehouse_type, normalized_warehouse_type) return _excel_response(workbook, f"{warehouse_label}出入库流水_{date_range}.xlsx") ``` - [ ] **Step 5: Run export tests** Run: ```bash cd backend python -m pytest tests/test_inventory_ledger_export.py -q ``` Expected: PASS. --- ## Task 5: Generate Generic Archive After Non-Production Warehouse Saves **Files:** - Modify: `backend/app/api/routes/inventory.py` - Modify: `backend/app/api/routes/sales.py` - Modify: `backend/app/api/routes/returns.py` - Modify: `backend/app/services/special_inventory_adjustment.py` - Modify: `backend/app/services/stocktake.py` - Test: `backend/tests/test_warehouse_operation_document_archive.py` - [ ] **Step 1: Add helper for safe archive generation** In `backend/app/api/routes/inventory.py`, add: ```python def _generate_warehouse_operation_archive_safely(db: Session, inventory_txn_id: int, created_by: int | None = None) -> DocumentArchiveGenerateResult: try: return generate_document_archive( db, DOCUMENT_TYPE_WAREHOUSE_OPERATION, inventory_txn_id, created_by=created_by, ) except HTTPException: raise except Exception as exc: return DocumentArchiveGenerateResult( business_id=inventory_txn_id, document_type=DOCUMENT_TYPE_WAREHOUSE_OPERATION, document_no=f"仓库出入库单-{inventory_txn_id}", archive_status="归档失败", archive_version=None, archive_error_message=str(exc), ) ``` If the same helper is needed outside `inventory.py`, move it to `backend/app/services/document_archives.py` as `generate_warehouse_operation_archive_safely()` and import it from each route/service. Prefer the shared service form if more than two files need it. - [ ] **Step 2: Return archive result for `客料入库` first** Find the `CUSTOMER_SUPPLIED` save path in `backend/app/api/routes/inventory.py`. After the transaction is committed and refreshed, call: ```python archive_result = _generate_warehouse_operation_archive_safely(db, txn.id, created_by=_extract_user_id(context)) ``` Return the existing payload plus: ```python "archive_business_id": archive_result.business_id, "archive_document_type": archive_result.document_type, "archive_status": archive_result.archive_status, "archive_error_message": archive_result.archive_error_message, ``` - [ ] **Step 3: Add coverage for remaining non-production inventory routes** Apply the same safe archive call to plain warehouse operations that create one inventory transaction: ```text 期初入库 委外余料入库 委外出库 报废出库 半成品产中入库 半成品产中出库 半成品委外入库 半成品委外出库 成品委外入库 成品销售出库 辅料生产出库 废料售卖出库 退货入库 退货返工出库 退货废料入库 返工废料入库 特殊入库 特殊出库 ``` Do not wrap production material out or production ledger inbound into `仓库出入库单`; those keep production-specific archive types. - [ ] **Step 4: Run targeted backend tests** Run: ```bash cd backend python -m pytest tests/test_warehouse_operation_document_archive.py tests/test_production_warehouse_document_archive.py tests/test_special_warehouse_adjustment.py -q ``` Expected: PASS. --- ## Task 6: Add PDF Archive Actions to Warehouse Ledger **Files:** - Modify: `frontend/src/views/InventoryLedgerView.vue` - Modify: `frontend/src/views/InventoryLedgerView.test.js` - [ ] **Step 1: Write failing frontend static test** In `frontend/src/views/InventoryLedgerView.test.js`, add: ```js it("shows document archive actions in warehouse ledger rows", () => { assert.match(source, /DocumentArchiveActions/); assert.match(source, /warehouse-operation/); assert.match(source, /previewWarehouseLedgerArchive/); assert.match(source, /downloadWarehouseLedgerArchive/); assert.match(source, /regenerateWarehouseLedgerArchive/); }); ``` - [ ] **Step 2: Run frontend test and verify failure** Run: ```bash cd frontend npm test -- InventoryLedgerView.test.js ``` Expected: FAIL until archive actions are added. - [ ] **Step 3: Import archive component** In `frontend/src/views/InventoryLedgerView.vue`, add: ```js import DocumentArchiveActions from "../components/documentForms/DocumentArchiveActions.vue"; ``` - [ ] **Step 4: Add ledger table column** In the warehouse ledger table header, add: ```vue 单据留档 ``` In each row, add: ```vue ``` Update empty-state colspan so it includes the new archive column. - [ ] **Step 5: Add archive handlers** Add these functions in the script section: ```js function warehouseLedgerArchiveTypeKey(row) { const documentType = String(row?.archive_document_type || ""); if (documentType === "生产领料出库单") return "production-material-out"; if (documentType === "生产入库结算单") return "production-inbound-settlement"; return "warehouse-operation"; } function warehouseLedgerArchiveBusinessId(row) { return row?.archive_business_id || row?.inventory_txn_id; } async function previewWarehouseLedgerArchive(row) { const businessId = warehouseLedgerArchiveBusinessId(row); if (!businessId) { errorMessage.value = "该流水缺少归档业务ID,无法预览PDF"; return; } await openResource(`/document-archives/${warehouseLedgerArchiveTypeKey(row)}/${businessId}/latest/preview`); } async function downloadWarehouseLedgerArchive(row) { const businessId = warehouseLedgerArchiveBusinessId(row); if (!businessId) { errorMessage.value = "该流水缺少归档业务ID,无法下载PDF"; return; } const filename = `${row?.txn_no || row?.archive_document_type || "仓库出入库单"}归档.pdf`; await downloadResource(`/document-archives/${warehouseLedgerArchiveTypeKey(row)}/${businessId}/latest/download`, filename); } async function regenerateWarehouseLedgerArchive(row) { const businessId = warehouseLedgerArchiveBusinessId(row); if (!businessId) { errorMessage.value = "该流水缺少归档业务ID,无法重新生成PDF"; return; } await postResource(`/document-archives/${warehouseLedgerArchiveTypeKey(row)}/${businessId}/generate`, {}); await loadWarehouseLedger(); } ``` - [ ] **Step 6: Run frontend test** Run: ```bash cd frontend npm test -- InventoryLedgerView.test.js ``` Expected: PASS. --- ## Task 7: Add Warehouse Ledger Excel Export Button **Files:** - Modify: `frontend/src/views/InventoryLedgerView.vue` - Modify: `frontend/src/views/InventoryLedgerView.test.js` - [ ] **Step 1: Write failing frontend export test** In `frontend/src/views/InventoryLedgerView.test.js`, add: ```js it("supports exporting warehouse ledger excel with current filters", () => { assert.match(source, /导出Excel/); assert.match(source, /exportWarehouseLedgerExcel/); assert.match(source, /\\/inventory\\/transaction-ledger\\/export/); assert.match(source, /warehouseLedgerExporting/); }); ``` - [ ] **Step 2: Run frontend test and verify failure** Run: ```bash cd frontend npm test -- InventoryLedgerView.test.js ``` Expected: FAIL until export UI is added. - [ ] **Step 3: Add export loading state** Near `warehouseLedgerLoading`, add: ```js const warehouseLedgerExporting = ref(false); ``` - [ ] **Step 4: Extract shared ledger params builder** Replace duplicate URLSearchParams construction with: ```js function buildWarehouseLedgerParams({ includePagination = true } = {}) { const params = new URLSearchParams({ warehouse_type: activeWarehouseType.value, direction: warehouseLedgerFilters.direction || "ALL", sort_key: warehouseLedgerFilters.sort_key || "biz_time", sort_direction: warehouseLedgerFilters.sort_direction || "desc" }); if (includePagination) { params.set("page", String(warehouseLedgerPage.value)); params.set("page_size", String(warehouseLedgerPageSize.value)); } if (warehouseLedgerFilters.keyword) params.set("keyword", warehouseLedgerFilters.keyword); if (warehouseLedgerFilters.txn_type) params.set("txn_type", warehouseLedgerFilters.txn_type); if (warehouseLedgerFilters.start_date) params.set("start_date", warehouseLedgerFilters.start_date); if (warehouseLedgerFilters.end_date) params.set("end_date", warehouseLedgerFilters.end_date); return params; } ``` Update `loadWarehouseLedger()` to call: ```js const params = buildWarehouseLedgerParams(); ``` - [ ] **Step 5: Add export function** Add: ```js function warehouseLedgerExportFilename() { const start = warehouseLedgerFilters.start_date || "全部"; const end = warehouseLedgerFilters.end_date || "全部"; return `${activeInventoryLabel.value}出入库流水_${start}_${end}.xlsx`; } async function exportWarehouseLedgerExcel() { if (!warehouseLedgerFilters.start_date || !warehouseLedgerFilters.end_date) { feedbackMessage.value = "当前未选择完整时间范围,将导出全部匹配流水"; } warehouseLedgerExporting.value = true; try { const params = buildWarehouseLedgerParams({ includePagination: false }); await downloadResource(`/inventory/transaction-ledger/export?${params.toString()}`, warehouseLedgerExportFilename()); } catch (error) { errorMessage.value = error.message || "流水Excel导出失败"; } finally { warehouseLedgerExporting.value = false; } } ``` - [ ] **Step 6: Add export button beside query** In the warehouse ledger filter actions, add: ```vue ``` - [ ] **Step 7: Run frontend export test** Run: ```bash cd frontend npm test -- InventoryLedgerView.test.js ``` Expected: PASS. --- ## Task 8: Extract Warehouse Paper Form Shell **Files:** - Create: `frontend/src/components/documentForms/WarehouseDocumentFormShell.vue` - Create: `frontend/src/components/documentForms/WarehouseDocumentSection.vue` - Create: `frontend/src/components/documentForms/WarehouseDocumentActionBar.vue` - Modify: `frontend/src/views/InventoryLedgerView.vue` - Modify: `frontend/src/styles/main.css` - [ ] **Step 1: Create shell component** Create `frontend/src/components/documentForms/WarehouseDocumentFormShell.vue`: ```vue ``` - [ ] **Step 2: Create section component** Create `frontend/src/components/documentForms/WarehouseDocumentSection.vue`: ```vue ``` - [ ] **Step 3: Create fixed action bar component** Create `frontend/src/components/documentForms/WarehouseDocumentActionBar.vue`: ```vue ``` - [ ] **Step 4: Add shared styles** Add styles in `frontend/src/styles/main.css`: ```css .warehouse-document-form { position: relative; background: linear-gradient(90deg, rgba(31, 41, 55, 0.045) 1px, transparent 1px), linear-gradient(180deg, rgba(31, 41, 55, 0.04) 1px, transparent 1px), #fffaf0; background-size: 28px 28px; border: 1px solid rgba(120, 83, 38, 0.26); border-radius: 22px; box-shadow: 0 22px 60px rgba(69, 50, 26, 0.16); padding: 28px; } .warehouse-document-head { display: flex; justify-content: space-between; gap: 24px; border-bottom: 2px solid rgba(80, 56, 26, 0.36); padding-bottom: 18px; } .warehouse-document-head h2 { margin: 4px 0 0; font-size: 28px; letter-spacing: 0.16em; color: #3d2b16; } .warehouse-document-kicker { font-size: 12px; letter-spacing: 0.22em; color: #9a6a28; } .warehouse-document-head dl { display: grid; grid-template-columns: repeat(2, minmax(120px, 1fr)); gap: 10px 18px; margin: 0; } .warehouse-document-head dt, .warehouse-document-head dd { margin: 0; } .warehouse-document-head dt { font-size: 12px; color: #8a6a3f; } .warehouse-document-head dd { font-weight: 700; color: #3d2b16; } .warehouse-document-body { display: grid; gap: 18px; padding: 22px 0; } .warehouse-document-section { border: 1px solid rgba(120, 83, 38, 0.22); border-radius: 16px; background: rgba(255, 255, 255, 0.72); padding: 18px; } .warehouse-document-section > header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; color: #4b3215; } .warehouse-document-section > header span { display: inline-grid; place-items: center; width: 26px; height: 26px; border-radius: 999px; background: #c8892f; color: #fff; font-size: 12px; } .warehouse-document-section-grid { display: grid; grid-template-columns: repeat(3, minmax(180px, 1fr)); gap: 14px; } .warehouse-document-signature { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; border-top: 1px dashed rgba(80, 56, 26, 0.38); padding-top: 18px; color: #7a5a30; } .warehouse-document-action-bar { position: sticky; bottom: 0; z-index: 8; display: flex; justify-content: space-between; gap: 16px; align-items: center; margin-top: 18px; padding: 14px 0 0; background: linear-gradient(180deg, rgba(255, 250, 240, 0), #fffaf0 32%); } @media (max-width: 900px) { .warehouse-document-head, .warehouse-document-action-bar { flex-direction: column; align-items: stretch; } .warehouse-document-section-grid { grid-template-columns: 1fr; } } ``` - [ ] **Step 5: Migrate `原材料库 · 客料入库` to shell** In `frontend/src/views/InventoryLedgerView.vue`, replace the current paper-style customer supplied block with: ```vue ``` Do not delete any current `客料入库` business field. Move fields into sections. - [ ] **Step 6: Run frontend build** Run: ```bash cd frontend npm run build ``` Expected: PASS. --- ## Task 9: Add Save-Success Archive Shortcuts **Files:** - Modify: `frontend/src/views/InventoryLedgerView.vue` - [ ] **Step 1: Add last archive result state** Add: ```js const lastWarehouseArchiveResult = ref(null); ``` - [ ] **Step 2: Capture backend archive fields after save** Where a warehouse operation save response is handled, set: ```js lastWarehouseArchiveResult.value = result?.archive_business_id ? { businessId: result.archive_business_id, documentType: result.archive_document_type || "仓库出入库单", status: result.archive_status || "未生成", errorMessage: result.archive_error_message || "" } : null; ``` - [ ] **Step 3: Show shortcut actions after successful save** In the operation drawer success area, render: ```vue ``` - [ ] **Step 4: Verify save feedback does not block inventory save** Run the relevant backend and frontend tests: ```bash cd backend python -m pytest tests/test_warehouse_operation_document_archive.py -q cd ../frontend npm test -- InventoryLedgerView.test.js ``` Expected: PASS. --- ## Task 10: Migrate Remaining Warehouse Forms to Paper Shell **Files:** - Modify: `frontend/src/views/InventoryLedgerView.vue` - [ ] **Step 1: Create operation-to-document title map** Add: ```js const warehouseOperationDocumentTitles = { OPENING: "期初入库单", CUSTOMER_SUPPLIED: "客料入库单", PRODUCTION_SURPLUS: "生产余料入库单", OUTSOURCING_SURPLUS: "委外余料入库单", PRODUCTION_OUT: "生产出库单", OUTSOURCING_OUT: "委外出库单", PURCHASE_RETURN_OUT: "退货出库单", SCRAP_OUT: "报废出库单", WIP_IN: "产中入库单", WIP_OUT: "产中出库单", OUTSOURCING_IN: "委外入库单", PRODUCTION_FINISHED: "生产入库单", REWORK_FINISHED_IN: "返工入库单", SALES_OUT: "销售出库单", PRODUCTION_SCRAP_IN: "生产废料入库单", REWORK_SCRAP_IN: "返工废料入库单", OUTSOURCING_SCRAP_IN: "委外废料入库单", RETURN_SCRAP_IN: "退货废料入库单", SCRAP_SALE_OUT: "售卖出库单", RETURN_IN: "退货入库单", RETURN_OUT: "返工出库单", SPECIAL_IN: "特殊入库单", SPECIAL_OUT: "特殊出库单" }; ``` - [ ] **Step 2: Wrap generic operation drawer content** In the generic operation drawer, wrap current form fields in: ```vue ``` - [ ] **Step 3: Keep production-specific forms on paper shell but production archive keys** For these entries, keep the same paper shell visual, but do not call `warehouse-operation` in save-success PDF actions: ```text 原材料库 · 生产出库 -> production-material-out 成品库 · 生产入库 -> production-inbound-settlement 原材料库 · 生产余料入库 -> production-inbound-settlement when tied to production inbound settlement 废料库 · 生产废料入库 -> production-inbound-settlement when tied to production inbound settlement 生产台账入库 -> production-inbound-settlement ``` - [ ] **Step 4: Browser verify all operation drawers open** Use the in-app browser at `http://127.0.0.1:5173/inventory-ledger`. Verify one operation per warehouse: ```text 原材料库:客料入库、生产出库、特殊出库 半成品库:产中入库、委外出库 成品库:生产入库、销售出库 辅料库:生产出库 废料库:生产废料入库、售卖出库 退货库:退货入库、返工出库 ``` Expected: each opens as a paper-style form; no old large explanation card dominates the form. --- ## Task 11: Add Archive Actions to Inventory Detail Drawer Ledger **Files:** - Modify: `frontend/src/views/InventoryLedgerView.vue` - Modify: `backend/app/services/operations.py` - [ ] **Step 1: Find detail drawer transaction source** Search: ```bash rg -n "get_inventory_txn_query|inventory_txn_id|库存流水|detail" frontend/src/views/InventoryLedgerView.vue backend/app/services/operations.py backend/app/api/routes/inventory.py ``` Use the same archive field strategy as `get_inventory_txn_ledger_query()`. - [ ] **Step 2: Add backend archive fields to detail transaction query** If the detail drawer uses `get_inventory_txn_query()`, extend that select with: ```python archive_document_type_expr, archive_business_id_expr, func.coalesce(latest_archive.c.status, ARCHIVE_STATUS_MISSING).label("archive_status"), latest_archive.c.error_message.label("archive_error_message"), ``` - [ ] **Step 3: Add `单据留档` column in detail drawer** Render: ```vue ``` - [ ] **Step 4: Build and smoke test** Run: ```bash cd frontend npm run build ``` Expected: PASS. --- ## Task 12: Verification **Files:** - Verify: backend tests - Verify: frontend tests - Verify: browser workflows - [ ] **Step 1: Run backend document tests** Run: ```bash cd backend python -m pytest \ tests/test_document_archive_routes.py \ tests/test_document_archive_service.py \ tests/test_warehouse_operation_document_archive.py \ tests/test_inventory_ledger_export.py \ tests/test_production_warehouse_document_archive.py \ -q ``` Expected: PASS. - [ ] **Step 2: Run frontend tests** Run: ```bash cd frontend npm test -- InventoryLedgerView.test.js ProductionLedgerView.test.js ``` Expected: PASS. - [ ] **Step 3: Run frontend build** Run: ```bash cd frontend npm run build ``` Expected: PASS. - [ ] **Step 4: Browser verification checklist** In `http://127.0.0.1:5173/inventory-ledger`, verify: ```text 1. 原材料库 · 客料入库保存后显示 PDF 状态和预览/下载/重新生成。 2. 工具 -> 流水中每行有“单据留档”。 3. 已归档流水可预览 PDF。 4. 未归档流水点“重新生成”后刷新状态。 5. 流水筛选开始日期、结束日期后,点“导出Excel”下载 xlsx。 6. 未选时间范围点“导出Excel”时给轻提示,并导出当前筛选全部流水。 7. Excel 表头为中文,备注、批次号、来源单据完整,不出现前端省略号。 8. 生产台账入库形成的多条流水指向同一张“生产入库结算单”。 9. 生产出库流水指向“生产领料出库单”,不是“仓库出入库单”。 ``` --- ## Self-Review ### Spec Coverage - 6 大库所有出入库纸质化:Task 8, Task 10. - 客料入库补 PDF:Task 2, Task 5, Task 9. - 通用 `仓库出入库单`:Task 1, Task 2. - 生产专用 PDF 不重复生成通用仓库单:Task 3, Task 10. - 仓库流水 PDF 预览/下载/重新生成:Task 3, Task 6. - 库存明细流水 PDF 入口:Task 11. - 各库流水按时间筛选导出 Excel:Task 4, Task 7. - PDF/Excel 不省略、不英文状态:Task 2, Task 4, Task 12. ### Placeholder Scan - No `TBD`. - No vague "handle edge cases" step. - Every code-changing task includes the exact file and representative code or command. ### Type Consistency - Archive route key: `warehouse-operation`. - Archive document type: `仓库出入库单`. - Ledger fields: - `archive_status` - `archive_business_id` - `archive_document_type` - `archive_error_message` - Existing production route keys preserved: - `production-material-out` - `production-inbound-settlement`