ForgeFlow-ERP/docs/superpowers/plans/2026-06-11-all-warehouse-document-archive.md
2026-06-14 21:05:49 +08:00

52 KiB
Raw Blame History

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

self.assertEqual(normalize_document_type_key("warehouse-operation"), "仓库出入库单")

Also add a Chinese direct type assertion:

self.assertEqual(normalize_document_type_key("仓库出入库单"), "仓库出入库单")
  • Step 2: Run route test and verify failure

Run:

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:

DOCUMENT_TYPE_WAREHOUSE_OPERATION = "仓库出入库单"
  • Step 4: Add route map entry

In backend/app/api/routes/document_archives.py, import the new constant:

DOCUMENT_TYPE_WAREHOUSE_OPERATION,

Add these entries to DOCUMENT_TYPE_KEY_MAP:

"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 仓库出入库单:

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

Run:

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:

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:

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:

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:

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:

if document_type == DOCUMENT_TYPE_WAREHOUSE_OPERATION:
    return collect_warehouse_operation_archive_context(db, business_id)
  • Step 5: Run warehouse archive tests

Run:

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:

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:

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:

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:

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:

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:

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:

.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:

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:

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:

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:

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():

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():

@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:

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:

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:

archive_result = _generate_warehouse_operation_archive_safely(db, txn.id, created_by=_extract_user_id(context))

Return the existing payload plus:

"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:

期初入库
委外余料入库
委外出库
报废出库
半成品产中入库
半成品产中出库
半成品委外入库
半成品委外出库
成品委外入库
成品销售出库
辅料生产出库
废料售卖出库
退货入库
退货返工出库
退货废料入库
返工废料入库
特殊入库
特殊出库

Do not wrap production material out or production ledger inbound into 仓库出入库单; those keep production-specific archive types.

  • Step 4: Run targeted backend tests

Run:

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:

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:

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:

import DocumentArchiveActions from "../components/documentForms/DocumentArchiveActions.vue";
  • Step 4: Add ledger table column

In the warehouse ledger table header, add:

<th>单据留档</th>

In each row, add:

<td>
  <DocumentArchiveActions
    :status="row.archive_status || '未生成'"
    @preview="previewWarehouseLedgerArchive(row)"
    @download="downloadWarehouseLedgerArchive(row)"
    @regenerate="regenerateWarehouseLedgerArchive(row)"
  />
</td>

Update empty-state colspan so it includes the new archive column.

  • Step 5: Add archive handlers

Add these functions in the script section:

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:

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:

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:

cd frontend
npm test -- InventoryLedgerView.test.js

Expected: FAIL until export UI is added.

  • Step 3: Add export loading state

Near warehouseLedgerLoading, add:

const warehouseLedgerExporting = ref(false);
  • Step 4: Extract shared ledger params builder

Replace duplicate URLSearchParams construction with:

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:

const params = buildWarehouseLedgerParams();
  • Step 5: Add export function

Add:

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:

<button
  class="secondary-button"
  type="button"
  :disabled="warehouseLedgerLoading || warehouseLedgerExporting"
  @click="exportWarehouseLedgerExcel"
>
  {{ warehouseLedgerExporting ? "导出中..." : "导出Excel" }}
</button>
  • Step 7: Run frontend export test

Run:

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:

<template>
  <form class="warehouse-document-form" @submit.prevent="$emit('submit')">
    <header class="warehouse-document-head">
      <div>
        <span class="warehouse-document-kicker">百华仓库</span>
        <h2>{{ title }}</h2>
      </div>
      <dl>
        <div>
          <dt>单据编号</dt>
          <dd>{{ documentNo || "保存后自动生成" }}</dd>
        </div>
        <div>
          <dt>业务日期</dt>
          <dd>{{ businessDate || "-" }}</dd>
        </div>
      </dl>
    </header>

    <main class="warehouse-document-body">
      <slot />
    </main>

    <footer class="warehouse-document-signature">
      <span>制单</span>
      <span>仓库确认</span>
      <span>业务确认</span>
      <span>复核</span>
    </footer>
  </form>
</template>

<script setup>
defineProps({
  title: { type: String, required: true },
  documentNo: { type: String, default: "" },
  businessDate: { type: String, default: "" }
});

defineEmits(["submit"]);
</script>
  • Step 2: Create section component

Create frontend/src/components/documentForms/WarehouseDocumentSection.vue:

<template>
  <section class="warehouse-document-section">
    <header>
      <span>{{ index }}</span>
      <strong>{{ title }}</strong>
    </header>
    <div class="warehouse-document-section-grid">
      <slot />
    </div>
  </section>
</template>

<script setup>
defineProps({
  index: { type: String, required: true },
  title: { type: String, required: true }
});
</script>
  • Step 3: Create fixed action bar component

Create frontend/src/components/documentForms/WarehouseDocumentActionBar.vue:

<template>
  <div class="warehouse-document-action-bar">
    <div class="warehouse-document-action-state">
      <slot name="state" />
    </div>
    <div class="warehouse-document-action-buttons">
      <slot />
    </div>
  </div>
</template>
  • Step 4: Add shared styles

Add styles in frontend/src/styles/main.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:

<WarehouseDocumentFormShell
  v-if="activeOperation?.bizType === 'CUSTOMER_SUPPLIED'"
  title="客料入库单"
  :business-date="formatDateTime(new Date())"
  @submit="submitOperation"
