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

1667 lines
62 KiB
Markdown
Raw Permalink 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 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`.