# Production Inbound Archive Entrypoints 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:** Expose preview/download/regenerate entrypoints for `生产入库结算单` in the places users naturally look after production ledger inbound creates multiple warehouse transactions.

**Architecture:** Keep `生产台账入库` as one business settlement action and keep all generated warehouse transactions linked to the same `生产入库结算单` archive. The backend warehouse transaction ledger will return archive metadata for production settlement transactions; the frontend will reuse `DocumentArchiveActions` in the warehouse ledger and show a post-save shortcut when `生产台账入库` succeeds.

**Tech Stack:** FastAPI, SQLAlchemy, Pydantic, existing `document_archives` service/routes, Vue 3, existing `DocumentArchiveActions`, existing `openResource`/`downloadResource`/`postResource`.

---

## File Structure

### Backend

- Modify: `backend/app/schemas/operations.py`
  - Add archive metadata fields to `InventoryTxnRead`, inherited by `InventoryTxnLedgerRowRead`.
- Modify: `backend/app/services/operations.py`
  - Add `DocumentArchive` and production archive constants imports if missing.
  - Add helper functions for production settlement archive grouping on warehouse inventory transactions.
  - Extend `get_inventory_txn_ledger_query` to select archive metadata for production settlement rows.
- Test: `backend/tests/test_inventory_transaction_ledger_archive_fields.py`
  - Add a focused test that proves finished, surplus, and scrap rows from the same production settlement batch all expose the same archive business ID and status.

### Frontend

- Modify: `frontend/src/views/InventoryLedgerView.vue`
  - Import `DocumentArchiveActions`.
  - Import `downloadResource` and `openResource` if not already imported.
  - Add a `单据留档` column to the warehouse流水 drawer.
  - Render `DocumentArchiveActions` for rows with `archive_business_id`.
  - Add archive preview/download/regenerate handlers.
  - Add a post-save shortcut panel after `生产台账入库` succeeds.
- Modify: `frontend/src/views/InventoryLedgerView.test.js`
  - Add static assertions for the new warehouse ledger archive column, document type key, and post-save shortcut state.
- Modify: `frontend/src/styles/main.css`
  - Add compact archive action styling for table cells and feedback shortcut panels.

---

## Current Reality To Preserve

- `生产台账入库` already calls `POST /production/production-ledger-inbounds`.
- That endpoint already returns:
  - `archive_status`
  - `archive_business_id`
  - `archive_document_type`
  - `archive_error_message`
- `ProductionLedgerView.vue` already uses `DocumentArchiveActions` for `production-material-out` and `production-inbound-settlement`.
- `InventoryLedgerView.vue` warehouse ledger currently has no archive column.
- `GET /inventory/transaction-ledger` currently returns `InventoryTxnLedgerRowRead`, which does not yet include archive metadata.

---

### Task 1: Backend Test For Warehouse Ledger Archive Metadata

**Files:**
- Create: `backend/tests/test_inventory_transaction_ledger_archive_fields.py`

- [ ] **Step 1: Write the failing test**

Create `backend/tests/test_inventory_transaction_ledger_archive_fields.py`:

```python
from __future__ import annotations

import unittest
from datetime import datetime
from decimal import Decimal

from sqlalchemy import BigInteger, create_engine
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, StockLot  # noqa: E402
from app.services.document_archives import DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT  # noqa: E402
from app.services.operations import get_inventory_txn_ledger_query  # noqa: E402


class InventoryTransactionLedgerArchiveFieldsTest(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, 14, 0, 0)
        self._seed_data()

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

    def _seed_data(self) -> None:
        finished_wh = Warehouse(
            id=1,
            warehouse_code="WH-FIN",
            warehouse_name="成品库",
            warehouse_type="FINISHED",
            status="ACTIVE",
            created_at=self.now,
            updated_at=self.now,
        )
        raw_wh = Warehouse(
            id=2,
            warehouse_code="WH-RAW",
            warehouse_name="原材料库",
            warehouse_type="RAW",
            status="ACTIVE",
            created_at=self.now,
            updated_at=self.now,
        )
        scrap_wh = Warehouse(
            id=3,
            warehouse_code="WH-SCRAP",
            warehouse_name="废料库",
            warehouse_type="SCRAP",
            status="ACTIVE",
            created_at=self.now,
            updated_at=self.now,
        )
        material = Item(
            id=10,
            item_code="RM-010",
            item_name="测试原材料",
            item_type="RAW_MATERIAL",
            unit_weight_kg=Decimal("0"),
            status="ACTIVE",
            created_at=self.now,
            updated_at=self.now,
        )
        product = Item(
            id=20,
            item_code="FG-020",
            item_name="测试成品",
            item_type="FINISHED",
            unit_weight_kg=Decimal("0.5"),
            status="ACTIVE",
            created_at=self.now,
            updated_at=self.now,
        )
        source_lot = StockLot(
            id=100,
            lot_no="YL0001",
            item_id=material.id,
            warehouse_id=raw_wh.id,
            location_id=None,
            inbound_qty=0,
            inbound_weight_kg=500,
            remaining_qty=0,
            remaining_weight_kg=200,
            locked_qty=0,
            locked_weight_kg=0,
            unit_cost=Decimal("6.5"),
            quality_status="PASS",
            status="AVAILABLE",
            created_at=self.now,
            updated_at=self.now,
        )
        finished_lot = StockLot(
            id=101,
            lot_no="FGL0001",
            parent_lot_id=source_lot.id,
            item_id=product.id,
            warehouse_id=finished_wh.id,
            location_id=None,
            source_material_lot_id=source_lot.id,
            source_material_sub_batch_no="YL0001",
            inbound_qty=100,
            inbound_weight_kg=50,
            remaining_qty=100,
            remaining_weight_kg=50,
            locked_qty=0,
            locked_weight_kg=0,
            unit_cost=Decimal("3.25"),
            quality_status="PASS",
            status="AVAILABLE",
            created_at=self.now,
            updated_at=self.now,
        )
        scrap_lot = StockLot(
            id=102,
            lot_no="SCRAPI0001",
            parent_lot_id=source_lot.id,
            item_id=material.id,
            warehouse_id=scrap_wh.id,
            location_id=None,
            source_material_lot_id=source_lot.id,
            source_material_sub_batch_no="YL0001",
            inbound_qty=0,
            inbound_weight_kg=5,
            remaining_qty=0,
            remaining_weight_kg=5,
            locked_qty=0,
            locked_weight_kg=0,
            unit_cost=Decimal("6.5"),
            quality_status="SCRAP",
            status="AVAILABLE",
            created_at=self.now,
            updated_at=self.now,
        )
        batch_no = "生产结算20260611140000000001"
        self.finished_txn = InventoryTxn(
            id=1000,
            txn_no="TXN-FIN-001",
            txn_type="FG_IN",
            item_id=product.id,
            warehouse_id=finished_wh.id,
            location_id=None,
            lot_id=finished_lot.id,
            qty_change=Decimal("100"),
            weight_change_kg=Decimal("50"),
            unit_cost=Decimal("3.25"),
            amount=Decimal("325"),
            source_doc_type="生产台账",
            source_doc_id=2000,
            source_line_id=finished_lot.id,
            biz_time=self.now,
            remark=f"生产台账入库；成品入库；单据批次号：{batch_no}",
            created_at=self.now,
            updated_at=self.now,
        )
        self.surplus_txn = InventoryTxn(
            id=1001,
            txn_no="TXN-SURPLUS-001",
            txn_type="PRODUCTION_SURPLUS_IN",
            item_id=material.id,
            warehouse_id=raw_wh.id,
            location_id=None,
            lot_id=source_lot.id,
            qty_change=Decimal("0"),
            weight_change_kg=Decimal("20"),
            unit_cost=Decimal("6.5"),
            amount=Decimal("130"),
            source_doc_type="生产台账",
            source_doc_id=2000,
            source_line_id=source_lot.id,
            biz_time=self.now,
            remark=f"生产台账入库；生产余料入库；单据批次号：{batch_no}",
            created_at=self.now,
            updated_at=self.now,
        )
        self.scrap_txn = InventoryTxn(
            id=1002,
            txn_no="TXN-SCRAP-001",
            txn_type="PRODUCTION_SCRAP_IN",
            item_id=material.id,
            warehouse_id=scrap_wh.id,
            location_id=None,
            lot_id=scrap_lot.id,
            qty_change=Decimal("0"),
            weight_change_kg=Decimal("5"),
            unit_cost=Decimal("6.5"),
            amount=Decimal("32.5"),
            source_doc_type="生产台账",
            source_doc_id=2000,
            source_line_id=scrap_lot.id,
            biz_time=self.now,
            remark=f"生产台账入库；生产废料入库；单据批次号：{batch_no}",
            created_at=self.now,
            updated_at=self.now,
        )
        archive = DocumentArchive(
            document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
            business_id=self.finished_txn.id,
            document_no="生产入库结算-TXN-FIN-001",
            archive_version=1,
            file_format="PDF",
            file_name="生产入库结算-TXN-FIN-001_V1.pdf",
            file_path="/tmp/生产入库结算-TXN-FIN-001_V1.pdf",
            status="已归档",
            error_message=None,
            created_by=None,
            created_at=self.now,
        )
        self.db.add_all([
            finished_wh,
            raw_wh,
            scrap_wh,
            material,
            product,
            source_lot,
            finished_lot,
            scrap_lot,
            self.finished_txn,
            self.surplus_txn,
            self.scrap_txn,
            archive,
        ])
        self.db.commit()

    def test_finished_warehouse_row_exposes_own_settlement_archive(self) -> None:
        rows = self.db.execute(get_inventory_txn_ledger_query(warehouse_type="FINISHED")).mappings().all()

        self.assertEqual(len(rows), 1)
        row = dict(rows[0])
        self.assertEqual(row["inventory_txn_id"], self.finished_txn.id)
        self.assertEqual(row["archive_business_id"], self.finished_txn.id)
        self.assertEqual(row["archive_document_type"], DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT)
        self.assertEqual(row["archive_status"], "已归档")

    def test_surplus_warehouse_row_exposes_same_settlement_archive(self) -> None:
        rows = self.db.execute(get_inventory_txn_ledger_query(warehouse_type="RAW")).mappings().all()

        self.assertEqual(len(rows), 1)
        row = dict(rows[0])
        self.assertEqual(row["inventory_txn_id"], self.surplus_txn.id)
        self.assertEqual(row["archive_business_id"], self.finished_txn.id)
        self.assertEqual(row["archive_document_type"], DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT)
        self.assertEqual(row["archive_status"], "已归档")

    def test_scrap_warehouse_row_exposes_same_settlement_archive(self) -> None:
        rows = self.db.execute(get_inventory_txn_ledger_query(warehouse_type="SCRAP")).mappings().all()

        self.assertEqual(len(rows), 1)
        row = dict(rows[0])
        self.assertEqual(row["inventory_txn_id"], self.scrap_txn.id)
        self.assertEqual(row["archive_business_id"], self.finished_txn.id)
        self.assertEqual(row["archive_document_type"], DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT)
        self.assertEqual(row["archive_status"], "已归档")


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

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

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
python -m unittest backend.tests.test_inventory_transaction_ledger_archive_fields -v
```