>
  <WarehouseDocumentSection index="01" title="基础信息">
    <!-- keep existing 客料入库 fields here without deleting business fields -->
  </WarehouseDocumentSection>
  <WarehouseDocumentSection index="02" title="入库明细">
    <!-- keep existing material, lot, weight, unit-cost fields here -->
  </WarehouseDocumentSection>
  <WarehouseDocumentSection index="03" title="辅助信息">
    <!-- keep existing logistics, photo, remark fields here -->
  </WarehouseDocumentSection>
  <WarehouseDocumentActionBar>
    <template #state>
      <span v-if="lastArchiveStatus">{{ lastArchiveStatus }}</span>
    </template>
    <button class="primary-button" type="submit" :disabled="operationSubmitting">
      {{ operationSubmitting ? "保存中..." : "保存并生成PDF归档" }}
    </button>
  </WarehouseDocumentActionBar>
</WarehouseDocumentFormShell>

Do not delete any current 客料入库 business field. Move fields into sections.

  • Step 6: Run frontend build

Run:

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:

const lastWarehouseArchiveResult = ref(null);
  • Step 2: Capture backend archive fields after save

Where a warehouse operation save response is handled, set:

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:

<DocumentArchiveActions
  v-if="lastWarehouseArchiveResult"
  :status="lastWarehouseArchiveResult.status"
  @preview="previewWarehouseLedgerArchive({ archive_document_type: lastWarehouseArchiveResult.documentType, archive_business_id: lastWarehouseArchiveResult.businessId })"
  @download="downloadWarehouseLedgerArchive({ archive_document_type: lastWarehouseArchiveResult.documentType, archive_business_id: lastWarehouseArchiveResult.businessId, txn_no: activeOperation?.label })"
  @regenerate="regenerateWarehouseLedgerArchive({ archive_document_type: lastWarehouseArchiveResult.documentType, archive_business_id: lastWarehouseArchiveResult.businessId })"
/>
  • Step 4: Verify save feedback does not block inventory save

Run the relevant backend and frontend tests:

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:

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:

<WarehouseDocumentFormShell
  :title="warehouseOperationDocumentTitles[activeOperation?.bizType] || `${activeOperation?.label || '仓库'}单`"
  :business-date="formatDateTime(new Date())"
  @submit="submitOperation"
>
  <WarehouseDocumentSection index="01" title="基础信息">
    <!-- existing warehouse, operation, provider/customer/person fields -->
  </WarehouseDocumentSection>
  <WarehouseDocumentSection index="02" title="明细信息">
    <!-- existing item/product/lot/qty/weight/cost fields -->
  </WarehouseDocumentSection>
  <WarehouseDocumentSection index="03" title="辅助信息">
    <!-- existing logistics/photo/remark/reason fields -->
  </WarehouseDocumentSection>
  <WarehouseDocumentActionBar>
    <button class="primary-button" type="submit" :disabled="operationSubmitting">
      {{ operationSubmitting ? "保存中..." : "保存并生成PDF归档" }}
    </button>
  </WarehouseDocumentActionBar>
</WarehouseDocumentFormShell>
  • 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:

原材料库 · 生产出库 -> 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:

原材料库:客料入库、生产出库、特殊出库
半成品库:产中入库、委外出库
成品库:生产入库、销售出库
辅料库:生产出库
废料库:生产废料入库、售卖出库
退货库:退货入库、返工出库

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:

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:

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:

<DocumentArchiveActions
  :status="txn.archive_status || '未生成'"
  @preview="previewWarehouseLedgerArchive(txn)"
  @download="downloadWarehouseLedgerArchive(txn)"
  @regenerate="regenerateWarehouseLedgerArchive(txn)"
/>
  • Step 4: Build and smoke test

Run:

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:

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:

cd frontend
npm test -- InventoryLedgerView.test.js ProductionLedgerView.test.js

Expected: PASS.

  • Step 3: Run frontend build

Run:

cd frontend
npm run build

Expected: PASS.

  • Step 4: Browser verification checklist

In http://127.0.0.1:5173/inventory-ledger, verify:

1. 原材料库 · 客料入库保存后显示 PDF 状态和预览/下载/重新生成。
2. 工具 -> 流水中每行有“单据留档”。
3. 已归档流水可预览 PDF。
4. 未归档流水点“重新生成”后刷新状态。
5. 流水筛选开始日期、结束日期后点“导出Excel”下载 xlsx。
6. 未选时间范围点“导出Excel”时给轻提示并导出当前筛选全部流水。
7. Excel 表头为中文,备注、批次号、来源单据完整,不出现前端省略号。
8. 生产台账入库形成的多条流水指向同一张“生产入库结算单”。
9. 生产出库流水指向“生产领料出库单”,不是“仓库出入库单”。

Self-Review

Spec Coverage

  • 6 大库所有出入库纸质化Task 8, Task 10.
  • 客料入库补 PDFTask 2, Task 5, Task 9.
  • 通用 仓库出入库单Task 1, Task 2.
  • 生产专用 PDF 不重复生成通用仓库单Task 3, Task 10.
  • 仓库流水 PDF 预览/下载/重新生成Task 3, Task 6.
  • 库存明细流水 PDF 入口Task 11.
  • 各库流水按时间筛选导出 ExcelTask 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