# Production Warehouse Document Archive Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add formal PDF archive support for the production warehouse chain: production material issue, production ledger inbound, finished production inbound, production surplus inbound, and production scrap inbound.

**Architecture:** Reuse the existing `document_archives` table, PDF renderer, and `/document-archives` API. Add two document types, `生产领料出库单` keyed by `ProductionBatchLedger.id` and `生产入库结算单` keyed by the main `InventoryTxn.id`; expose archive status through production ledger rows and production ledger transaction rows.

**Tech Stack:** FastAPI, SQLAlchemy, Pydantic, ReportLab PDF generation, Vue 3, existing document form/archive components.

---

## File Structure

## Scope Mapping

- 原材料库 · 生产出库: implemented by `生产领料出库单`, generated after raw material production outbound updates `ProductionBatchLedger`.
- 生产台账入口 · 生产工单入库: implemented by `生产入库结算单`, generated after `/production/production-ledger-inbounds` saves.
- 成品库 · 生产入库: implemented by `生产入库结算单`, generated from the branch production finished inbound main `InventoryTxn`.
- 原材料库 · 生产余料入库: implemented by `生产入库结算单`, generated from the branch production surplus inbound main `InventoryTxn`.
- 废料库 · 生产废料入库: implemented by `生产入库结算单`, generated from the branch production scrap inbound main `InventoryTxn`.

### Backend

- Modify: `backend/app/services/document_archives.py`
  - Add production warehouse document constants.
  - Add production material issue context collector.
  - Add production inbound settlement context collector.
  - Add helper to group settlement inventory transactions from a main transaction.
  - Add document type dispatch.
- Modify: `backend/app/api/routes/document_archives.py`
  - Add route keys `production-material-out` and `production-inbound-settlement`.
  - Update Chinese validation error text.
- Modify: `backend/app/schemas/operations.py`
  - Add archive fields to `ProductionBatchLedgerRead`.
  - Add archive fields to `ProductionBatchLedgerTxnRead`.
  - Add archive fields to `ProductionBatchLedgerInboundRead`.
  - Add archive fields to `ProductionWorkOrderInboundRead`.
  - Add optional archive fields to `StockLotRead` so warehouse branch entry responses can carry the archive result.
- Modify: `backend/app/api/routes/production.py`
  - Generate `生产入库结算单` after `/production/production-ledger-inbounds` saves.
  - Generate `生产入库结算单` after `/production/work-order-inbounds` saves through the existing service response.
  - Add latest archive joins to `/production/production-ledger`.
- Modify: `backend/app/services/production_work_order_inbound.py`
  - Generate `生产入库结算单` after old work-order inbound saves.
  - Return archive status fields without rolling back saved business data.
- Modify: `backend/app/api/routes/inventory.py`
  - Generate `生产领料出库单` after raw material production outbound creates or updates a production ledger.
  - Generate `生产入库结算单` after branch production inbound creates a main inventory transaction.
  - Return archive status fields in existing response models.
- Test: `backend/tests/test_production_warehouse_document_archive.py`
  - New focused backend tests for production warehouse archive collection and generation.
- Modify: `backend/tests/test_document_archive_routes.py`
  - Add route key normalization assertions for production warehouse document types.

### Frontend

- Modify: `frontend/src/views/ProductionLedgerView.vue`
  - Import and render `DocumentArchiveActions`.
  - Show production material issue archive actions in the production ledger detail header.
  - Show production inbound settlement archive actions for production ledger transaction rows that came from inventory transactions.
  - Add preview/download/regenerate handlers using the two new route keys.
- Modify: `frontend/src/views/InventoryLedgerView.vue`
  - Update production ledger inbound success feedback to include archive status.
  - Update generic production inbound/outbound success feedback to include archive status where the backend returns it.
  - Add archive action helpers only if needed by the visible warehouse flow; do not duplicate `DocumentArchiveActions` if production ledger already covers the detail action.
- Test: `frontend/src/views/ProductionLedgerView.test.js`
  - New static tests for archive actions and route keys.
- Modify: `frontend/src/views/InventoryLedgerView.test.js` if present; otherwise no new inventory static test is required.

---

### Task 1: Backend Tests For Production Archive Types And Contexts

**Files:**
- Create: `backend/tests/test_production_warehouse_document_archive.py`
- Modify: `backend/tests/test_document_archive_routes.py`

- [ ] **Step 1: Create the focused backend test file**

Create `backend/tests/test_production_warehouse_document_archive.py` with this complete structure. The seed intentionally uses only the tables needed by document archive context collection.