Expected: fail with missing `archive_business_id`/`archive_document_type`/`archive_status` columns on the warehouse transaction ledger query.

---

### Task 2: Add Archive Metadata To Warehouse Transaction Ledger

**Files:**
- Modify: `backend/app/schemas/operations.py`
- Modify: `backend/app/services/operations.py`

- [ ] **Step 1: Extend the response schema**

In `backend/app/schemas/operations.py`, add these fields to `InventoryTxnRead` after `remark`:

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

- [ ] **Step 2: Import production archive constants**

In `backend/app/services/operations.py`, add this import near the other service imports:

```python
from app.services.document_archives import DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, production_document_batch_no_from_text
```

- [ ] **Step 3: Add production settlement helper constants**

In `backend/app/services/operations.py`, near `WAREHOUSE_LEDGER_SORT_COLUMNS`, add:

```python
PRODUCTION_SETTLEMENT_INVENTORY_TXN_TYPES = {
    "FG_IN",
    "PRODUCTION_SURPLUS_IN",
    "PRODUCTION_SCRAP_IN",
}

PRODUCTION_SETTLEMENT_MAIN_TXN_PRIORITY = {
    "FG_IN": 1,
    "PRODUCTION_SURPLUS_IN": 2,
    "PRODUCTION_SCRAP_IN": 3,
}
```

