# Production Work Order Ledger 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 a production work-order ledger under production execution and make finished inbound, production scrap inbound, and production surplus inbound use production work orders as their controlling business object. **Architecture:** Reuse the existing `pp_work_order` production work-order tables created by raw-material production outbound. Add a focused backend service that aggregates work-order facts and calculates inbound limits with a 15% tolerance, then connect the warehouse inbound APIs and ERP warehouse drawers to those rules. The ledger page reads derived data from existing fact tables instead of storing a separate disconnected ledger table. **Tech Stack:** FastAPI, SQLAlchemy ORM, Pydantic schemas, existing warehouse and production route modules, Vue 3 SFCs, existing `FormDrawer`, `DrawerDataTable`, `StockLotTagSelect`, `TableControls`, and Vite build/static tests. --- ## File Structure - Create: `backend/app/services/production_work_order_ledger.py` - Central production ledger aggregation and limit calculations. - Exposes `get_work_order_ledger_rows`, `get_work_order_limit_snapshot`, and `validate_inbound_with_tolerance`. - Modify: `backend/app/schemas/operations.py` - Add `WorkOrderLedgerMaterialRead`, `WorkOrderLedgerOperationRead`, `WorkOrderLedgerLimitRead`, and `WorkOrderLedgerRead`. - Add the derived fields needed by the warehouse drawers and the ledger page. - Modify: `backend/app/api/routes/production.py` - Add `GET /production/work-order-ledger`. - Use the new limit service in `POST /production/completion-receipts`. - Apply the 15% tolerance rule to finished production inbound. - Modify: `backend/app/api/routes/inventory.py` - Make `PRODUCTION_SURPLUS` require `source_doc_type="WORK_ORDER"` and `source_doc_id=`. - Make `PRODUCTION_SCRAP_IN` require `source_doc_type="WORK_ORDER"` and `source_doc_id=`. - Validate surplus and scrap inbound against the work-order limit service. - For production surplus inbound, keep returning material to the selected original source stock lot and update `pp_work_order_material.returned_weight_kg`. - For production scrap inbound, create a scrap lot in scrap warehouse and link its stock lot and inventory transaction to the work order. - Modify: `frontend/src/router/index.js` - Import `WorkOrderManagementView`. - Add route `/work-order-ledger` with permission `MENU_WORK_ORDER`. - Add `MENU_WORK_ORDER` to production redirect and permission ordering. - Modify: `frontend/src/App.vue` - Add the production child menu `工单台账`. - Add `MENU_WORK_ORDER` to the production stage visibility. - Modify: `frontend/src/views/WorkOrderManagementView.vue` - Convert the old production-out list into the new `工单台账` page. - Remove the production-out create drawer from this page; production outbound remains in raw-material warehouse. - Show ledger fields, operation summaries, issued lots, inbound limits, and linked inbound totals. - Modify: `frontend/src/views/InventoryLedgerView.vue` - Finished warehouse `生产入库`: first select production work order, display final-operation good quantity, already received quantity, calculated max, and 15% tolerance max. - Scrap warehouse `生产废料入库`: first select production work order, display scrap formula inputs and calculated max/tolerance max. - Raw-material warehouse `生产余料入库`: first select production work order, restrict source stock lots to the work order issued lots, display used issued weight, returned weight, max surplus, and tolerance max. - Send `source_doc_type: "WORK_ORDER"` and `source_doc_id: work_order_id` for production surplus and production scrap inbound. - Test: `backend/tests/test_production_work_order_ledger.py` - Backend service and API tests for ledger aggregation, finished inbound tolerance, scrap inbound tolerance, and surplus return linkage. - Test: `frontend/scripts/test-production-work-order-ledger.mjs` - Static frontend test for route/menu wiring and warehouse drawer payloads. --- ### Task 1: Add Backend Ledger Tests First **Files:** - Create: `backend/tests/test_production_work_order_ledger.py` - [ ] **Step 1: Create the test file with fixtures and failing tests** Create `backend/tests/test_production_work_order_ledger.py` with this structure: ```python from __future__ import annotations import unittest from datetime import datetime from decimal import Decimal from types import SimpleNamespace from fastapi import HTTPException from sqlalchemy import BigInteger, create_engine, select from sqlalchemy.ext.compiler import compiles from sqlalchemy.orm import Session, sessionmaker @compiles(BigInteger, "sqlite") def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: _ = type_, compiler, kw return "INTEGER" 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.master_data import Bom, BomItem, Item, StockBalance, Warehouse # noqa: E402 from app.models.operations import ( # noqa: E402 CompletionReceipt, CompletionReceiptItem, InventoryTxn, Process, ProcessRoute, ProcessRouteOperation, StockLot, WorkCenter, WorkOrder, WorkOrderMaterial, WorkOrderMaterialIssue, WorkOrderOperation, ) from app.models.org import Department # noqa: E402 from app.api.routes.inventory import create_warehouse_inbound # noqa: E402 from app.api.routes.production import create_completion_receipt # noqa: E402 from app.schemas.operations import ( # noqa: E402 CompletionReceiptCreate, CompletionReceiptItemCreate, WarehouseInboundCreate, ) from app.services.production_work_order_ledger import get_work_order_limit_snapshot, get_work_order_ledger_rows # noqa: E402 class Context: user = SimpleNamespace(id=1) class ProductionWorkOrderLedgerTest(unittest.TestCase): def setUp(self) -> None: engine = create_engine("sqlite+pysqlite:///:memory:", future=True) raw_connection = engine.raw_connection() raw_connection.create_function("greatest", -1, lambda *values: max(value for value in values if value is not None)) raw_connection.close() Base.metadata.create_all(engine) self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) self.db: Session = self.SessionLocal() self.raw_item = Item(id=1, item_code="RM-001", item_name="冷轧钢板", item_type="RAW_MATERIAL", unit_weight_kg=Decimal("1"), status="ACTIVE") self.product = Item(id=2, item_code="FG-001", item_name="钢制碗", item_type="FINISHED_GOOD", unit_weight_kg=Decimal("0.50"), status="ACTIVE") self.raw_wh = Warehouse(id=1, warehouse_code="WH-RAW", warehouse_name="原材料库", warehouse_type="RAW", status="ACTIVE") self.finished_wh = Warehouse(id=2, warehouse_code="WH-FG", warehouse_name="成品库", warehouse_type="FINISHED", status="ACTIVE") self.scrap_wh = Warehouse(id=3, warehouse_code="WH-SCRAP", warehouse_name="废料库", warehouse_type="SCRAP", status="ACTIVE") self.db.add_all([self.raw_item, self.product, self.raw_wh, self.finished_wh, self.scrap_wh]) self.db.flush() self.bom = Bom(id=1, bom_code="BOM-001", product_item_id=2, version_no="V1.0", is_default=1, status="ACTIVE") self.bom_item = BomItem( id=1, bom_id=1, seq_no=1, material_item_id=1, usage_type="WEIGHT", net_weight_per_piece_kg=Decimal("0.50"), gross_weight_per_piece_kg=Decimal("0.60"), loss_rate=Decimal("0.1667"), status="ACTIVE", ) self.department = Department(id=1, dept_code="PROD", dept_name="生产部", dept_type="PRODUCTION", status="ACTIVE") self.process1 = Process(id=1, process_code="P1", process_name="开平", process_type="INTERNAL", status="ACTIVE") self.process2 = Process(id=2, process_code="P2", process_name="冲压", process_type="INTERNAL", status="ACTIVE") self.work_center = WorkCenter(id=1, center_code="WC-001", center_name="车间", dept_id=1, center_type="INTERNAL", capacity_hours_per_day=Decimal("8"), status="ACTIVE") self.route = ProcessRoute(id=1, route_code="RT-001", route_name="钢制碗工艺", product_item_id=2, version_no="V1.0", is_default=1, status="ACTIVE") self.route_op1 = ProcessRouteOperation(id=1, route_id=1, seq_no=1, process_id=1, operation_name="1序", work_center_id=1, std_cycle_seconds=Decimal("10"), status="ACTIVE") self.route_op2 = ProcessRouteOperation(id=2, route_id=1, seq_no=2, process_id=2, operation_name="2序", work_center_id=1, std_cycle_seconds=Decimal("10"), status="ACTIVE") self.db.add_all([self.bom, self.bom_item, self.department, self.process1, self.process2, self.work_center, self.route, self.route_op1, self.route_op2]) self.source_lot = StockLot( id=1, lot_no="JH_冷轧钢板00001_0001", lot_role="RAW_MATERIAL", item_id=1, warehouse_id=1, source_doc_type="PURCHASE_RECEIPT", source_doc_id=10, inbound_qty=Decimal("0"), inbound_weight_kg=Decimal("500"), remaining_qty=Decimal("0"), remaining_weight_kg=Decimal("300"), locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=Decimal("4"), quality_status="PASS", status="AVAILABLE", ) self.balance = StockBalance( id=1, item_id=1, warehouse_id=1, qty_on_hand=Decimal("0"), weight_on_hand_kg=Decimal("300"), qty_available=Decimal("0"), weight_available_kg=Decimal("300"), qty_allocated=Decimal("0"), weight_allocated_kg=Decimal("0"), avg_unit_cost=Decimal("4"), ) self.work_order = WorkOrder( id=1, work_order_no="WO-001", work_order_type="NORMAL", product_item_id=2, route_id=1, bom_id=1, planned_qty=Decimal("333"), released_qty=Decimal("333"), finished_qty=Decimal("0"), scrap_qty=Decimal("0"), status="RELEASED", ) self.material_row = WorkOrderMaterial( id=1, work_order_id=1, bom_item_id=1, material_item_id=1, required_qty=Decimal("0"), required_weight_kg=Decimal("200"), issued_qty=Decimal("0"), issued_weight_kg=Decimal("200"), returned_qty=Decimal("0"), returned_weight_kg=Decimal("0"), status="ISSUED", ) self.issue = WorkOrderMaterialIssue( id=1, work_order_material_id=1, work_order_id=1, material_item_id=1, source_lot_id=1, material_sub_batch_no="JH_冷轧钢板00001_0001", issued_qty=Decimal("0"), issued_weight_kg=Decimal("200"), unit_cost=Decimal("4"), amount=Decimal("800"), issue_time=datetime.now(), ) self.op1 = WorkOrderOperation(id=1, work_order_id=1, route_operation_id=1, seq_no=1, operation_name="1序", work_center_id=1, planned_qty=Decimal("333"), report_qty=Decimal("210"), good_qty=Decimal("206"), scrap_qty=Decimal("4"), status="RUNNING") self.op2 = WorkOrderOperation(id=2, work_order_id=1, route_operation_id=2, seq_no=2, operation_name="2序", work_center_id=1, planned_qty=Decimal("333"), report_qty=Decimal("200"), good_qty=Decimal("198"), scrap_qty=Decimal("2"), status="RUNNING") self.db.add_all([self.source_lot, self.balance, self.work_order, self.material_row, self.issue, self.op1, self.op2]) self.db.commit() def tearDown(self) -> None: self.db.close() def test_limit_snapshot_uses_max_report_qty_for_used_weight_and_sum_scrap_qty_for_scrap_weight(self) -> None: snapshot = get_work_order_limit_snapshot(self.db, 1) self.assertEqual(float(snapshot.issued_weight_kg), 200) self.assertEqual(float(snapshot.used_material_weight_kg), 126) self.assertEqual(float(snapshot.returned_material_weight_kg), 0) self.assertEqual(float(snapshot.max_surplus_inbound_weight_kg), 74) self.assertEqual(float(snapshot.final_operation_good_qty), 198) self.assertEqual(float(snapshot.finished_received_qty), 0) self.assertEqual(float(snapshot.max_finished_inbound_qty), 198) self.assertEqual(float(snapshot.total_operation_scrap_qty), 6) self.assertEqual(float(snapshot.max_scrap_inbound_weight_kg), 36.3) self.assertEqual(float(snapshot.tolerance_finished_inbound_qty), 227.7) self.assertEqual(float(snapshot.tolerance_scrap_inbound_weight_kg), 41.745) self.assertEqual(float(snapshot.tolerance_surplus_inbound_weight_kg), 85.1) def test_ledger_rows_include_material_operation_and_limit_details(self) -> None: rows = get_work_order_ledger_rows(self.db, limit=20) self.assertEqual(len(rows), 1) row = rows[0] self.assertEqual(row.work_order_no, "WO-001") self.assertEqual(row.product_name, "钢制碗") self.assertEqual(row.material_summary, "冷轧钢板") self.assertEqual(row.source_lot_summary, "JH_冷轧钢板00001_0001") self.assertEqual(float(row.planned_qty), 333) self.assertEqual(float(row.issued_weight_kg), 200) self.assertEqual(float(row.used_material_weight_kg), 126) self.assertEqual(float(row.returned_material_weight_kg), 0) self.assertEqual(float(row.finished_received_qty), 0) self.assertEqual(len(row.operations), 2) self.assertEqual(row.operations[0].operation_name, "1序") self.assertEqual(float(row.operations[0].good_qty), 206) self.assertEqual(float(row.operations[0].scrap_qty), 4) self.assertEqual(float(row.limits.max_scrap_inbound_weight_kg), 36.3) def test_finished_inbound_accepts_15_percent_tolerance_and_rejects_above_tolerance(self) -> None: with self.assertRaises(HTTPException) as cm: create_completion_receipt( CompletionReceiptCreate( work_order_id=1, warehouse_id=2, receiver_employee_id=None, items=[CompletionReceiptItemCreate(product_item_id=2, receipt_qty=227.701, receipt_weight_kg=0)], ), context=Context(), db=self.db, ) self.assertIn("超过允许入库上限", cm.exception.detail) accepted = create_completion_receipt( CompletionReceiptCreate( work_order_id=1, warehouse_id=2, receiver_employee_id=None, items=[CompletionReceiptItemCreate(product_item_id=2, receipt_qty=227.7, receipt_weight_kg=0)], ), context=Context(), db=self.db, ) self.assertEqual(float(accepted.total_receipt_qty), 227.7) def test_production_scrap_inbound_requires_work_order_and_respects_tolerance(self) -> None: row = create_warehouse_inbound( WarehouseInboundCreate( biz_type="PRODUCTION_SCRAP_IN", item_id=1, warehouse_id=3, inbound_weight_kg=41.745, source_doc_type="WORK_ORDER", source_doc_id=1, source_material_sub_batch_no="JH_冷轧钢板00001_0001", source_material_summary="WO-001生产废料", remark="生产废料入库", ), context=Context(), db=self.db, ) lot = self.db.get(StockLot, row.lot_id) txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == row.lot_id)) self.assertEqual(lot.source_doc_type, "WORK_ORDER") self.assertEqual(lot.source_doc_id, 1) self.assertEqual(txn.source_doc_type, "WORK_ORDER") self.assertEqual(txn.source_doc_id, 1) with self.assertRaises(HTTPException) as cm: create_warehouse_inbound( WarehouseInboundCreate( biz_type="PRODUCTION_SCRAP_IN", item_id=1, warehouse_id=3, inbound_weight_kg=0.001, source_doc_type="WORK_ORDER", source_doc_id=1, remark="再次超额入库", ), context=Context(), db=self.db, ) self.assertIn("超过允许入库上限", cm.exception.detail) def test_production_surplus_returns_to_original_lot_and_updates_work_order_returned_weight(self) -> None: row = create_warehouse_inbound( WarehouseInboundCreate( biz_type="PRODUCTION_SURPLUS", item_id=1, warehouse_id=1, source_lot_id=1, inbound_weight_kg=85.1, source_doc_type="WORK_ORDER", source_doc_id=1, remark="生产余料归还", ), context=Context(), db=self.db, ) self.db.refresh(self.source_lot) self.db.refresh(self.material_row) txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "PRODUCTION_SURPLUS_IN")) self.assertEqual(row.lot_id, 1) self.assertEqual(float(self.source_lot.remaining_weight_kg), 385.1) self.assertEqual(float(self.material_row.returned_weight_kg), 85.1) self.assertEqual(txn.source_doc_type, "WORK_ORDER") self.assertEqual(txn.source_doc_id, 1) self.assertEqual(txn.source_line_id, 1) ``` - [ ] **Step 2: Run the new tests and verify they fail before implementation** Run: ```bash cd /Volumes/ForgeFlow-ERP/backend PYTHONPATH=. .venv/bin/pytest tests/test_production_work_order_ledger.py -v ``` Expected: FAIL because `app.services.production_work_order_ledger` does not exist. --- ### Task 2: Add Backend Ledger Schemas and Service **Files:** - Modify: `backend/app/schemas/operations.py` - Create: `backend/app/services/production_work_order_ledger.py` - [ ] **Step 1: Add ledger response schemas** Add these schemas after `WorkOrderOperationRead` in `backend/app/schemas/operations.py`: ```python class WorkOrderLedgerMaterialRead(BaseModel): material_item_id: int material_code: str material_name: str source_lot_id: int source_lot_no: str issued_weight_kg: float returned_weight_kg: float = 0 unit_cost: float = 0 amount: float = 0 class WorkOrderLedgerOperationRead(BaseModel): work_order_operation_id: int seq_no: int operation_name: str report_qty: float good_qty: float scrap_qty: float status: str class WorkOrderLedgerLimitRead(BaseModel): gross_weight_per_piece_kg: float net_weight_per_piece_kg: float issued_weight_kg: float used_material_weight_kg: float returned_material_weight_kg: float final_operation_good_qty: float finished_received_qty: float total_operation_scrap_qty: float scrap_inbound_weight_kg: float max_finished_inbound_qty: float max_scrap_inbound_weight_kg: float max_surplus_inbound_weight_kg: float tolerance_finished_inbound_qty: float tolerance_scrap_inbound_weight_kg: float tolerance_surplus_inbound_weight_kg: float class WorkOrderLedgerRead(BaseModel): work_order_id: int work_order_no: str product_item_id: int product_code: str product_name: str planned_qty: float status: str material_summary: str source_lot_summary: str issued_weight_kg: float used_material_weight_kg: float returned_material_weight_kg: float finished_received_qty: float max_finished_inbound_qty: float max_scrap_inbound_weight_kg: float max_surplus_inbound_weight_kg: float limits: WorkOrderLedgerLimitRead materials: list[WorkOrderLedgerMaterialRead] operations: list[WorkOrderLedgerOperationRead] ``` - [ ] **Step 2: Create the service with exact calculations** Create `backend/app/services/production_work_order_ledger.py`: ```python from __future__ import annotations from decimal import Decimal from fastapi import HTTPException from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.orm import Session from app.models.master_data import BomItem, Item from app.models.operations import ( CompletionReceipt, CompletionReceiptItem, InventoryTxn, StockLot, WorkOrder, WorkOrderMaterial, WorkOrderMaterialIssue, WorkOrderOperation, ) from app.services.operations import to_decimal INBOUND_TOLERANCE_MULTIPLIER = Decimal("1.15") class WorkOrderLimitSnapshot(BaseModel): work_order_id: int gross_weight_per_piece_kg: Decimal net_weight_per_piece_kg: Decimal issued_weight_kg: Decimal used_material_weight_kg: Decimal returned_material_weight_kg: Decimal final_operation_good_qty: Decimal finished_received_qty: Decimal total_operation_scrap_qty: Decimal scrap_inbound_weight_kg: Decimal max_finished_inbound_qty: Decimal max_scrap_inbound_weight_kg: Decimal max_surplus_inbound_weight_kg: Decimal tolerance_finished_inbound_qty: Decimal tolerance_scrap_inbound_weight_kg: Decimal tolerance_surplus_inbound_weight_kg: Decimal class WorkOrderLedgerMaterialRow(BaseModel): material_item_id: int material_code: str material_name: str source_lot_id: int source_lot_no: str issued_weight_kg: Decimal returned_weight_kg: Decimal = Decimal("0") unit_cost: Decimal = Decimal("0") amount: Decimal = Decimal("0") class WorkOrderLedgerOperationRow(BaseModel): work_order_operation_id: int seq_no: int operation_name: str report_qty: Decimal good_qty: Decimal scrap_qty: Decimal status: str class WorkOrderLedgerRow(BaseModel): work_order_id: int work_order_no: str product_item_id: int product_code: str product_name: str planned_qty: Decimal status: str material_summary: str source_lot_summary: str issued_weight_kg: Decimal used_material_weight_kg: Decimal returned_material_weight_kg: Decimal finished_received_qty: Decimal max_finished_inbound_qty: Decimal max_scrap_inbound_weight_kg: Decimal max_surplus_inbound_weight_kg: Decimal limits: WorkOrderLimitSnapshot materials: list[WorkOrderLedgerMaterialRow] operations: list[WorkOrderLedgerOperationRow] def _non_negative(value: Decimal) -> Decimal: return max(value, Decimal("0")) def _round_decimal(value: Decimal, places: str = "0.001") -> Decimal: return to_decimal(value, places) def _get_work_order(db: Session, work_order_id: int) -> WorkOrder: work_order = db.get(WorkOrder, work_order_id) if not work_order: raise HTTPException(status_code=404, detail="生产工单不存在") return work_order def _get_weight_profile(db: Session, work_order: WorkOrder) -> tuple[Decimal, Decimal]: bom_item = db.scalar( select(BomItem) .where(BomItem.bom_id == work_order.bom_id) .order_by(BomItem.seq_no) .limit(1) ) product = db.get(Item, work_order.product_item_id) gross = to_decimal(bom_item.gross_weight_per_piece_kg if bom_item else 0) net = to_decimal(bom_item.net_weight_per_piece_kg if bom_item else 0) if net <= 0 and product: net = to_decimal(product.unit_weight_kg) if gross <= 0: gross = net if gross <= 0: raise HTTPException(status_code=400, detail="产品需规清单缺少产品毛重,无法计算工单入库上限") if net < 0: net = Decimal("0") if gross < net: gross = net return gross, net def _get_operations(db: Session, work_order_id: int) -> list[WorkOrderOperation]: return db.scalars( select(WorkOrderOperation) .where(WorkOrderOperation.work_order_id == work_order_id) .order_by(WorkOrderOperation.seq_no, WorkOrderOperation.id) ).all() def _get_finished_received_qty(db: Session, work_order_id: int) -> Decimal: value = db.scalar( select(func.coalesce(func.sum(CompletionReceiptItem.receipt_qty), 0)) .join(CompletionReceipt, CompletionReceipt.id == CompletionReceiptItem.completion_receipt_id) .where(CompletionReceipt.work_order_id == work_order_id) ) return to_decimal(value) def _get_scrap_inbound_weight(db: Session, work_order_id: int) -> Decimal: value = db.scalar( select(func.coalesce(func.sum(InventoryTxn.weight_change_kg), 0)) .where( InventoryTxn.txn_type == "PRODUCTION_SCRAP_IN", InventoryTxn.source_doc_type == "WORK_ORDER", InventoryTxn.source_doc_id == work_order_id, ) ) return _non_negative(to_decimal(value)) def get_work_order_limit_snapshot(db: Session, work_order_id: int) -> WorkOrderLimitSnapshot: work_order = _get_work_order(db, work_order_id) gross_weight, net_weight = _get_weight_profile(db, work_order) operations = _get_operations(db, work_order_id) issued_weight = to_decimal( db.scalar( select(func.coalesce(func.sum(WorkOrderMaterialIssue.issued_weight_kg), 0)) .where(WorkOrderMaterialIssue.work_order_id == work_order_id) ) ) returned_weight = to_decimal( db.scalar( select(func.coalesce(func.sum(WorkOrderMaterial.returned_weight_kg), 0)) .where(WorkOrderMaterial.work_order_id == work_order_id) ) ) max_report_qty = max((to_decimal(operation.report_qty) for operation in operations), default=Decimal("0")) used_material_weight = _round_decimal(max_report_qty * gross_weight) final_good_qty = to_decimal(operations[-1].good_qty) if operations else Decimal("0") finished_received_qty = _get_finished_received_qty(db, work_order_id) total_scrap_qty = sum((to_decimal(operation.scrap_qty) for operation in operations), Decimal("0")) scrap_inbound_weight = _get_scrap_inbound_weight(db, work_order_id) edge_scrap_weight = _non_negative((to_decimal(work_order.planned_qty) - total_scrap_qty) * _non_negative(gross_weight - net_weight)) rejected_product_scrap_weight = total_scrap_qty * gross_weight max_finished_inbound_qty = _non_negative(final_good_qty - finished_received_qty) max_scrap_inbound_weight = _non_negative(edge_scrap_weight + rejected_product_scrap_weight - scrap_inbound_weight) max_surplus_inbound_weight = _non_negative(issued_weight - used_material_weight - returned_weight) return WorkOrderLimitSnapshot( work_order_id=work_order_id, gross_weight_per_piece_kg=_round_decimal(gross_weight), net_weight_per_piece_kg=_round_decimal(net_weight), issued_weight_kg=_round_decimal(issued_weight), used_material_weight_kg=_round_decimal(used_material_weight), returned_material_weight_kg=_round_decimal(returned_weight), final_operation_good_qty=_round_decimal(final_good_qty), finished_received_qty=_round_decimal(finished_received_qty), total_operation_scrap_qty=_round_decimal(total_scrap_qty), scrap_inbound_weight_kg=_round_decimal(scrap_inbound_weight), max_finished_inbound_qty=_round_decimal(max_finished_inbound_qty), max_scrap_inbound_weight_kg=_round_decimal(max_scrap_inbound_weight), max_surplus_inbound_weight_kg=_round_decimal(max_surplus_inbound_weight), tolerance_finished_inbound_qty=_round_decimal(max_finished_inbound_qty * INBOUND_TOLERANCE_MULTIPLIER), tolerance_scrap_inbound_weight_kg=_round_decimal(max_scrap_inbound_weight * INBOUND_TOLERANCE_MULTIPLIER), tolerance_surplus_inbound_weight_kg=_round_decimal(max_surplus_inbound_weight * INBOUND_TOLERANCE_MULTIPLIER), ) def validate_inbound_with_tolerance(label: str, requested: Decimal, calculated_max: Decimal) -> None: if requested <= 0: raise HTTPException(status_code=400, detail=f"{label}必须大于0") allowed = _round_decimal(_non_negative(calculated_max) * INBOUND_TOLERANCE_MULTIPLIER) if requested > allowed: raise HTTPException(status_code=400, detail=f"{label}超过允许入库上限 {allowed}") def validate_work_order_source_lot(db: Session, work_order_id: int, source_lot_id: int, item_id: int) -> WorkOrderMaterial: row = db.execute( select(WorkOrderMaterial) .join(WorkOrderMaterialIssue, WorkOrderMaterialIssue.work_order_material_id == WorkOrderMaterial.id) .where( WorkOrderMaterial.work_order_id == work_order_id, WorkOrderMaterial.material_item_id == item_id, WorkOrderMaterialIssue.source_lot_id == source_lot_id, ) .limit(1) ).scalar_one_or_none() if not row: raise HTTPException(status_code=400, detail="来源库存批次号不属于该生产工单") return row def get_work_order_ledger_rows(db: Session, limit: int = 200, work_order_id: int | None = None) -> list[WorkOrderLedgerRow]: stmt = select(WorkOrder).order_by(WorkOrder.id.desc()).limit(limit) if work_order_id: stmt = stmt.where(WorkOrder.id == work_order_id) work_orders = db.scalars(stmt).all() rows: list[WorkOrderLedgerRow] = [] for work_order in work_orders: product = db.get(Item, work_order.product_item_id) limits = get_work_order_limit_snapshot(db, work_order.id) issue_rows = db.execute( select( WorkOrderMaterialIssue.material_item_id, Item.item_code, Item.item_name, WorkOrderMaterialIssue.source_lot_id, StockLot.lot_no, WorkOrderMaterialIssue.issued_weight_kg, WorkOrderMaterialIssue.unit_cost, WorkOrderMaterialIssue.amount, ) .join(Item, Item.id == WorkOrderMaterialIssue.material_item_id) .join(StockLot, StockLot.id == WorkOrderMaterialIssue.source_lot_id) .where(WorkOrderMaterialIssue.work_order_id == work_order.id) .order_by(WorkOrderMaterialIssue.id) ).mappings().all() material_rows = [ WorkOrderLedgerMaterialRow( material_item_id=int(issue["material_item_id"]), material_code=issue["item_code"], material_name=issue["item_name"], source_lot_id=int(issue["source_lot_id"]), source_lot_no=issue["lot_no"], issued_weight_kg=_round_decimal(to_decimal(issue["issued_weight_kg"])), returned_weight_kg=Decimal("0"), unit_cost=_round_decimal(to_decimal(issue["unit_cost"]), "0.0001"), amount=_round_decimal(to_decimal(issue["amount"]), "0.01"), ) for issue in issue_rows ] operation_rows = [ WorkOrderLedgerOperationRow( work_order_operation_id=int(operation.id), seq_no=int(operation.seq_no), operation_name=operation.operation_name, report_qty=_round_decimal(to_decimal(operation.report_qty)), good_qty=_round_decimal(to_decimal(operation.good_qty)), scrap_qty=_round_decimal(to_decimal(operation.scrap_qty)), status=operation.status, ) for operation in _get_operations(db, work_order.id) ] material_names = sorted({row.material_name for row in material_rows}) source_lot_nos = [row.source_lot_no for row in material_rows] rows.append( WorkOrderLedgerRow( work_order_id=int(work_order.id), work_order_no=work_order.work_order_no, product_item_id=int(work_order.product_item_id), product_code=product.item_code if product else "", product_name=product.item_name if product else "", planned_qty=_round_decimal(to_decimal(work_order.planned_qty)), status=work_order.status, material_summary="、".join(material_names) if material_names else "-", source_lot_summary="、".join(source_lot_nos) if source_lot_nos else "-", issued_weight_kg=limits.issued_weight_kg, used_material_weight_kg=limits.used_material_weight_kg, returned_material_weight_kg=limits.returned_material_weight_kg, finished_received_qty=limits.finished_received_qty, max_finished_inbound_qty=limits.max_finished_inbound_qty, max_scrap_inbound_weight_kg=limits.max_scrap_inbound_weight_kg, max_surplus_inbound_weight_kg=limits.max_surplus_inbound_weight_kg, limits=limits, materials=material_rows, operations=operation_rows, ) ) return rows ``` - [ ] **Step 3: Run backend tests and verify the new service calculations pass except route validations still fail** Run: ```bash cd /Volumes/ForgeFlow-ERP/backend PYTHONPATH=. .venv/bin/pytest tests/test_production_work_order_ledger.py::ProductionWorkOrderLedgerTest::test_limit_snapshot_uses_max_report_qty_for_used_weight_and_sum_scrap_qty_for_scrap_weight tests/test_production_work_order_ledger.py::ProductionWorkOrderLedgerTest::test_ledger_rows_include_material_operation_and_limit_details -v ``` Expected: PASS for the two service-level tests. --- ### Task 3: Wire Production Ledger API and Finished Inbound Tolerance **Files:** - Modify: `backend/app/api/routes/production.py` - [ ] **Step 1: Import the new schema and service functions** Add `WorkOrderLedgerRead` to the `app.schemas.operations` import list. Add this service import near the existing `app.services.operations` import: ```python from app.services.production_work_order_ledger import ( get_work_order_ledger_rows, get_work_order_limit_snapshot, validate_inbound_with_tolerance, ) ``` - [ ] **Step 2: Add the ledger endpoint** Add this route before `@router.get("/work-orders"`: ```python @router.get("/work-order-ledger", response_model=list[WorkOrderLedgerRead]) def list_work_order_ledger( work_order_id: int | None = Query(default=None), limit: int = Query(default=200, ge=1, le=500), db: Session = Depends(get_db), ) -> list[WorkOrderLedgerRead]: if work_order_id and sync_work_order_reference_qty(db, work_order_id): db.commit() rows = get_work_order_ledger_rows(db, limit=limit, work_order_id=work_order_id) return [WorkOrderLedgerRead.model_validate(row.model_dump()) for row in rows] ``` - [ ] **Step 3: Replace finished inbound max calculation with the shared limit snapshot** In `create_completion_receipt`, replace: ```python work_order_snapshot = db.execute(get_work_orders_query(limit=1).where(WorkOrder.id == payload.work_order_id)).mappings().first() if not work_order_snapshot: raise HTTPException(status_code=404, detail="工单不存在") max_receivable_qty = to_decimal(work_order_snapshot["max_receivable_qty"]) if max_receivable_qty <= 0: raise HTTPException(status_code=400, detail="该工单当前没有可入库的完工数量") ``` with: ```python limit_snapshot = get_work_order_limit_snapshot(db, payload.work_order_id) max_receivable_qty = to_decimal(limit_snapshot.max_finished_inbound_qty) if max_receivable_qty <= 0: raise HTTPException(status_code=400, detail="该工单当前没有可入库的完工数量") ``` Replace the per-line check: ```python if receipt_qty > remaining_receivable_qty: raise HTTPException(status_code=400, detail=f"入库数量不能超过当前最大可入库数量 {remaining_receivable_qty}") ``` with: ```python validate_inbound_with_tolerance("成品入库数量", receipt_qty, remaining_receivable_qty) ``` Keep: ```python remaining_receivable_qty -= receipt_qty ``` so multiple lines in one receipt still consume the same calculated allowance. - [ ] **Step 4: Run focused production tests** Run: ```bash cd /Volumes/ForgeFlow-ERP/backend PYTHONPATH=. .venv/bin/pytest tests/test_production_work_order_ledger.py::ProductionWorkOrderLedgerTest::test_finished_inbound_accepts_15_percent_tolerance_and_rejects_above_tolerance tests/test_selected_stock_lot_production_issue.py -v ``` Expected: PASS. --- ### Task 4: Enforce Work-Order-Controlled Scrap and Surplus Inbound **Files:** - Modify: `backend/app/api/routes/inventory.py` - [ ] **Step 1: Import ledger validation helpers** Add this import near the other service imports: ```python from app.services.production_work_order_ledger import ( get_work_order_limit_snapshot, validate_inbound_with_tolerance, validate_work_order_source_lot, ) ``` - [ ] **Step 2: Add a helper that validates production inbound work-order payloads** Add this helper above `create_warehouse_inbound`: ```python def _get_required_production_work_order_id(payload: WarehouseInboundCreate, biz_type: str) -> int: if biz_type not in {"PRODUCTION_SURPLUS", "PRODUCTION_SCRAP_IN"}: return 0 if str(payload.source_doc_type or "").upper() != "WORK_ORDER" or not payload.source_doc_id: raise HTTPException(status_code=400, detail=f"{_inventory_biz_type_label(biz_type)}必须先选择生产工单") return int(payload.source_doc_id) ``` - [ ] **Step 3: Calculate the work-order id before normal inbound validation** Inside `create_warehouse_inbound`, after `biz_type` is normalized, add: ```python production_work_order_id = _get_required_production_work_order_id(payload, biz_type) ``` - [ ] **Step 4: Validate production surplus inbound and update work-order returned weight** Inside the `if biz_type in _SOURCE_LOT_RETURN_INBOUND_BIZ_TYPES:` branch, after source lot validation and before increasing `source_lot.remaining_weight_kg`, add: ```python work_order_material_row = None if biz_type == "PRODUCTION_SURPLUS": limit_snapshot = get_work_order_limit_snapshot(db, production_work_order_id) validate_inbound_with_tolerance("生产余料入库重量", inbound_weight, to_decimal(limit_snapshot.max_surplus_inbound_weight_kg)) work_order_material_row = validate_work_order_source_lot( db, production_work_order_id, int(source_lot.id), int(payload.item_id), ) ``` After `source_lot.updated_at = datetime.now()`, add: ```python if work_order_material_row is not None: work_order_material_row.returned_weight_kg = to_decimal(work_order_material_row.returned_weight_kg) + inbound_weight work_order_material_row.updated_at = datetime.now() db.add(work_order_material_row) ``` Replace the `create_inventory_txn` source fields in the same branch: ```python source_doc_type=source_doc_type, source_doc_id=source_lot.id, source_line_id=None, ``` with: ```python source_doc_type="WORK_ORDER" if biz_type == "PRODUCTION_SURPLUS" else source_doc_type, source_doc_id=production_work_order_id if biz_type == "PRODUCTION_SURPLUS" else source_lot.id, source_line_id=source_lot.id if biz_type == "PRODUCTION_SURPLUS" else None, ``` - [ ] **Step 5: Validate production scrap inbound and link created lot/transaction to the work order** Before the generic lot creation section sets `operation_source_doc_type`, add: ```python if biz_type == "PRODUCTION_SCRAP_IN": limit_snapshot = get_work_order_limit_snapshot(db, production_work_order_id) validate_inbound_with_tolerance("生产废料入库重量", inbound_weight, to_decimal(limit_snapshot.max_scrap_inbound_weight_kg)) ``` After: ```python operation_source_doc_type = source_doc_type operation_source_doc_id = payload.source_doc_id or 0 operation_source_line_id = payload.source_line_id ``` add: ```python if biz_type == "PRODUCTION_SCRAP_IN": operation_source_doc_type = "WORK_ORDER" operation_source_doc_id = production_work_order_id operation_source_line_id = None ``` - [ ] **Step 6: Run inventory linkage tests** Run: ```bash cd /Volumes/ForgeFlow-ERP/backend PYTHONPATH=. .venv/bin/pytest tests/test_production_work_order_ledger.py::ProductionWorkOrderLedgerTest::test_production_scrap_inbound_requires_work_order_and_respects_tolerance tests/test_production_work_order_ledger.py::ProductionWorkOrderLedgerTest::test_production_surplus_returns_to_original_lot_and_updates_work_order_returned_weight tests/test_scrap_warehouse_flow.py::ScrapWarehouseFlowTest::test_production_surplus_returns_weight_to_selected_source_lot tests/test_scrap_warehouse_flow.py::ScrapWarehouseFlowTest::test_production_scrap_inbound_creates_scrap_lot_balance_and_txn -v ``` Expected: PASS after updating the two older scrap warehouse tests to pass `source_doc_type="WORK_ORDER"` and `source_doc_id=` only where they exercise production work-order inbound. If older tests intentionally exercise generic historical behavior, update their expected error to confirm production inbound now requires a work order. --- ### Task 5: Add Route and Menu for 工单台账 **Files:** - Modify: `frontend/src/router/index.js` - Modify: `frontend/src/App.vue` - [ ] **Step 1: Add router import** In `frontend/src/router/index.js`, add: ```js import WorkOrderManagementView from "../views/WorkOrderManagementView.vue"; ``` - [ ] **Step 2: Add work-order route to production permission ordering** Change `productionPermissionOrder` to: ```js const productionPermissionOrder = [ { routeName: "work-order-ledger", permission: "MENU_WORK_ORDER" }, { routeName: "operation-reports", permission: "MENU_OPERATION_REPORT" }, { routeName: "scrap-rework", permission: "MENU_SCRAP_REWORK" } ]; ``` Add `MENU_WORK_ORDER` to the `navPermissionOrder` production workspace permission list. - [ ] **Step 3: Add route** Add this route before `/operation-reports`: ```js { path: "/work-order-ledger", name: "work-order-ledger", component: WorkOrderManagementView, meta: { title: "工单台账", requiresAuth: true, permission: "MENU_WORK_ORDER" } }, ``` - [ ] **Step 4: Add App production menu child** In `frontend/src/App.vue`, add `MENU_WORK_ORDER` to the production stage `permissionsAny`. Add this child before `工序报工`: ```js { key: "work-order-ledger", label: "工单台账", to: { name: "work-order-ledger" }, routeNames: ["work-order-ledger"], permission: "MENU_WORK_ORDER", iconPaths: [ "M5 5H19V19H5V5Z", "M8 9H16", "M8 13H16", "M8 17H13", "M15 17L17 19L21 14" ] }, ``` - [ ] **Step 5: Run frontend route sanity build** Run: ```bash cd /Volumes/ForgeFlow-ERP/frontend npm run build ``` Expected: build succeeds. --- ### Task 6: Convert WorkOrderManagementView into 工单台账 **Files:** - Modify: `frontend/src/views/WorkOrderManagementView.vue` - [ ] **Step 1: Replace data loading with the new ledger endpoint** Replace the current `loadAll` requests for `/production/work-orders`, `/production/work-order-materials`, and `/production/work-order-operations` with: ```js async function loadAll() { const [ledgerRows] = await Promise.all([ fetchResource("/production/work-order-ledger?limit=500", []) ]); workOrders.value = Array.isArray(ledgerRows) ? ledgerRows : []; if (!selectedWorkOrderId.value && workOrders.value.length) { selectedWorkOrderId.value = workOrders.value[0].work_order_id; } } ``` - [ ] **Step 2: Remove production-out creation from the ledger page** Remove the `FormDrawer` titled `新增生产出库`, the `openCreateDrawer` handler, the `submitWorkOrder` handler, and the production-out form state. Keep detail drawer behavior for ledger rows. - [ ] **Step 3: Update table columns** Change the main table header to: ```vue 生产工单 产品 原材料 来源库存批次号 计划生产数 领料重量 已用领料重量 归还原料重量 已入库成品 状态 ``` Change each row body to: ```vue {{ workOrder.product_name }} {{ workOrder.material_summary || "-" }} {{ workOrder.source_lot_summary || "-" }} {{ formatQty(workOrder.planned_qty) }} {{ formatWeight(workOrder.issued_weight_kg) }} {{ formatWeight(workOrder.used_material_weight_kg) }} {{ formatWeight(workOrder.returned_material_weight_kg) }} {{ formatQty(workOrder.finished_received_qty) }} {{ formatStatusLabel(workOrder.status) }} ``` - [ ] **Step 4: Add ledger limit cards above the detail tables** Inside the detail drawer, add this summary grid before material and operation tables: ```vue
可入库成品{{ formatQty(activeWorkOrder?.limits?.max_finished_inbound_qty) }}
成品容差上限{{ formatQty(activeWorkOrder?.limits?.tolerance_finished_inbound_qty) }}
可入库废料{{ formatWeight(activeWorkOrder?.limits?.max_scrap_inbound_weight_kg) }}
废料容差上限{{ formatWeight(activeWorkOrder?.limits?.tolerance_scrap_inbound_weight_kg) }}
可归还余料{{ formatWeight(activeWorkOrder?.limits?.max_surplus_inbound_weight_kg) }}
余料容差上限{{ formatWeight(activeWorkOrder?.limits?.tolerance_surplus_inbound_weight_kg) }}
``` - [ ] **Step 5: Use nested arrays from the active ledger row** Replace material and operation computed rows with: ```js const filteredMaterials = computed(() => activeWorkOrder.value?.materials || []); const filteredOperations = computed(() => activeWorkOrder.value?.operations || []); ``` - [ ] **Step 6: Build check** Run: ```bash cd /Volumes/ForgeFlow-ERP/frontend npm run build ``` Expected: build succeeds. --- ### Task 7: Update Warehouse Drawers to Select Production Work Orders **Files:** - Modify: `frontend/src/views/InventoryLedgerView.vue` - [ ] **Step 1: Load ledger rows alongside existing work-order rows** Add state: ```js const workOrderLedgerRows = ref([]); const selectedGenericWorkOrderId = ref(""); ``` In `loadAll`, add: ```js fetchResource("/production/work-order-ledger?limit=500", []), ``` Assign it: ```js workOrderLedgerRows.value = Array.isArray(workOrderLedgerResult) ? workOrderLedgerResult : []; ``` - [ ] **Step 2: Add computed work-order option lists** Add: ```js const productionFinishedWorkOrderOptions = computed(() => workOrderLedgerRows.value.filter((row) => Number(row.max_finished_inbound_qty || 0) > 0) ); const productionScrapWorkOrderOptions = computed(() => workOrderLedgerRows.value.filter((row) => Number(row.max_scrap_inbound_weight_kg || 0) > 0) ); const productionSurplusWorkOrderOptions = computed(() => workOrderLedgerRows.value.filter((row) => Number(row.max_surplus_inbound_weight_kg || 0) > 0) ); const selectedGenericWorkOrder = computed(() => workOrderLedgerRows.value.find((row) => Number(row.work_order_id) === Number(selectedGenericWorkOrderId.value)) || null ); ``` - [ ] **Step 3: Rename finished production inbound selector** In the finished production inbound drawer, change label text from `库存批次号` to `生产工单`, and change the disabled option text from `请选择库存批次号` to `请选择生产工单`. Change option text to: ```vue {{ workOrder.work_order_no }} · {{ workOrder.product_name }} · 可入库 {{ formatQty(workOrder.max_receivable_qty) }} ``` Then switch the options to the new ledger row list and display tolerance: ```vue ``` Update `applyCompletionWorkOrder` to read from `workOrderLedgerRows`: ```js function applyCompletionWorkOrder() { const workOrder = workOrderLedgerRows.value.find((item) => item.work_order_id === Number(completionForm.work_order_id)); if (!workOrder) { return; } const product = products.value.find((item) => item.item_id === Number(workOrder.product_item_id)); completionForm.product_item_id = workOrder.product_item_id; completionForm.max_receivable_qty = Number(workOrder.limits?.max_finished_inbound_qty || 0); completionForm.tolerance_receivable_qty = Number(workOrder.limits?.tolerance_finished_inbound_qty || 0); completionForm.unit_weight_kg = Number(product?.unit_weight_kg || 0); completionForm.receipt_qty = Number(workOrder.limits?.max_finished_inbound_qty || 0); recalculateCompletionWeight(); } ``` Update `recalculateCompletionWeight` to clamp to `completionForm.tolerance_receivable_qty`, not the raw calculated max: ```js function recalculateCompletionWeight() { const toleranceQty = Number(completionForm.tolerance_receivable_qty || completionForm.max_receivable_qty || 0); if (Number(completionForm.receipt_qty || 0) > toleranceQty) { completionForm.receipt_qty = toleranceQty; } completionForm.receipt_weight_kg = Number((Number(completionForm.receipt_qty || 0) * Number(completionForm.unit_weight_kg || 0)).toFixed(3)); } ``` - [ ] **Step 4: Add production work-order selector to generic drawer for production scrap and surplus** Add this block near the top of the generic drawer form, before item selection: ```vue ``` Add: ```js function applyGenericProductionWorkOrder() { const workOrder = selectedGenericWorkOrder.value; if (!workOrder) { return; } if (activeOperation.value?.bizType === "PRODUCTION_SCRAP_IN") { genericForm.item_id = workOrder.materials?.[0]?.material_item_id || ""; genericForm.item_option_key = String(genericForm.item_id || ""); genericForm.weight_kg = Number(workOrder.limits?.max_scrap_inbound_weight_kg || 0); genericForm.source_material_sub_batch_no = workOrder.source_lot_summary || ""; genericForm.source_material_summary = `${workOrder.work_order_no}生产废料`; } if (activeOperation.value?.bizType === "PRODUCTION_SURPLUS") { genericForm.item_id = workOrder.materials?.[0]?.material_item_id || ""; genericForm.item_option_key = String(genericForm.item_id || ""); genericForm.weight_kg = Number(workOrder.limits?.max_surplus_inbound_weight_kg || 0); selectedGenericSourceLots.value = (workOrder.materials || []).map((material) => ({ lot_id: material.source_lot_id, item_id: material.material_item_id, item_code: material.material_code, item_name: material.material_name, lot_no: material.source_lot_no, remaining_weight_kg: 0, unit_cost: material.unit_cost, return_weight_kg: 0 })); } } ``` - [ ] **Step 5: Send work-order linkage for production scrap and surplus inbound** In the `isSourceLotReturnInbound` loop payload, add: ```js source_doc_type: activeOperation.value.bizType === "PRODUCTION_SURPLUS" ? "WORK_ORDER" : null, source_doc_id: activeOperation.value.bizType === "PRODUCTION_SURPLUS" ? Number(selectedGenericWorkOrderId.value) : null, ``` In the generic inbound payload for production scrap, set: ```js source_doc_type: activeOperation.value.bizType === "PRODUCTION_SCRAP_IN" ? "WORK_ORDER" : activeWarehouseType.value === "SEMI" ? "PRODUCT_ROUTE_OPERATION" : null, source_doc_id: activeOperation.value.bizType === "PRODUCTION_SCRAP_IN" ? Number(selectedGenericWorkOrderId.value) : activeWarehouseType.value === "SEMI" ? Number(selectedSourceDocId || 0) : null, ``` - [ ] **Step 6: Add frontend validation** At the top of `submitGenericOperation`, after logistics validation, add: ```js if ((activeOperation.value?.bizType === "PRODUCTION_SCRAP_IN" || activeOperation.value?.bizType === "PRODUCTION_SURPLUS") && !Number(selectedGenericWorkOrderId.value)) { errorMessage.value = `${activeOperation.value.label}必须先选择生产工单`; return; } ``` - [ ] **Step 7: Run frontend build** Run: ```bash cd /Volumes/ForgeFlow-ERP/frontend npm run build ``` Expected: build succeeds. --- ### Task 8: Add Frontend Static Regression Test **Files:** - Create: `frontend/scripts/test-production-work-order-ledger.mjs` - [ ] **Step 1: Create the static test** Create `frontend/scripts/test-production-work-order-ledger.mjs`: ```js import fs from "node:fs"; import path from "node:path"; import process from "node:process"; const root = process.cwd(); function read(relativePath) { return fs.readFileSync(path.join(root, relativePath), "utf8"); } function assertIncludes(content, needle, message) { if (!content.includes(needle)) { throw new Error(message); } } const router = read("src/router/index.js"); const app = read("src/App.vue"); const inventory = read("src/views/InventoryLedgerView.vue"); const ledger = read("src/views/WorkOrderManagementView.vue"); assertIncludes(router, "WorkOrderManagementView", "router must import WorkOrderManagementView"); assertIncludes(router, 'name: "work-order-ledger"', "router must define work-order-ledger route"); assertIncludes(router, 'permission: "MENU_WORK_ORDER"', "work-order-ledger route must use MENU_WORK_ORDER"); assertIncludes(app, 'label: "工单台账"', "production menu must include 工单台账"); assertIncludes(app, 'to: { name: "work-order-ledger" }', "工单台账 menu must point to work-order-ledger route"); assertIncludes(ledger, "/production/work-order-ledger?limit=500", "ledger view must load production work-order ledger endpoint"); assertIncludes(ledger, "已用领料重量", "ledger view must show used material weight"); assertIncludes(ledger, "归还原料重量", "ledger view must show returned raw material weight"); assertIncludes(ledger, "可入库废料", "ledger detail must show scrap inbound limit"); assertIncludes(inventory, 'source_doc_type: activeOperation.value.bizType === "PRODUCTION_SURPLUS" ? "WORK_ORDER"', "production surplus inbound must link to work order"); assertIncludes(inventory, 'activeOperation.value.bizType === "PRODUCTION_SCRAP_IN" ? "WORK_ORDER"', "production scrap inbound must link to work order"); assertIncludes(inventory, "生产工单", "warehouse drawers must expose production work order selector"); console.log("production work-order ledger static checks passed"); ``` - [ ] **Step 2: Run the static test** Run: ```bash cd /Volumes/ForgeFlow-ERP/frontend node scripts/test-production-work-order-ledger.mjs ``` Expected: ```text production work-order ledger static checks passed ``` --- ### Task 9: Full Verification **Files:** - Verify only. - [ ] **Step 1: Run backend focused tests** Run: ```bash cd /Volumes/ForgeFlow-ERP/backend PYTHONPATH=. .venv/bin/pytest tests/test_production_work_order_ledger.py tests/test_selected_stock_lot_production_issue.py tests/test_scrap_warehouse_flow.py::ScrapWarehouseFlowTest::test_production_surplus_returns_weight_to_selected_source_lot tests/test_scrap_warehouse_flow.py::ScrapWarehouseFlowTest::test_production_scrap_inbound_creates_scrap_lot_balance_and_txn -v ``` Expected: all selected tests pass. - [ ] **Step 2: Run frontend static tests** Run: ```bash cd /Volumes/ForgeFlow-ERP/frontend node scripts/test-production-work-order-ledger.mjs node scripts/test-inventory-ledger-options.mjs npm run build ``` Expected: both static scripts pass and Vite build succeeds. - [ ] **Step 3: Manual UI smoke test** Start the backend and frontend using the project’s normal local commands. In the browser, verify: ```text 1. 左侧生产执行展开后出现“工单台账”。 2. 原材料库执行“生产出库”后自动生成一条工单台账记录。 3. 小程序或测试报工同步后,工单台账的各工序良品/废品累计更新。 4. 成品库“生产入库”第一步选择生产工单,页面显示可入库数量和容差上限。 5. 废料库“生产废料入库”第一步选择生产工单,页面显示可入库废料重量和容差上限。 6. 原材料库“生产余料入库”第一步选择生产工单,来源库存批次号只出现该工单领用过的批次。 7. 超过 15% 容差提交时后端返回中文错误提示。 ``` Expected: all seven checks pass. --- ## Self-Review - Spec coverage: The plan covers the production execution menu, automatic work orders from production outbound, work-order ledger fields, finished inbound by work order, scrap inbound by work order, surplus inbound by work order, and the 15% backend tolerance rule. - Warehouse inbound coverage: Finished warehouse quantity-led inbound, scrap warehouse weight-only inbound, and raw-material warehouse weight-only surplus return are explicit backend and frontend tasks. - Placeholder scan: The plan contains no placeholder markers or unspecified implementation steps. - Type consistency: New schema names, service function names, and frontend endpoint names are consistent across tasks.