# 全仓库出入库单据纸质化、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