- [ ] **Step 4: Add a settlement main transaction subquery helper**

In `backend/app/services/operations.py`, add this helper before `get_inventory_txn_ledger_query`:

```python
def _production_settlement_archive_subquery():
    settlement_txn = aliased(InventoryTxn)
    settlement_batch_no = func.coalesce(
        production_document_batch_no_from_text(settlement_txn.remark),
        func.date_format(settlement_txn.biz_time, "%Y-%m-%d %H:%i:%s.%f"),
    ).label("settlement_group_key")
    priority_expr = case(
        *[
            (settlement_txn.txn_type == txn_type, priority)
            for txn_type, priority in PRODUCTION_SETTLEMENT_MAIN_TXN_PRIORITY.items()
        ],
        else_=99,
    )
    grouped = (
        select(
            settlement_txn.source_doc_id.label("production_ledger_id"),
            settlement_batch_no,
            func.min(
                case(
                    *[
                        (settlement_txn.txn_type == txn_type, priority)
                        for txn_type, priority in PRODUCTION_SETTLEMENT_MAIN_TXN_PRIORITY.items()
                    ],
                    else_=99,
                )
                * 1_000_000_000
                + settlement_txn.id
            ).label("main_rank"),
        )
        .where(
            settlement_txn.source_doc_type == "生产台账",
            settlement_txn.source_doc_id.is_not(None),
            settlement_txn.txn_type.in_(PRODUCTION_SETTLEMENT_INVENTORY_TXN_TYPES),
        )
        .group_by(settlement_txn.source_doc_id, settlement_batch_no)
        .subquery()
    )
    return grouped
```

If MySQL-specific `date_format` breaks SQLite tests, replace the fallback group key with `func.coalesce(production_document_batch_no_from_text(settlement_txn.remark), settlement_txn.biz_time)`. The seed data uses `单据批次号` in all remarks, so the SQLite tests still exercise the intended path.

- [ ] **Step 5: Join archive metadata in `get_inventory_txn_ledger_query`**

In `get_inventory_txn_ledger_query`, add aliases before building `stmt`:

```python
    settlement_grouped = _production_settlement_archive_subquery()
    settlement_main_txn = aliased(InventoryTxn)
    settlement_archive = aliased(DocumentArchive)
    current_batch_no = func.coalesce(
        production_document_batch_no_from_text(InventoryTxn.remark),
        func.date_format(InventoryTxn.biz_time, "%Y-%m-%d %H:%i:%s.%f"),
    )
```

Add these selected columns after `InventoryTxn.remark.label("remark")`:

```python
            settlement_archive.status.label("archive_status"),
            settlement_main_txn.id.label("archive_business_id"),
            literal(DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT).label("archive_document_type"),
            settlement_archive.error_message.label("archive_error_message"),
```

Add these joins after the operator join:

```python
        .outerjoin(
            settlement_grouped,
            and_(
                InventoryTxn.source_doc_type == "生产台账",
                InventoryTxn.source_doc_id == settlement_grouped.c.production_ledger_id,
                current_batch_no == settlement_grouped.c.settlement_group_key,
                InventoryTxn.txn_type.in_(PRODUCTION_SETTLEMENT_INVENTORY_TXN_TYPES),
            ),
        )
        .outerjoin(
            settlement_main_txn,
            settlement_main_txn.id == settlement_grouped.c.main_rank % 1_000_000_000,
        )
        .outerjoin(
            settlement_archive,
            and_(
                settlement_archive.document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
                settlement_archive.business_id == settlement_main_txn.id,
                settlement_archive.file_format == "PDF",
                settlement_archive.archive_version
                == select(func.max(DocumentArchive.archive_version))
                .where(
                    DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
                    DocumentArchive.business_id == settlement_main_txn.id,
                    DocumentArchive.file_format == "PDF",
                )
                .scalar_subquery(),
            ),
        )
```