```python
from __future__ import annotations

import unittest
from datetime import datetime
from decimal import Decimal
from tempfile import TemporaryDirectory
from unittest.mock import patch

from sqlalchemy import BigInteger, create_engine, select
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session, sessionmaker


@compiles(BigInteger, "sqlite")
def _compile_big_integer_for_sqlite(type_, compiler, **kw) -> str:
    _ = type_, compiler, kw
    return "INTEGER"


import app.models.document_archive  # noqa: E402,F401
import app.models.master_data  # noqa: E402,F401
import app.models.miniapp  # noqa: E402,F401
import app.models.operations  # noqa: E402,F401
import app.models.org  # noqa: E402,F401
import app.models.planning  # noqa: E402,F401
import app.models.sales  # noqa: E402,F401
from app.models.base import Base  # noqa: E402
from app.models.document_archive import DocumentArchive  # noqa: E402
from app.models.master_data import Item, Warehouse  # noqa: E402
from app.models.operations import InventoryTxn, ProductionBatchLedger, ProductionBatchLedgerTxn, StockLot  # noqa: E402
from app.services.document_archives import (  # noqa: E402
    ARCHIVE_STATUS_READY,
    DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
    DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
    collect_production_inbound_settlement_archive_context,
    collect_production_material_out_archive_context,
    generate_document_archive,
)


class ProductionWarehouseDocumentArchiveTest(unittest.TestCase):
    def setUp(self) -> None:
        engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
        Base.metadata.create_all(engine)
        self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
        self.db: Session = self.SessionLocal()
        self.now = datetime(2026, 6, 11, 10, 30, 0)
        self._seed_data()

    def tearDown(self) -> None:
        self.db.close()

    def _seed_data(self) -> None:
        self.raw_warehouse = Warehouse(
            id=1,
            warehouse_code="WH-RAW",
            warehouse_name="原材料库",
            warehouse_type="RAW",
            status="ACTIVE",
            created_at=self.now,
            updated_at=self.now,
        )
        self.finished_warehouse = Warehouse(
            id=2,
            warehouse_code="WH-FIN",
            warehouse_name="成品库",
            warehouse_type="FINISHED",
            status="ACTIVE",
            created_at=self.now,
            updated_at=self.now,
        )
        self.scrap_warehouse = Warehouse(
            id=3,
            warehouse_code="WH-SCRAP",
            warehouse_name="废料库",
            warehouse_type="SCRAP",
            status="ACTIVE",
            created_at=self.now,
            updated_at=self.now,
        )
        self.material = Item(
            id=10,
            item_code="RM-00010",
            item_name="测试冷轧板",
            item_type="RAW_MATERIAL",
            specification="1.2mm",
            material_grade="SPCC",
            unit_weight_kg=Decimal("0"),
            status="ACTIVE",
            created_at=self.now,
            updated_at=self.now,
        )
        self.product = Item(
            id=20,
            item_code="FG-00020",
            item_name="测试钢制碗",
            item_type="FINISHED",
            specification="φ180",
            material_grade=None,
            unit_weight_kg=Decimal("0.5"),
            status="ACTIVE",
            created_at=self.now,
            updated_at=self.now,
        )
        self.material_lot = StockLot(
            id=100,
            lot_no="YL0001",
            parent_lot_id=None,
            lot_role="INBOUND_RAW",
            material_sub_batch_no=None,
            item_id=self.material.id,
            warehouse_id=self.raw_warehouse.id,
            location_id=None,
            source_doc_type="PURCHASE_RECEIPT",
            source_doc_id=900,
            source_line_id=901,
            source_material_lot_id=None,
            source_material_sub_batch_no=None,
            source_material_summary=None,
            inbound_qty=0,
            inbound_weight_kg=500,
            remaining_qty=0,
            remaining_weight_kg=300,
            locked_qty=0,
            locked_weight_kg=0,
            unit_cost=Decimal("6.6"),
            production_date=None,
            expire_date=None,
            quality_status="PASS",
            status="AVAILABLE",
            remark="测试材料批次",
            created_at=self.now,
            updated_at=self.now,
        )
        self.ledger = ProductionBatchLedger(
            id=200,
            material_lot_id=self.material_lot.id,
            material_lot_no=self.material_lot.lot_no,
            material_item_id=self.material.id,
            product_item_id=self.product.id,
            status="在生产",
            miniapp_selectable=1,
            total_issued_weight_kg=Decimal("200"),
            outside_weight_kg=Decimal("80"),
            finished_inbound_qty=Decimal("198"),
            surplus_inbound_weight_kg=Decimal("60"),
            scrap_inbound_weight_kg=Decimal("2"),
            writeoff_weight_kg=Decimal("0"),
            first_issue_time=self.now,
            last_issue_time=self.now,
            locked_at=None,
            reopened_at=None,
            lock_count=0,
            remark="生产台账测试",
            created_at=self.now,
            updated_at=self.now,
        )
        self.issue_ledger_txn = ProductionBatchLedgerTxn(
            id=300,
            production_ledger_id=self.ledger.id,
            txn_type="生产出库",
            qty_delta=0,
            weight_delta_kg=Decimal("200"),
            outside_weight_after_kg=Decimal("200"),
            source_doc_type="库存流水",
            source_doc_id=400,
            source_line_id=self.material_lot.id,
            biz_time=self.now,
            operator_user_id=1,
            remark="原材料库生产出库形成生产台账",
            created_at=self.now,
            updated_at=self.now,
        )
        self.issue_inventory_txn = InventoryTxn(
            id=400,
            txn_no="TXN-ISSUE-001",
            txn_type="PRODUCTION_OUT",
            item_id=self.material.id,
            warehouse_id=self.raw_warehouse.id,
            location_id=None,
            lot_id=self.material_lot.id,
            qty_change=0,
            weight_change_kg=Decimal("-200"),
            unit_cost=Decimal("6.6"),
            amount=Decimal("1320"),
            source_doc_type="生产台账",
            source_doc_id=self.ledger.id,
            source_line_id=self.material_lot.id,
            biz_time=self.now,
            operator_user_id=1,
            remark="生产出库",
            created_at=self.now,
            updated_at=self.now,
        )
        self.finished_lot = StockLot(
            id=101,
            lot_no="FGL20260611001",
            parent_lot_id=self.material_lot.id,
            lot_role="FINISHED_FROM_RAW_BATCH",
            material_sub_batch_no=None,
            item_id=self.product.id,
            warehouse_id=self.finished_warehouse.id,
            location_id=None,
            source_doc_type="PRODUCTION_LEDGER_IN",
            source_doc_id=self.ledger.id,
            source_line_id=None,
            source_material_lot_id=self.material_lot.id,
            source_material_sub_batch_no=self.material_lot.lot_no,
            source_material_summary=f"{self.material.item_name}：{self.material_lot.lot_no}",
            inbound_qty=198,
            inbound_weight_kg=99,
            remaining_qty=198,
            remaining_weight_kg=99,
            locked_qty=0,
            locked_weight_kg=0,
            unit_cost=Decimal("3.3"),
            production_date=self.now.date(),
            expire_date=None,
            quality_status="PASS",
            status="AVAILABLE",
            remark="生产台账入库；成品入库；来源库存批次已记录",
            created_at=self.now,
            updated_at=self.now,
        )
        self.finished_txn = InventoryTxn(
            id=401,
            txn_no="TXN-FG-001",
            txn_type="FG_IN",
            item_id=self.product.id,
            warehouse_id=self.finished_warehouse.id,
            location_id=None,
            lot_id=self.finished_lot.id,
            qty_change=198,
            weight_change_kg=99,
            unit_cost=Decimal("3.3"),
            amount=Decimal("653.4"),
            source_doc_type="生产台账",
            source_doc_id=self.ledger.id,
            source_line_id=self.finished_lot.id,
            biz_time=self.now,
            operator_user_id=1,
            remark="生产台账入库；成品入库；偏差说明：称重复核通过",
            created_at=self.now,
            updated_at=self.now,
        )
        self.surplus_txn = InventoryTxn(
            id=402,
            txn_no="TXN-SURPLUS-001",
            txn_type="PRODUCTION_SURPLUS_IN",
            item_id=self.material.id,
            warehouse_id=self.raw_warehouse.id,
            location_id=None,
            lot_id=self.material_lot.id,
            qty_change=0,
            weight_change_kg=60,
            unit_cost=Decimal("6.6"),
            amount=Decimal("396"),
            source_doc_type="生产台账",
            source_doc_id=self.ledger.id,
            source_line_id=self.material_lot.id,
            biz_time=self.now,
            operator_user_id=1,
            remark="生产台账入库；生产余料入库；偏差说明：称重复核通过",
            created_at=self.now,
            updated_at=self.now,
        )
        self.db.add_all(
            [
                self.raw_warehouse,
                self.finished_warehouse,
                self.scrap_warehouse,
                self.material,
                self.product,
                self.material_lot,
                self.ledger,
                self.issue_ledger_txn,
                self.issue_inventory_txn,
                self.finished_lot,
                self.finished_txn,
                self.surplus_txn,
            ]
        )
        self.db.commit()

    def test_collect_material_out_context_uses_production_ledger(self) -> None:
        context = collect_production_material_out_archive_context(self.db, self.ledger.id)

        self.assertEqual(context.document_type, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT)
        self.assertEqual(context.business_id, self.ledger.id)
        self.assertEqual(context.document_no, "生产领料-YL0001-测试钢制碗")
        self.assertEqual(context.partner_label, "生产产品")
        self.assertEqual(context.partner_name, "测试钢制碗")
        self.assertEqual(context.address, "原材料库")
        self.assertIn("材料库存批次号：YL0001", context.remark)
        self.assertEqual(len(context.lines), 1)
        self.assertEqual(context.lines[0].item_name, "测试冷轧板")
        self.assertEqual(Decimal(str(context.lines[0].quantity)), Decimal("200"))
        self.assertEqual(Decimal(str(context.lines[0].line_amount)), Decimal("1320.00"))

    def test_collect_inbound_settlement_context_groups_same_save_action(self) -> None:
        context = collect_production_inbound_settlement_archive_context(self.db, self.finished_txn.id)

        self.assertEqual(context.document_type, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT)
        self.assertEqual(context.business_id, self.finished_txn.id)
        self.assertEqual(context.document_no, "生产入库结算-TXN-FG-001")
        self.assertEqual(context.partner_label, "生产产品")
        self.assertEqual(context.partner_name, "测试钢制碗")
        self.assertIn("材料库存批次号：YL0001", context.remark)
        self.assertEqual(len(context.lines), 2)
        self.assertEqual([line.remark.split("；")[0] for line in context.lines], ["入库类型：成品入库", "入库类型：生产余料入库"])

    def test_generate_production_material_out_archive_pdf(self) -> None:
        with TemporaryDirectory() as tmp_dir, patch("app.services.document_archives.default_archive_root", return_value=tmp_dir):
            result = generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, self.ledger.id, created_by=1)

        self.assertEqual(result.status, ARCHIVE_STATUS_READY)
        saved = self.db.scalar(select(DocumentArchive).where(DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT))
        self.assertIsNotNone(saved)
        self.assertEqual(saved.business_id, self.ledger.id)
        self.assertEqual(saved.archive_version, 1)

    def test_generate_production_inbound_settlement_archive_pdf(self) -> None:
        with TemporaryDirectory() as tmp_dir, patch("app.services.document_archives.default_archive_root", return_value=tmp_dir):
            result = generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, self.finished_txn.id, created_by=1)

        self.assertEqual(result.status, ARCHIVE_STATUS_READY)
        saved = self.db.scalar(select(DocumentArchive).where(DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT))
        self.assertIsNotNone(saved)
        self.assertEqual(saved.business_id, self.finished_txn.id)
        self.assertEqual(saved.archive_version, 1)


if __name__ == "__main__":
    unittest.main()
```

