ForgeFlow-ERP/docs/superpowers/plans/2026-06-02-production-work-order-ledger.md
2026-06-12 16:00:56 +08:00

58 KiB
Raw Permalink Blame History

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=<work_order_id>.
    • Make PRODUCTION_SCRAP_IN require source_doc_type="WORK_ORDER" and source_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.
  • 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:

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 projects 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.