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

In each row, add:

```vue
<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:

```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
<button
  class="secondary-button"
  type="button"
  :disabled="warehouseLedgerLoading || warehouseLedgerExporting"
  @click="exportWarehouseLedgerExcel"
>
  {{ warehouseLedgerExporting ? "导出中..." : "导出Excel" }}
</button>
```

- [ ] **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
<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`:

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

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

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

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

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

```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
<DocumentArchiveActions
  :status="txn.archive_status || '未生成'"
  @preview="previewWarehouseLedgerArchive(txn)"
  @download="downloadWarehouseLedgerArchive(txn)"
  @regenerate="regenerateWarehouseLedgerArchive(txn)"
/>
```

- [ ] **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`