- [ ] **Step 2: Run the new test file and verify it fails for missing symbols**

Run:

```bash
PYTHONPATH=backend backend/.venv/bin/pytest backend/tests/test_production_warehouse_document_archive.py -q
```

Expected: FAIL with import errors for `DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT`, `DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT`, `collect_production_inbound_settlement_archive_context`, and `collect_production_material_out_archive_context`.

- [ ] **Step 3: Extend document archive route key tests**

Modify `backend/tests/test_document_archive_routes.py::DocumentArchiveRouteTest.test_normalize_document_type_key_supports_route_keys` to include:

```python
self.assertEqual(normalize_document_type_key("production-material-out"), "生产领料出库单")
self.assertEqual(normalize_document_type_key("production-inbound-settlement"), "生产入库结算单")
```

- [ ] **Step 4: Run route tests and verify they fail**

Run:

```bash
PYTHONPATH=backend backend/.venv/bin/pytest backend/tests/test_document_archive_routes.py::DocumentArchiveRouteTest::test_normalize_document_type_key_supports_route_keys -q
```

Expected: FAIL because the new document type keys are not mapped yet.

- [ ] **Step 5: Commit tests**

Run:

```bash
git add backend/tests/test_production_warehouse_document_archive.py backend/tests/test_document_archive_routes.py
git commit -m "test: cover production warehouse document archives"
```

Expected: commit succeeds in a git worktree. If the workspace reports `fatal: not a git repository`, record that commit was skipped because this project copy has no `.git` directory.

---

### Task 2: Add Production Document Archive Contexts And Route Keys

**Files:**
- Modify: `backend/app/services/document_archives.py`
- Modify: `backend/app/api/routes/document_archives.py`
- Test: `backend/tests/test_production_warehouse_document_archive.py`
- Test: `backend/tests/test_document_archive_routes.py`

- [ ] **Step 1: Add imports and constants in `document_archives.py`**

Modify imports:

```python
from app.models.operations import (
    InventoryTxn,
    ProductionBatchLedger,
    ProductionBatchLedgerTxn,
    PurchaseOrder,
    PurchaseOrderItem,
    PurchaseReceipt,
    PurchaseReceiptItem,
    StockLot,
    Supplier,
)
```

Add constants below `DOCUMENT_TYPE_QUALITY_INSPECTION`:

```python
DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT = "生产领料出库单"
DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT = "生产入库结算单"
```

- [ ] **Step 2: Add production helper functions**

Add these helper functions below `collect_quality_inspection_archive_context`.

```python
PRODUCTION_INBOUND_TXN_LABELS = {
    "FG_IN": "成品入库",
    "PRODUCTION_SURPLUS_IN": "生产余料入库",
    "PRODUCTION_SCRAP_IN": "生产废料入库",
}


def _production_document_no_part(value: str | None) -> str:
    text = str(value or "").strip()
    return text or "-"


def _production_amount(quantity_or_weight: Decimal | float | int | None, unit_cost: Decimal | float | int | None) -> Decimal:
    return Decimal(str(quantity_or_weight or 0)) * Decimal(str(unit_cost or 0))


def _production_ledger_row(db: Session, production_ledger_id: int):
    material_item = aliased(Item)
    product_item = aliased(Item)
    row = db.execute(
        select(
            ProductionBatchLedger,
            StockLot,
            material_item,
            product_item,
            Warehouse,
        )
        .join(StockLot, StockLot.id == ProductionBatchLedger.material_lot_id)
        .join(material_item, material_item.id == ProductionBatchLedger.material_item_id)
        .join(product_item, product_item.id == ProductionBatchLedger.product_item_id)
        .join(Warehouse, Warehouse.id == StockLot.warehouse_id)
        .where(ProductionBatchLedger.id == production_ledger_id)
    ).one_or_none()
    if row is None:
        raise HTTPException(status_code=404, detail="生产台账不存在")
    return row
```

Also add `aliased` to the SQLAlchemy import line:

```python
from sqlalchemy.orm import Session, aliased
```

- [ ] **Step 3: Add `collect_production_material_out_archive_context`**

Add:

