62 KiB
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 updatesProductionBatchLedger. - 生产台账入口 · 生产工单入库: implemented by
生产入库结算单, generated after/production/production-ledger-inboundssaves. - 成品库 · 生产入库: implemented by
生产入库结算单, generated from the branch production finished inbound mainInventoryTxn. - 原材料库 · 生产余料入库: implemented by
生产入库结算单, generated from the branch production surplus inbound mainInventoryTxn. - 废料库 · 生产废料入库: implemented by
生产入库结算单, generated from the branch production scrap inbound mainInventoryTxn.
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-outandproduction-inbound-settlement. - Update Chinese validation error text.
- Add route keys
- 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
StockLotReadso warehouse branch entry responses can carry the archive result.
- Add archive fields to
- Modify:
backend/app/api/routes/production.py- Generate
生产入库结算单after/production/production-ledger-inboundssaves. - Generate
生产入库结算单after/production/work-order-inboundssaves through the existing service response. - Add latest archive joins to
/production/production-ledger.
- Generate
- 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.
- Generate
- 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.
- Generate
- 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.
- Import and render
- 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
DocumentArchiveActionsif 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.jsif 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.
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:
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:
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:
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:
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:
from app.models.operations import (
InventoryTxn,
ProductionBatchLedger,
ProductionBatchLedgerTxn,
PurchaseOrder,
PurchaseOrderItem,
PurchaseReceipt,
PurchaseReceiptItem,
StockLot,
Supplier,
)
Add constants below DOCUMENT_TYPE_QUALITY_INSPECTION:
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.
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:
from sqlalchemy.orm import Session, aliased
- Step 3: Add
collect_production_material_out_archive_context
Add:
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:
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:
from sqlalchemy import case, func, or_, select
- Step 5: Register new collectors
Modify collect_archive_context:
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:
DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
Add to DOCUMENT_TYPE_KEY_MAP:
"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:
raise HTTPException(status_code=400, detail="单据类型只能选择销售订单、采购订单、到货入库单、质量校验单、生产领料出库单或生产入库结算单")
- Step 7: Run archive tests
Run:
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:
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:
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:
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:
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:
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:
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:
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:
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:
**_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:
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:
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:
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:
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:
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:
production_material_out_archive_ledger_id = None
When biz_type == "PRODUCTION_OUT" creates or updates a ProductionBatchLedger, set:
production_material_out_archive_ledger_id = ledger.id
After the existing db.commit(), before returning StockLotRead, generate:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
material_out_archives = _latest_archive_map(db, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, ledger_ids)
After txn_rows is loaded, collect settlement business IDs:
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:
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:
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
ProductionBatchLedgerReadconstruction
When appending ProductionBatchLedgerRead, get:
material_archive = material_out_archives.get(int(ledger.id))
Add fields:
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:
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:
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:
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:
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:
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:
import DocumentArchiveActions from "../components/documentForms/DocumentArchiveActions.vue";
import { downloadResource, fetchResource, openResource, postResource } from "../services/api";
Replace the current import:
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:
<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:
<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:
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:
.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:
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:
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:
const result = await postResource("/inventory/inbound", payload);
feedbackMessage.value = `${activeOperation.value?.label || "入库"}已保存${formatArchiveFeedback(result)}`;
and:
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:
node --test frontend/src/views/ProductionLedgerView.test.js
Expected: PASS.
- Step 10: Run frontend build
Run:
cd frontend
npm run build
Expected: PASS. A Vite chunk-size warning is acceptable.
- Step 11: Commit frontend archive UI
Run:
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:
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:
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:
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:
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.
- Production material issue is covered by
- 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-outandproduction-inbound-settlement. - Document types are
生产领料出库单and生产入库结算单. - Archive response fields are consistently named
archive_status,archive_business_id,archive_document_type, andarchive_error_message.
- Route keys are