853 lines
29 KiB
Markdown
853 lines
29 KiB
Markdown
# 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.
|