```python
def collect_production_material_out_archive_context(db: Session, production_ledger_id: int) -> ArchiveContext:
    ledger, material_lot, material, product, warehouse = _production_ledger_row(db, production_ledger_id)
    issue_txns = db.scalars(
        select(ProductionBatchLedgerTxn)
        .where(
            ProductionBatchLedgerTxn.production_ledger_id == production_ledger_id,
            ProductionBatchLedgerTxn.txn_type == "生产出库",
        )
        .order_by(ProductionBatchLedgerTxn.biz_time.asc(), ProductionBatchLedgerTxn.id.asc())
    ).all()
    issued_weight = Decimal(str(ledger.total_issued_weight_kg or 0))
    unit_cost = Decimal(str(material_lot.unit_cost or 0))
    amount = issued_weight * unit_cost
    latest_issue_time = ledger.last_issue_time or ledger.updated_at
    issue_count = len(issue_txns)
    line_remark_parts = [
        f"库存批次号：{ledger.material_lot_no}",
        f"累计生产出库次数：{issue_count}",
        f"库外未闭环重量：{decimal_text(ledger.outside_weight_kg, 3)}kg",
    ]
    if ledger.remark:
        line_remark_parts.append(str(ledger.remark))

    return ArchiveContext(
        document_type=DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
        business_id=ledger.id,
        document_no=f"生产领料-{_production_document_no_part(ledger.material_lot_no)}-{_production_document_no_part(product.item_name)}",
        title="生产领料出库单归档",
        partner_label="生产产品",
        partner_name=product.item_name,
        document_date=latest_issue_time,
        due_date_label="生产台账",
        due_date=ledger.id,
        status=archive_status_text(ledger.status),
        tax_rate=Decimal("0"),
        total_amount=amount,
        contact_name=None,
        contact_phone=None,
        address=warehouse.warehouse_name,
        remark=f"生产台账号：{ledger.id}；材料库存批次号：{ledger.material_lot_no}；产品编码：{product.item_code}",
        lines=[
            ArchiveLine(
                line_no=1,
                item_code=material.item_code,
                item_name=material.item_name,
                specification=material.specification,
                quantity=issued_weight,
                delivered_or_received_quantity=ledger.outside_weight_kg,
                unit_price=unit_cost,
                line_amount=amount,
                promised_or_expected_date=latest_issue_time,
                remark="；".join(line_remark_parts),
            )
        ],
    )
```

- [ ] **Step 4: Add settlement grouping and `collect_production_inbound_settlement_archive_context`**

Add:

```python
def _production_inbound_related_txns(db: Session, main_txn: InventoryTxn) -> list[InventoryTxn]:
    source_doc_type = str(main_txn.source_doc_type or "")
    source_doc_id = int(main_txn.source_doc_id or 0)
    if source_doc_id <= 0:
        return [main_txn]
    rows = db.scalars(
        select(InventoryTxn)
        .where(
            InventoryTxn.source_doc_type == source_doc_type,
            InventoryTxn.source_doc_id == source_doc_id,
            InventoryTxn.biz_time == main_txn.biz_time,
            InventoryTxn.txn_type.in_(tuple(PRODUCTION_INBOUND_TXN_LABELS.keys())),
        )
        .order_by(
            case(
                (InventoryTxn.txn_type == "FG_IN", 1),
                (InventoryTxn.txn_type == "PRODUCTION_SURPLUS_IN", 2),
                (InventoryTxn.txn_type == "PRODUCTION_SCRAP_IN", 3),
                else_=9,
            ),
            InventoryTxn.id.asc(),
        )
    ).all()
    return rows or [main_txn]


def _production_ledger_from_inbound_txn(db: Session, txn: InventoryTxn) -> ProductionBatchLedger | None:
    if str(txn.source_doc_type or "") == "生产台账" and txn.source_doc_id:
        return db.get(ProductionBatchLedger, int(txn.source_doc_id))
    linked = db.scalar(
        select(ProductionBatchLedger)
        .join(StockLot, StockLot.id == ProductionBatchLedger.material_lot_id)
        .where(
            or_(
                StockLot.id == txn.lot_id,
                StockLot.id == db.scalar(select(StockLot.source_material_lot_id).where(StockLot.id == txn.lot_id)),
            )
        )
        .order_by(ProductionBatchLedger.updated_at.desc(), ProductionBatchLedger.id.desc())
        .limit(1)
    )
    return linked


def collect_production_inbound_settlement_archive_context(db: Session, inventory_txn_id: int) -> ArchiveContext:
    main_txn = db.get(InventoryTxn, inventory_txn_id)
    if main_txn is None:
        raise HTTPException(status_code=404, detail="生产入库库存流水不存在")
    if main_txn.txn_type not in PRODUCTION_INBOUND_TXN_LABELS:
        raise HTTPException(status_code=400, detail="该库存流水不是生产入库结算流水")

    txns = _production_inbound_related_txns(db, main_txn)
    ledger = _production_ledger_from_inbound_txn(db, main_txn)
    if ledger:
        ledger_row = _production_ledger_row(db, ledger.id)
        ledger, material_lot, material, product, _warehouse = ledger_row
        partner_name = product.item_name
        remark_prefix = f"生产台账号：{ledger.id}；材料库存批次号：{ledger.material_lot_no}；产品编码：{product.item_code}"
        status_text = archive_status_text(ledger.status)
    else:
        material_lot = db.get(StockLot, main_txn.lot_id) if main_txn.lot_id else None
        item = db.get(Item, main_txn.item_id)
        material = item
        product = item
        partner_name = item.item_name if item else "生产入库"
        remark_prefix = f"来源：{main_txn.source_doc_type} #{main_txn.source_doc_id}"
        status_text = "已入库"

    lines: list[ArchiveLine] = []
    total_amount = Decimal("0")
    for index, txn in enumerate(txns, start=1):
        item = db.get(Item, txn.item_id)
        lot = db.get(StockLot, txn.lot_id) if txn.lot_id else None
        warehouse = db.get(Warehouse, txn.warehouse_id)
        txn_label = PRODUCTION_INBOUND_TXN_LABELS.get(txn.txn_type, archive_status_text(txn.txn_type))
        qty_or_weight = txn.qty_change if txn.txn_type == "FG_IN" else txn.weight_change_kg
        amount = Decimal(str(txn.amount or 0))
        total_amount += amount
        remark_parts = [
            f"入库类型：{txn_label}",
            f"仓库：{warehouse.warehouse_name if warehouse else '-'}",
            f"库存批次号：{lot.lot_no if lot else '-'}",
        ]
        if lot and lot.source_material_lot_id:
            source_lot = db.get(StockLot, lot.source_material_lot_id)
            if source_lot:
                remark_parts.append(f"原材料库存批次号：{source_lot.lot_no}")
        elif ledger:
            remark_parts.append(f"原材料库存批次号：{ledger.material_lot_no}")
        if txn.remark:
            remark_parts.append(str(txn.remark))
        lines.append(
            ArchiveLine(
                line_no=index,
                item_code=item.item_code if item else "-",
                item_name=item.item_name if item else "-",
                specification=item.specification if item else None,
                quantity=qty_or_weight,
                delivered_or_received_quantity=txn.weight_change_kg,
                unit_price=txn.unit_cost,
                line_amount=amount,
                promised_or_expected_date=txn.biz_time,
                remark="；".join(remark_parts),
            )
        )

    return ArchiveContext(
        document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
        business_id=main_txn.id,
        document_no=f"生产入库结算-{main_txn.txn_no}",
        title="生产入库结算单归档",
        partner_label="生产产品",
        partner_name=partner_name,
        document_date=main_txn.biz_time,
        due_date_label="主库存流水",
        due_date=main_txn.txn_no,
        status=status_text,
        tax_rate=Decimal("0"),
        total_amount=total_amount,
        contact_name=None,
        contact_phone=None,
        address=material_lot.lot_no if material_lot else "",
        remark=remark_prefix,
        lines=lines,
    )
```

Also add `case` and `or_` to the SQLAlchemy import line:

```python
from sqlalchemy import case, func, or_, select
```

- [ ] **Step 5: Register new collectors**

Modify `collect_archive_context`:

```python
if document_type == DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT:
    return collect_production_material_out_archive_context(db, business_id)
if document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT:
    return collect_production_inbound_settlement_archive_context(db, business_id)
```

- [ ] **Step 6: Add document route keys**

Modify `backend/app/api/routes/document_archives.py` imports:

```python
DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
```

Add to `DOCUMENT_TYPE_KEY_MAP`:

```python
"production-material-out": DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
"production-inbound-settlement": DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT: DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT: DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
```

Update the validation error:

```python
raise HTTPException(status_code=400, detail="单据类型只能选择销售订单、采购订单、到货入库单、质量校验单、生产领料出库单或生产入库结算单")
```

- [ ] **Step 7: Run archive tests**

Run:

```bash
PYTHONPATH=backend backend/.venv/bin/pytest backend/tests/test_production_warehouse_document_archive.py backend/tests/test_document_archive_routes.py::DocumentArchiveRouteTest::test_normalize_document_type_key_supports_route_keys -q
```

Expected: PASS.

- [ ] **Step 8: Run existing document archive tests**

Run:

```bash
PYTHONPATH=backend backend/.venv/bin/pytest backend/tests/test_document_archive_service.py backend/tests/test_quality_inspection_document_archive.py backend/tests/test_document_archive_routes.py -q
```

Expected: PASS. This confirms sales, purchase, receipt, and quality archive paths still work.

- [ ] **Step 9: Commit backend archive service changes**

Run:

```bash
git add backend/app/services/document_archives.py backend/app/api/routes/document_archives.py backend/tests/test_production_warehouse_document_archive.py backend/tests/test_document_archive_routes.py
git commit -m "feat: add production warehouse document archive types"
```

Expected: commit succeeds in a git worktree. If no `.git` exists, record the skipped commit.

---

### Task 3: Generate Archives From Production Save Points

**Files:**
- Modify: `backend/app/schemas/operations.py`
- Modify: `backend/app/api/routes/production.py`
- Modify: `backend/app/services/production_work_order_inbound.py`
- Modify: `backend/app/api/routes/inventory.py`
- Test: `backend/tests/test_production_warehouse_document_archive.py`

- [ ] **Step 1: Add archive fields to response schemas**

In `backend/app/schemas/operations.py`, add these fields to `StockLotRead`, `ProductionWorkOrderInboundRead`, and `ProductionBatchLedgerInboundRead`:

```python
archive_status: str | None = None
archive_business_id: int | None = None
archive_document_type: str | None = None
archive_error_message: str | None = None
```

Add these fields to `ProductionBatchLedgerTxnRead`:

```python
archive_status: str | None = None
archive_business_id: int | None = None
archive_document_type: str | None = None
archive_error_message: str | None = None
```

Add these fields to `ProductionBatchLedgerRead`:

```python
material_out_archive_status: str | None = None
material_out_archive_business_id: int | None = None
material_out_archive_document_type: str | None = None
material_out_archive_error_message: str | None = None
```

- [ ] **Step 2: Add a small archive-result helper in `production.py`**

In `backend/app/api/routes/production.py`, import:

```python
from app.schemas.document_archives import DocumentArchiveGenerateResult
from app.services.document_archives import (
    DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
    generate_document_archive,
)
```

Add helper near route helpers:

```python
def _archive_fields_from_result(result: DocumentArchiveGenerateResult | None, *, document_type: str, business_id: int | None) -> dict[str, object]:
    if result is None:
        return {
            "archive_status": "未生成",
            "archive_business_id": business_id,
            "archive_document_type": document_type,
            "archive_error_message": None,
        }
    return {
        "archive_status": result.status,
        "archive_business_id": business_id,
        "archive_document_type": document_type,
        "archive_error_message": result.error_message,
    }
```

- [ ] **Step 3: Generate settlement archive after `/production/production-ledger-inbounds`**

In `create_production_batch_ledger_inbound`, after `db.commit()` and before returning, determine the main transaction ID:

```python
main_txn_id = finished_txn_id or surplus_txn_id or scrap_txn_id
archive_result = None
if main_txn_id:
    try:
        archive_result = generate_document_archive(
            db,
            DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
            int(main_txn_id),
            created_by=context.user.id,
        )
    except Exception as exc:
        archive_result = DocumentArchiveGenerateResult(
            document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
            business_id=int(main_txn_id),
            document_no=f"库存流水#{main_txn_id}",
            archive_version=0,
            status="归档失败",
            file_name="",
            file_path="",
            error_message=str(exc),
        )
```

In the returned `ProductionBatchLedgerInboundRead`, include:

```python
**_archive_fields_from_result(
    archive_result,
    document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
    business_id=int(main_txn_id) if main_txn_id else None,
)
```

To keep the current constructor style easy to review, replace the direct constructor return for `ProductionBatchLedgerInboundRead` with this explicit response object:

```python
response = ProductionBatchLedgerInboundRead(
    production_ledger_id=ledger.id,
    material_lot_no=ledger.material_lot_no,
    finished_qty=float(finished_qty),
    surplus_weight_kg=float(surplus_weight),
    scrap_weight_kg=float(scrap_weight),
    finished_lot_id=finished_lot_id,
    surplus_lot_id=surplus_lot_id,
    scrap_lot_id=scrap_lot_id,
    finished_txn_id=finished_txn_id,
    surplus_txn_id=surplus_txn_id,
    scrap_txn_id=scrap_txn_id,
    locked=bool(payload.lock_material_batch),
    status=ledger.status,
    remark=payload.remark,
)
for key, value in _archive_fields_from_result(
    archive_result,
    document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
    business_id=int(main_txn_id) if main_txn_id else None,
).items():
    setattr(response, key, value)
return response
```

- [ ] **Step 4: Generate settlement archive after old `/production/work-order-inbounds`**

In `backend/app/services/production_work_order_inbound.py`, import:

```python
from app.schemas.document_archives import DocumentArchiveGenerateResult
from app.services.document_archives import DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, generate_document_archive
```

After `db.commit()`, before returning, add:

```python
main_txn_id = finished_txn_id or surplus_txn_id or scrap_txn_id
archive_status = "未生成"
archive_error_message = None
if main_txn_id:
    try:
        archive_result = generate_document_archive(
            db,
            DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
            int(main_txn_id),
            created_by=operator_user_id,
        )
        archive_status = archive_result.status
        archive_error_message = archive_result.error_message
    except Exception as exc:
        archive_status = "归档失败"
        archive_error_message = str(exc)
```

Include these fields in `ProductionWorkOrderInboundRead`:

```python
archive_status=archive_status,
archive_business_id=int(main_txn_id) if main_txn_id else None,
archive_document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT if main_txn_id else None,
archive_error_message=archive_error_message,
```

- [ ] **Step 5: Generate material issue archive after raw material production outbound**

In `backend/app/api/routes/inventory.py`, import:

```python
from app.schemas.document_archives import DocumentArchiveGenerateResult
from app.services.document_archives import (
    DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
    DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
    generate_document_archive,
)
```

In `create_warehouse_outbound`, track production ledger ID for production outbound. After the branch that posts production issue to ledger, store:

```python
production_material_out_archive_ledger_id = None
```

When `biz_type == "PRODUCTION_OUT"` creates or updates a `ProductionBatchLedger`, set:

```python
production_material_out_archive_ledger_id = ledger.id
```

After the existing `db.commit()`, before returning `StockLotRead`, generate:

```python
archive_status = None
archive_business_id = None
archive_document_type = None
archive_error_message = None
if production_material_out_archive_ledger_id:
    archive_business_id = int(production_material_out_archive_ledger_id)
    archive_document_type = DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT
    try:
        archive_result = generate_document_archive(
            db,
            DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
            archive_business_id,
            created_by=context.user.id,
        )
        archive_status = archive_result.status
        archive_error_message = archive_result.error_message
    except Exception as exc:
        archive_status = "归档失败"
        archive_error_message = str(exc)
```

When returning the `StockLotRead`, assign:

```python
response = StockLotRead.model_validate(dict(row))
response.archive_status = archive_status
response.archive_business_id = archive_business_id
response.archive_document_type = archive_document_type
response.archive_error_message = archive_error_message
return response
```

- [ ] **Step 6: Generate settlement archive after branch production inbound**

In `create_warehouse_inbound`, track the main inventory transaction ID for these `biz_type` values:

```python
PRODUCTION_ARCHIVE_INBOUND_BIZ_TYPES = {"PRODUCTION_FINISHED", "PRODUCTION_SURPLUS", "PRODUCTION_SCRAP_IN"}
```

For the source-lot return branch, after the existing assignment to `return_txn` from `create_inventory_txn`, set:

```python
production_inbound_archive_main_txn_id = return_txn.id if biz_type in PRODUCTION_ARCHIVE_INBOUND_BIZ_TYPES else None
```

For the non-return branches, capture the `InventoryTxn` created for finished production inbound and production scrap inbound. Use the same priority rule:

```python
production_inbound_archive_main_txn_id = finished_txn.id or surplus_txn.id or scrap_txn.id
```

After `db.commit()`, generate `DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT` with `production_inbound_archive_main_txn_id`, catch exceptions, and set the same four archive fields on `StockLotRead`.

- [ ] **Step 7: Add integration tests for archive generation not rolling back business data**

Append to `backend/tests/test_production_warehouse_document_archive.py`:

```python
def test_archive_generation_failure_keeps_existing_business_rows(self) -> None:
    with patch("app.services.document_archives.render_pdf_archive", side_effect=RuntimeError("PDF服务异常")):
        result = generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, self.finished_txn.id, created_by=1)

    self.assertEqual(result.status, "归档失败")
    self.assertIsNotNone(self.db.get(InventoryTxn, self.finished_txn.id))
    saved = self.db.scalar(select(DocumentArchive).where(DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT))
    self.assertIsNotNone(saved)
    self.assertEqual(saved.status, "归档失败")
```

- [ ] **Step 8: Run focused backend tests**

Run:

```bash
PYTHONPATH=backend backend/.venv/bin/pytest backend/tests/test_production_warehouse_document_archive.py backend/tests/test_production_batch_ledger.py backend/tests/test_production_work_order_inbound.py -q
```

Expected: PASS.

- [ ] **Step 9: Run production inventory regression tests**

Run:

```bash
PYTHONPATH=backend backend/.venv/bin/pytest backend/tests/test_selected_stock_lot_production_issue.py backend/tests/test_production_work_order_ledger.py backend/tests/test_scrap_warehouse_flow.py -q
```

Expected: PASS.

- [ ] **Step 10: Commit production save-point archive generation**

Run:

```bash
git add backend/app/schemas/operations.py backend/app/api/routes/production.py backend/app/services/production_work_order_inbound.py backend/app/api/routes/inventory.py backend/tests/test_production_warehouse_document_archive.py
git commit -m "feat: archive production warehouse save points"
```

Expected: commit succeeds in a git worktree. If no `.git` exists, record the skipped commit.

---

### Task 4: Expose Archive Status In Production Ledger API

**Files:**
- Modify: `backend/app/api/routes/production.py`
- Test: `backend/tests/test_production_warehouse_document_archive.py`

- [ ] **Step 1: Import archive model and status constants**

In `backend/app/api/routes/production.py`, import:

```python
from app.models.document_archive import DocumentArchive
from app.services.document_archives import ARCHIVE_STATUS_MISSING, FILE_FORMAT_PDF
```

Keep the production document type imports from Task 3.

- [ ] **Step 2: Add helper to get latest archive rows by business IDs**

Add near route helpers:

```python
def _latest_archive_map(db: Session, document_type: str, business_ids: list[int]) -> dict[int, DocumentArchive]:
    if not business_ids:
        return {}
    latest_version_subquery = (
        select(
            DocumentArchive.business_id.label("business_id"),
            func.max(DocumentArchive.archive_version).label("archive_version"),
        )
        .where(
            DocumentArchive.document_type == document_type,
            DocumentArchive.file_format == FILE_FORMAT_PDF,
            DocumentArchive.business_id.in_(business_ids),
        )
        .group_by(DocumentArchive.business_id)
        .subquery()
    )
    latest_id_subquery = (
        select(
            DocumentArchive.business_id.label("business_id"),
            func.max(DocumentArchive.id).label("archive_id"),
        )
        .join(
            latest_version_subquery,
            (latest_version_subquery.c.business_id == DocumentArchive.business_id)
            & (latest_version_subquery.c.archive_version == DocumentArchive.archive_version),
        )
        .where(
            DocumentArchive.document_type == document_type,
            DocumentArchive.file_format == FILE_FORMAT_PDF,
        )
        .group_by(DocumentArchive.business_id)
        .subquery()
    )
    rows = db.scalars(
        select(DocumentArchive).join(latest_id_subquery, latest_id_subquery.c.archive_id == DocumentArchive.id)
    ).all()
    return {int(row.business_id): row for row in rows}
```

- [ ] **Step 3: Populate production ledger archive fields**

Inside `list_production_batch_ledger`, after `ledger_ids` is built:

```python
material_out_archives = _latest_archive_map(db, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, ledger_ids)
```

After `txn_rows` is loaded, collect settlement business IDs:

```python
settlement_business_ids = [
    int(txn.source_doc_id)
    for txn in txn_rows
    if txn.source_doc_type == "库存流水" and txn.source_doc_id and txn.txn_type in {"成品入库", "生产余料入库", "生产废料入库"}
]
settlement_archives = _latest_archive_map(db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, settlement_business_ids)
```

If current `ProductionBatchLedgerTxn.txn_type` values for inbound rows are exactly `成品入库`, `生产余料入库`, `生产废料入库`, keep the set above. If the code uses `post_finished_inbound_to_ledger`, `post_surplus_inbound_to_ledger`, and `post_scrap_inbound_to_ledger` with these names, do not change them.

- [ ] **Step 4: Extend `_production_ledger_txn_read`**

Modify `_production_ledger_txn_read(txn)` to compute archive fields:

```python
def _production_ledger_txn_read(txn: ProductionBatchLedgerTxn, archive: DocumentArchive | None = None) -> dict:
    archive_business_id = int(txn.source_doc_id) if txn.source_doc_type == "库存流水" and txn.source_doc_id else None
    return {
        "production_ledger_txn_id": txn.id,
        "production_ledger_id": txn.production_ledger_id,
        "txn_type": txn.txn_type,
        "qty_delta": float(to_decimal(txn.qty_delta)),
        "weight_delta_kg": float(to_decimal(txn.weight_delta_kg)),
        "outside_weight_after_kg": float(to_decimal(txn.outside_weight_after_kg)),
        "source_doc_type": txn.source_doc_type,
        "source_doc_id": txn.source_doc_id,
        "source_line_id": txn.source_line_id,
        "biz_time": txn.biz_time,
        "operator_user_id": txn.operator_user_id,
        "remark": txn.remark,
        "archive_status": archive.status if archive else (ARCHIVE_STATUS_MISSING if archive_business_id else None),
        "archive_business_id": archive_business_id,
        "archive_document_type": DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT if archive_business_id else None,
        "archive_error_message": archive.error_message if archive else None,
    }
```

Update all call sites to pass the archive:

```python
txns=[
    _production_ledger_txn_read(
        txn,
        settlement_archives.get(int(txn.source_doc_id)) if txn.source_doc_type == "库存流水" and txn.source_doc_id else None,
    )
    for txn in txns_by_ledger_id.get(ledger.id, [])
]
```

- [ ] **Step 5: Extend `ProductionBatchLedgerRead` construction**

When appending `ProductionBatchLedgerRead`, get:

```python
material_archive = material_out_archives.get(int(ledger.id))
```

Add fields:

```python
material_out_archive_status=material_archive.status if material_archive else ARCHIVE_STATUS_MISSING,
material_out_archive_business_id=ledger.id,
material_out_archive_document_type=DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
material_out_archive_error_message=material_archive.error_message if material_archive else None,
```

- [ ] **Step 6: Add a production ledger API test**

Add to `backend/tests/test_production_warehouse_document_archive.py` a direct helper-level assertion if full FastAPI setup is too heavy:

```python
def test_latest_archive_rows_can_be_joined_by_business_id(self) -> None:
    with TemporaryDirectory() as tmp_dir, patch("app.services.document_archives.default_archive_root", return_value=tmp_dir):
        generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, self.ledger.id, created_by=1)
        generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, self.finished_txn.id, created_by=1)

    material_archive = self.db.scalar(select(DocumentArchive).where(DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT))
    settlement_archive = self.db.scalar(select(DocumentArchive).where(DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT))
    self.assertEqual(material_archive.business_id, self.ledger.id)
    self.assertEqual(settlement_archive.business_id, self.finished_txn.id)
```

The execution agent should also run the full production API tests after implementation.

- [ ] **Step 7: Run production API and archive tests**

Run:

```bash
PYTHONPATH=backend backend/.venv/bin/pytest backend/tests/test_production_warehouse_document_archive.py backend/tests/test_production_batch_ledger.py -q
```

Expected: PASS.

- [ ] **Step 8: Commit production ledger archive status API**

Run:

```bash
git add backend/app/api/routes/production.py backend/app/schemas/operations.py backend/tests/test_production_warehouse_document_archive.py
git commit -m "feat: expose production archive status"
```

Expected: commit succeeds in a git worktree. If no `.git` exists, record the skipped commit.

---

### Task 5: Frontend Archive Actions For Production Ledger And Warehouse Feedback

**Files:**
- Modify: `frontend/src/views/ProductionLedgerView.vue`
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- Create: `frontend/src/views/ProductionLedgerView.test.js`

- [ ] **Step 1: Add static frontend test for production ledger archive UI**

Create `frontend/src/views/ProductionLedgerView.test.js`:

```javascript
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const __dirname = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(join(__dirname, "ProductionLedgerView.vue"), "utf8");

describe("ProductionLedgerView archive actions", () => {
  it("uses DocumentArchiveActions for material out and inbound settlement archives", () => {
    assert.match(source, /DocumentArchiveActions/);
    assert.match(source, /production-material-out/);
    assert.match(source, /production-inbound-settlement/);
  });

  it("has preview, download and regenerate archive handlers", () => {
    assert.match(source, /previewDocumentArchive/);
    assert.match(source, /downloadDocumentArchive/);
    assert.match(source, /regenerateDocumentArchive/);
  });
});
```

- [ ] **Step 2: Run the frontend test and verify it fails**

Run:

```bash
node --test frontend/src/views/ProductionLedgerView.test.js
```

Expected: FAIL because the archive component and route keys are not yet used in `ProductionLedgerView.vue`.

- [ ] **Step 3: Import archive component and API helpers**

In `frontend/src/views/ProductionLedgerView.vue`, add:

```javascript
import DocumentArchiveActions from "../components/documentForms/DocumentArchiveActions.vue";
import { downloadResource, fetchResource, openResource, postResource } from "../services/api";
```

Replace the current import:

```javascript
import { fetchResource, postResource } from "../services/api";
```

with the combined import above.

- [ ] **Step 4: Render material issue archive actions in the detail header**

Inside the first detail drawer, below the `drawer-row-detail-grid`, add:

```vue
<div v-if="activeLedger" class="production-archive-panel">
  <div>
    <span>生产领料出库单</span>
    <strong>{{ activeLedger.material_lot_no || "-" }}</strong>
  </div>
  <DocumentArchiveActions
    :status="activeLedger.material_out_archive_status"
    @preview="previewDocumentArchive('production-material-out', activeLedger.material_out_archive_business_id || activeLedger.production_ledger_id)"
    @download="downloadDocumentArchive('production-material-out', activeLedger.material_out_archive_business_id || activeLedger.production_ledger_id, `${activeLedger.material_lot_no || '生产领料出库单'}-${activeLedger.product_name || '产品'}归档.pdf`)"
    @regenerate="regenerateDocumentArchive('production-material-out', activeLedger.material_out_archive_business_id || activeLedger.production_ledger_id)"
  />
</div>
```

- [ ] **Step 5: Render inbound settlement archive actions in transaction detail**

Inside the `DrawerDataTable` `#detail` slot, after the existing `drawer-row-detail-grid`, add:

```vue
<div v-if="row.archive_business_id" class="production-archive-panel production-archive-panel-inline">
  <div>
    <span>生产入库结算单</span>
    <strong>{{ row.txn_type || "-" }}</strong>
  </div>
  <DocumentArchiveActions
    :status="row.archive_status"
    @preview="previewDocumentArchive('production-inbound-settlement', row.archive_business_id)"
    @download="downloadDocumentArchive('production-inbound-settlement', row.archive_business_id, `${activeLedger?.material_lot_no || '生产入库结算单'}-${row.txn_type || row.production_ledger_txn_id}归档.pdf`)"
    @regenerate="regenerateDocumentArchive('production-inbound-settlement', row.archive_business_id)"
  />
</div>
```

- [ ] **Step 6: Add handlers**

In `ProductionLedgerView.vue` script, add:

```javascript
async function previewDocumentArchive(typeKey, businessId) {
  feedbackMessage.value = "";
  errorMessage.value = "";
  if (!businessId) {
    errorMessage.value = "缺少归档业务ID，无法预览";
    return;
  }
  try {
    await openResource(`/document-archives/${typeKey}/${businessId}/latest/preview`);
  } catch (error) {
    errorMessage.value = error.message || "PDF归档预览失败";
  }
}

async function downloadDocumentArchive(typeKey, businessId, fallbackFilename) {
  feedbackMessage.value = "";
  errorMessage.value = "";
  if (!businessId) {
    errorMessage.value = "缺少归档业务ID，无法下载";
    return;
  }
  try {
    await downloadResource(`/document-archives/${typeKey}/${businessId}/latest/download`, fallbackFilename);
    feedbackMessage.value = "PDF归档已下载";
  } catch (error) {
    errorMessage.value = error.message || "PDF归档下载失败";
  }
}

async function regenerateDocumentArchive(typeKey, businessId) {
  feedbackMessage.value = "";
  errorMessage.value = "";
  if (!businessId) {
    errorMessage.value = "缺少归档业务ID，无法重新生成";
    return;
  }
  try {
    const result = await postResource(`/document-archives/${typeKey}/${businessId}/generate`, {});
    feedbackMessage.value = result.message || "PDF归档已重新生成";
    await loadAll();
  } catch (error) {
    errorMessage.value = error.message || "PDF归档重新生成失败";
  }
}
```

- [ ] **Step 7: Add production archive styles**

Add scoped styles to `ProductionLedgerView.vue`:

```css
.production-archive-panel {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  margin: 14px 0;
  padding: 12px 14px;
  border: 1px solid rgba(59, 130, 246, 0.18);
  border-radius: 16px;
  background: linear-gradient(135deg, rgba(239, 246, 255, 0.96), rgba(255, 255, 255, 0.9));
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
}

.production-archive-panel > div:first-child {
  display: grid;
  gap: 4px;
}

.production-archive-panel span {
  color: var(--muted);
  font-size: 12px;
}

.production-archive-panel strong {
  color: var(--ink);
  font-size: 14px;
}

.production-archive-panel-inline {
  margin-top: 12px;
}
```

- [ ] **Step 8: Update warehouse feedback messages**

In `InventoryLedgerView.vue`, add helper:

```javascript
function formatArchiveFeedback(result) {
  if (!result?.archive_status) {
    return "";
  }
  if (result.archive_status === "已归档") {
    return "，PDF归档已生成";
  }
  if (result.archive_status === "归档失败") {
    return `，PDF归档失败${result.archive_error_message ? `：${result.archive_error_message}` : ""}`;
  }
  return `，PDF归档状态：${result.archive_status}`;
}
```

Update `submitProductionWorkOrderInbound` success message:

```javascript
feedbackMessage.value = `生产台账入库 ${result.material_lot_no || ""} 已保存${formatArchiveFeedback(result)}`;
```

Update `submitGenericOperation` success messages for production branch operations so the saved result appends `formatArchiveFeedback(result)`. For inbound and outbound requests currently calling `postResource` without keeping the response, keep the payload unchanged and store the response:

```javascript
const result = await postResource("/inventory/inbound", payload);
feedbackMessage.value = `${activeOperation.value?.label || "入库"}已保存${formatArchiveFeedback(result)}`;
```

and:

```javascript
const result = await postResource("/inventory/outbound", payload);
feedbackMessage.value = `${activeOperation.value?.label || "出库"}已保存${formatArchiveFeedback(result)}`;
```

Do not change validation or payload fields.

- [ ] **Step 9: Run frontend static test**

Run:

```bash
node --test frontend/src/views/ProductionLedgerView.test.js
```

Expected: PASS.

- [ ] **Step 10: Run frontend build**

Run:

```bash
cd frontend
npm run build
```

Expected: PASS. A Vite chunk-size warning is acceptable.

- [ ] **Step 11: Commit frontend archive UI**

Run:

```bash
git add frontend/src/views/ProductionLedgerView.vue frontend/src/views/InventoryLedgerView.vue frontend/src/views/ProductionLedgerView.test.js
git commit -m "feat: show production warehouse archive actions"
```

Expected: commit succeeds in a git worktree. If no `.git` exists, record the skipped commit.

---

### Task 6: Full Verification And Manual Browser Check

**Files:**
- No code changes expected.

- [ ] **Step 1: Run full focused backend verification**

Run:

```bash
PYTHONPATH=backend backend/.venv/bin/pytest \
  backend/tests/test_document_archive_service.py \
  backend/tests/test_quality_inspection_document_archive.py \
  backend/tests/test_document_archive_routes.py \
  backend/tests/test_production_warehouse_document_archive.py \
  backend/tests/test_production_batch_ledger.py \
  backend/tests/test_production_work_order_inbound.py \
  backend/tests/test_selected_stock_lot_production_issue.py \
  backend/tests/test_production_work_order_ledger.py \
  backend/tests/test_scrap_warehouse_flow.py \
  -q
```

Expected: PASS.

- [ ] **Step 2: Run frontend checks**

Run:

```bash
node --test frontend/src/views/ProductionLedgerView.test.js
cd frontend
npm run build
```

Expected: static test PASS and build PASS. Vite chunk-size warning is acceptable.

- [ ] **Step 3: Browser verify production ledger archive actions**

Use the in-app Browser and navigate to:

```text
http://127.0.0.1:5173/production-ledger
```

Expected:

- Production ledger list loads.
- Clicking a material lot opens the detail drawer.
- Detail drawer shows `生产领料出库单` archive actions.
- Transaction detail rows that have inventory source IDs show `生产入库结算单` archive actions.
- If old rows show `未生成`, clicking `重新生成` creates the PDF or shows a Chinese error.

- [ ] **Step 4: Browser verify warehouse feedback**

Use the in-app Browser and navigate to:

```text
http://127.0.0.1:5173/inventory-ledger
```

Expected:

- Production ledger inbound save success message includes PDF archive result when backend returns it.
- Production branch inbound/outbound save success message includes PDF archive result when backend returns it.
- Existing validations and payload behavior remain unchanged.

- [ ] **Step 5: Inspect generated PDF manually**

Preview one generated `生产领料出库单` and one generated `生产入库结算单`.

Expected:

- PDF title is Chinese.
- Status values are Chinese.
- Long material lot numbers and remarks wrap instead of truncating.
- No ellipsis is used in PDF cell content.
- PDF includes material lot number, product, quantities/weights, unit cost, amount, and remarks.

- [ ] **Step 6: Final status report**

Report:

- Backend tests run and pass/fail count.
- Frontend tests/build run and pass/fail count.
- Browser verification result.
- Any Vite warnings.
- Whether commits were created or skipped because this workspace has no `.git`.

---

## Self-Review Checklist

- Spec coverage:
  - Production material issue is covered by `生产领料出库单`.
  - Production ledger inbound and branch inbound are covered by `生产入库结算单`.
  - PDF failure does not roll back business save; archive failure is returned in response fields.
  - History rows show `未生成` and can regenerate through existing document archive routes.
  - Batch download remains out of scope.
- Placeholder scan:
  - No implementation step uses placeholder tokens or unresolved file names.
  - Commands and expected results are explicit.
- Type consistency:
  - Route keys are `production-material-out` and `production-inbound-settlement`.
  - Document types are `生产领料出库单` and `生产入库结算单`.
  - Archive response fields are consistently named `archive_status`, `archive_business_id`, `archive_document_type`, and `archive_error_message`.