- [ ] **Step 6: Run the backend test**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
python -m unittest backend.tests.test_inventory_transaction_ledger_archive_fields -v
```

Expected: all 3 tests pass.

---

### Task 3: Frontend Static Tests For Warehouse Ledger Archive Entrypoints

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

- [ ] **Step 1: Add static assertions**

Append this `describe` block to `frontend/src/views/InventoryLedgerView.test.js`:

```javascript
describe("InventoryLedgerView document archive entrypoints", () => {
  it("renders a warehouse ledger archive column using production settlement archive actions", () => {
    assert.match(source, /<th>单据留档<\/th>/);
    assert.match(source, /<DocumentArchiveActions/);
    assert.match(source, /row\.archive_business_id/);
    assert.match(source, /production-inbound-settlement/);
  });

  it("stores the latest production ledger inbound archive shortcut after saving", () => {
    assert.match(source, /lastArchiveAction/);
    assert.match(source, /buildProductionInboundArchiveAction/);
    assert.match(source, /预览结算单/);
    assert.match(source, /下载PDF/);
  });
});
```

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

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node --test src/views/InventoryLedgerView.test.js
```

Expected: fail because archive entrypoint code has not been added yet.

---

### Task 4: Add Warehouse Ledger Archive Actions And Post-Save Shortcut

**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- Modify: `frontend/src/styles/main.css`

- [ ] **Step 1: Import archive helpers**

In `frontend/src/views/InventoryLedgerView.vue`, add the component import:

```javascript
import DocumentArchiveActions from "../components/documentForms/DocumentArchiveActions.vue";
```

Update the API import to include download/open helpers:

```javascript
import { downloadResource, fetchResource, openResource, postResource, requestJson, uploadResource } from "../services/api";
```

- [ ] **Step 2: Add latest archive shortcut state**

Near existing refs, add:

```javascript
const lastArchiveAction = ref(null);
```

Add this helper near `formatArchiveFeedback`:

```javascript
function buildProductionInboundArchiveAction(result) {
  if (!result?.archive_business_id) {
    return null;
  }
  return {
    typeKey: "production-inbound-settlement",
    businessId: Number(result.archive_business_id),
    status: result.archive_status || "未生成",
    label: "生产入库结算单",
    filename: `${result.material_lot_no || "生产入库结算单"}归档.pdf`
  };
}
```

Clear stale shortcut in `resetFeedback`:

```javascript
function resetFeedback() {
  feedbackMessage.value = "";
  errorMessage.value = "";
  lastArchiveAction.value = null;
}
```

- [ ] **Step 3: Render post-save shortcut near feedback banners**

In the main page area, after the existing `feedbackMessage` banner if present, render:

```vue
<div v-if="lastArchiveAction" class="document-archive-feedback-panel">
  <span>{{ lastArchiveAction.label }}</span>
  <button class="document-archive-button document-archive-button-preview" type="button" @click="previewDocumentArchive(lastArchiveAction.typeKey, lastArchiveAction.businessId)">预览结算单</button>
  <button class="document-archive-button document-archive-button-download" type="button" @click="downloadDocumentArchive(lastArchiveAction.typeKey, lastArchiveAction.businessId, lastArchiveAction.filename)">下载PDF</button>
</div>
```

Also render the same panel inside the `productionWorkOrderInboundDrawerOpen` form under its `feedbackMessage` banner, so the shortcut works if a future change keeps the drawer open after save.

- [ ] **Step 4: Add `单据留档` column to warehouse流水**

In the warehouse ledger drawer table header, add a column before `备注`:

```vue
<th>单据留档</th>
```

In the row body, add the matching cell before `备注`:

```vue
<td class="warehouse-ledger-archive-cell">
  <DocumentArchiveActions
    v-if="row.archive_business_id"
    class="document-archive-actions-compact"
    :status="row.archive_status"
    @preview="previewDocumentArchive('production-inbound-settlement', row.archive_business_id)"
    @download="downloadDocumentArchive('production-inbound-settlement', row.archive_business_id, buildWarehouseLedgerArchiveFilename(row))"
    @regenerate="regenerateDocumentArchive('production-inbound-settlement', row.archive_business_id)"
  />
  <span v-else class="muted-cell">-</span>
</td>
```

