58 KiB
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, andvalidate_inbound_with_tolerance.
-
Modify:
backend/app/schemas/operations.py- Add
WorkOrderLedgerMaterialRead,WorkOrderLedgerOperationRead,WorkOrderLedgerLimitRead, andWorkOrderLedgerRead. - Add the derived fields needed by the warehouse drawers and the ledger page.
- Add
-
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.
- Add
-
Modify:
backend/app/api/routes/inventory.py- Make
PRODUCTION_SURPLUSrequiresource_doc_type="WORK_ORDER"andsource_doc_id=<work_order_id>. - Make
PRODUCTION_SCRAP_INrequiresource_doc_type="WORK_ORDER"andsource_doc_id=<work_order_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.
- Make
-
Modify:
frontend/src/router/index.js- Import
WorkOrderManagementView. - Add route
/work-order-ledgerwith permissionMENU_WORK_ORDER. - Add
MENU_WORK_ORDERto production redirect and permission ordering.
- Import
-
Modify:
frontend/src/App.vue- Add the production child menu
工单台账. - Add
MENU_WORK_ORDERto the production stage visibility.
- Add the production child menu
-
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.
- Convert the old production-out list into the new
-
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"andsource_doc_id: work_order_idfor production surplus and production scrap inbound.
- Finished warehouse
-
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:
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:
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:
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:
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:
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:
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":
@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:
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:
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:
if receipt_qty > remaining_receivable_qty:
raise HTTPException(status_code=400, detail=f"入库数量不能超过当前最大可入库数量 {remaining_receivable_qty}")
with:
validate_inbound_with_tolerance("成品入库数量", receipt_qty, remaining_receivable_qty)
Keep:
remaining_receivable_qty -= receipt_qty
so multiple lines in one receipt still consume the same calculated allowance.
- Step 4: Run focused production tests
Run:
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:
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:
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:
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:
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:
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:
source_doc_type=source_doc_type,
source_doc_id=source_lot.id,
source_line_id=None,
with:
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:
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:
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:
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:
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=<work_order_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:
import WorkOrderManagementView from "../views/WorkOrderManagementView.vue";
- Step 2: Add work-order route to production permission ordering
Change productionPermissionOrder to:
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:
{
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 工序报工:
{
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:
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:
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:
<th>生产工单</th>
<th>产品</th>
<th>原材料</th>
<th>来源库存批次号</th>
<th>计划生产数</th>
<th>领料重量</th>
<th>已用领料重量</th>
<th>归还原料重量</th>
<th>已入库成品</th>
<th>状态</th>
Change each row body to:
<td>
<button class="link-button" type="button" @click="openDetailDrawer(workOrder)">
{{ workOrder.work_order_no }}
</button>
</td>
<td>{{ workOrder.product_name }}</td>
<td>{{ workOrder.material_summary || "-" }}</td>
<td><span class="drawer-cell-code" :title="workOrder.source_lot_summary || ''">{{ workOrder.source_lot_summary || "-" }}</span></td>
<td>{{ formatQty(workOrder.planned_qty) }}</td>
<td>{{ formatWeight(workOrder.issued_weight_kg) }}</td>
<td>{{ formatWeight(workOrder.used_material_weight_kg) }}</td>
<td>{{ formatWeight(workOrder.returned_material_weight_kg) }}</td>
<td>{{ formatQty(workOrder.finished_received_qty) }}</td>
<td><span class="status-pill">{{ formatStatusLabel(workOrder.status) }}</span></td>
- Step 4: Add ledger limit cards above the detail tables
Inside the detail drawer, add this summary grid before material and operation tables:
<div class="drawer-row-detail-grid ledger-limit-grid">
<article><span>可入库成品</span><strong>{{ formatQty(activeWorkOrder?.limits?.max_finished_inbound_qty) }}</strong></article>
<article><span>成品容差上限</span><strong>{{ formatQty(activeWorkOrder?.limits?.tolerance_finished_inbound_qty) }}</strong></article>
<article><span>可入库废料</span><strong>{{ formatWeight(activeWorkOrder?.limits?.max_scrap_inbound_weight_kg) }}</strong></article>
<article><span>废料容差上限</span><strong>{{ formatWeight(activeWorkOrder?.limits?.tolerance_scrap_inbound_weight_kg) }}</strong></article>
<article><span>可归还余料</span><strong>{{ formatWeight(activeWorkOrder?.limits?.max_surplus_inbound_weight_kg) }}</strong></article>
<article><span>余料容差上限</span><strong>{{ formatWeight(activeWorkOrder?.limits?.tolerance_surplus_inbound_weight_kg) }}</strong></article>
</div>
- Step 5: Use nested arrays from the active ledger row
Replace material and operation computed rows with:
const filteredMaterials = computed(() => activeWorkOrder.value?.materials || []);
const filteredOperations = computed(() => activeWorkOrder.value?.operations || []);
- Step 6: Build check
Run:
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:
const workOrderLedgerRows = ref([]);
const selectedGenericWorkOrderId = ref("");
In loadAll, add:
fetchResource("/production/work-order-ledger?limit=500", []),
Assign it:
workOrderLedgerRows.value = Array.isArray(workOrderLedgerResult) ? workOrderLedgerResult : [];
- Step 2: Add computed work-order option lists
Add:
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:
{{ 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:
<option v-for="workOrder in productionFinishedWorkOrderOptions" :key="workOrder.work_order_id" :value="workOrder.work_order_id">
{{ workOrder.work_order_no }} · {{ workOrder.product_name }} · 可入库 {{ formatQty(workOrder.limits.max_finished_inbound_qty) }} · 容差上限 {{ formatQty(workOrder.limits.tolerance_finished_inbound_qty) }}
</option>
Update applyCompletionWorkOrder to read from workOrderLedgerRows:
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:
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:
<label v-if="activeOperation?.bizType === 'PRODUCTION_SCRAP_IN' || activeOperation?.bizType === 'PRODUCTION_SURPLUS'" class="form-field">
<span>生产工单</span>
<select v-model.number="selectedGenericWorkOrderId" required @change="applyGenericProductionWorkOrder">
<option disabled value="">请选择生产工单</option>
<option
v-for="workOrder in activeOperation?.bizType === 'PRODUCTION_SCRAP_IN' ? productionScrapWorkOrderOptions : productionSurplusWorkOrderOptions"
:key="workOrder.work_order_id"
:value="workOrder.work_order_id"
>
{{ workOrder.work_order_no }} · {{ workOrder.product_name }} · {{ activeOperation?.bizType === 'PRODUCTION_SCRAP_IN' ? `可入库废料 ${formatWeight(workOrder.limits.max_scrap_inbound_weight_kg)}` : `可归还余料 ${formatWeight(workOrder.limits.max_surplus_inbound_weight_kg)}` }}
</option>
</select>
</label>
Add:
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:
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:
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:
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:
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:
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:
cd /Volumes/ForgeFlow-ERP/frontend
node scripts/test-production-work-order-ledger.mjs
Expected:
production work-order ledger static checks passed
Task 9: Full Verification
Files:
-
Verify only.
-
Step 1: Run backend focused tests
Run:
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:
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:
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.