ForgeFlow-ERP/docs/superpowers/plans/2026-06-11-production-inbound-archive-entrypoints.md
2026-06-12 16:00:56 +08:00

853 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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