Update the empty-row colspan expression by adding 1:

```vue
:colspan="activeWeightOnly || activeQuantityOnly ? 13 : 14"
```

- [ ] **Step 5: Add archive action handlers**

Add these functions near `openLogisticsPhoto`:

```javascript
function buildWarehouseLedgerArchiveFilename(row) {
  const lotNo = row.source_material_lot_no || row.lot_no || activeInventoryLabel.value || "生产入库结算单";
  const typeLabel = formatInventoryTxnTypeLabel(row.txn_type) || "生产入库结算单";
  return `${lotNo}-${typeLabel}归档.pdf`;
}

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

- [ ] **Step 6: Store shortcut after `生产台账入库` save**

In `submitProductionWorkOrderInbound`, after the API result:

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

Keep the drawer close behavior unchanged.

- [ ] **Step 7: Add compact styles**

Append to `frontend/src/styles/main.css`:

```css
.warehouse-ledger-archive-cell {
  min-width: 260px;
}

.document-archive-actions-compact {
  align-items: stretch;
  display: grid;
  gap: 6px;
}

.document-archive-actions-compact .document-archive-status {
  justify-content: center;
  min-height: 26px;
}

.document-archive-actions-compact .document-archive-buttons {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 5px;
}

.document-archive-actions-compact .document-archive-button {
  min-height: 28px;
  padding: 0 7px;
  font-size: 11px;
}

.document-archive-feedback-panel {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
  margin: 10px 0 14px;
  padding: 10px;
  border: 1px solid rgba(31, 122, 74, 0.26);
  border-radius: 14px;
  background: rgba(232, 246, 232, 0.76);
}

.document-archive-feedback-panel > span {
  color: #145c39;
  font-weight: 900;
}
```

- [ ] **Step 8: Run the frontend static test**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node --test src/views/InventoryLedgerView.test.js
```

Expected: pass.

---

### Task 5: Verification

**Files:**
- No code files.

- [ ] **Step 1: Run backend archive metadata test**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
python -m unittest backend.tests.test_inventory_transaction_ledger_archive_fields -v
```

Expected: all tests pass.

- [ ] **Step 2: Run frontend static tests**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node --test src/views/InventoryLedgerView.test.js src/views/ProductionLedgerView.test.js
```

Expected: all tests pass.

- [ ] **Step 3: Build frontend**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build
```

Expected: build exits 0. The existing Vite chunk-size warning is acceptable.

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

Use the in-app browser:

1. Open `http://127.0.0.1:5173/inventory-ledger`.
2. Click `工具 -> 流水`.
3. Find a `生产台账` related `成品入库` / `生产余料入库` / `生产废料入库` row.
4. Confirm the `单据留档` column shows `已归档` plus `预览PDF` and `下载PDF`.
5. Click `预览PDF`.
6. Confirm the opened PDF is `生产入库结算单` and includes 成品、余料、废料明细 when the settlement action created multiple warehouse transactions.

- [ ] **Step 5: Browser verify post-save shortcut**

Use the in-app browser:

1. Open `http://127.0.0.1:5173/inventory-ledger`.
2. Click `工具 -> 生产台账入库`.
3. Select an open production ledger with safe test values.
4. Save.
5. Confirm the success area shows `预览结算单` and `下载PDF`.
6. Click `预览结算单`.
7. Confirm it opens the same `生产入库结算单` archive as the warehouse ledger row.

---

## Self-Review

- Spec coverage: The plan covers the approved design: a single `生产入库结算单` shared by the multiple warehouse流水 rows from one production ledger inbound action, warehouse ledger buttons, production ledger detail already existing, and post-save shortcut.
- Placeholder scan: No `TBD`, `TODO`, or vague “add appropriate handling” steps remain.
- Type consistency: The plan consistently uses `archive_status`, `archive_business_id`, `archive_document_type`, `archive_error_message`, and route key `production-inbound-settlement`.
- Scope control: This plan does not implement all warehouse branch PDFs. It only exposes production settlement archives already generated by the production main chain.
