# 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:

```python
from __future__ import annotations

import unittest
from datetime import datetime
from decimal import Decimal
from types import SimpleNamespace

from fastapi import HTTPException
from sqlalchemy import BigInteger, create_engine, select
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session, sessionmaker


@compiles(BigInteger, "sqlite")
def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str:
    _ = type_, compiler, kw
    return "INTEGER"


import app.models.master_data  # noqa: E402,F401
import app.models.miniapp  # noqa: E402,F401
import app.models.operations  # noqa: E402,F401
import app.models.org  # noqa: E402,F401
import app.models.planning  # noqa: E402,F401
import app.models.sales  # noqa: E402,F401
from app.models.base import Base  # noqa: E402
from app.models.master_data import Bom, BomItem, Item, StockBalance, Warehouse  # noqa: E402
from app.models.operations import (  # noqa: E402
    CompletionReceipt,
    CompletionReceiptItem,
    InventoryTxn,
    Process,
    ProcessRoute,
    ProcessRouteOperation,
    StockLot,
    WorkCenter,
    WorkOrder,
    WorkOrderMaterial,
    WorkOrderMaterialIssue,
    WorkOrderOperation,
)
from app.models.org import Department  # noqa: E402
from app.api.routes.inventory import create_warehouse_inbound  # noqa: E402
from app.api.routes.production import create_completion_receipt  # noqa: E402
from app.schemas.operations import (  # noqa: E402
    CompletionReceiptCreate,
    CompletionReceiptItemCreate,
    WarehouseInboundCreate,
)
from app.services.production_work_order_ledger import get_work_order_limit_snapshot, get_work_order_ledger_rows  # noqa: E402


class Context:
    user = SimpleNamespace(id=1)


class ProductionWorkOrderLedgerTest(unittest.TestCase):
    def setUp(self) -> None:
        engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
        raw_connection = engine.raw_connection()
        raw_connection.create_function("greatest", -1, lambda *values: max(value for value in values if value is not None))
        raw_connection.close()
        Base.metadata.create_all(engine)
        self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
        self.db: Session = self.SessionLocal()

        self.raw_item = Item(id=1, item_code="RM-001", item_name="冷轧钢板", item_type="RAW_MATERIAL", unit_weight_kg=Decimal("1"), status="ACTIVE")
        self.product = Item(id=2, item_code="FG-001", item_name="钢制碗", item_type="FINISHED_GOOD", unit_weight_kg=Decimal("0.50"), status="ACTIVE")
        self.raw_wh = Warehouse(id=1, warehouse_code="WH-RAW", warehouse_name="原材料库", warehouse_type="RAW", status="ACTIVE")
        self.finished_wh = Warehouse(id=2, warehouse_code="WH-FG", warehouse_name="成品库", warehouse_type="FINISHED", status="ACTIVE")
        self.scrap_wh = Warehouse(id=3, warehouse_code="WH-SCRAP", warehouse_name="废料库", warehouse_type="SCRAP", status="ACTIVE")
        self.db.add_all([self.raw_item, self.product, self.raw_wh, self.finished_wh, self.scrap_wh])
        self.db.flush()

        self.bom = Bom(id=1, bom_code="BOM-001", product_item_id=2, version_no="V1.0", is_default=1, status="ACTIVE")
        self.bom_item = BomItem(
            id=1,
            bom_id=1,
            seq_no=1,
            material_item_id=1,
            usage_type="WEIGHT",
            net_weight_per_piece_kg=Decimal("0.50"),
            gross_weight_per_piece_kg=Decimal("0.60"),
            loss_rate=Decimal("0.1667"),
            status="ACTIVE",
        )
        self.department = Department(id=1, dept_code="PROD", dept_name="生产部", dept_type="PRODUCTION", status="ACTIVE")
        self.process1 = Process(id=1, process_code="P1", process_name="开平", process_type="INTERNAL", status="ACTIVE")
        self.process2 = Process(id=2, process_code="P2", process_name="冲压", process_type="INTERNAL", status="ACTIVE")
        self.work_center = WorkCenter(id=1, center_code="WC-001", center_name="车间", dept_id=1, center_type="INTERNAL", capacity_hours_per_day=Decimal("8"), status="ACTIVE")
        self.route = ProcessRoute(id=1, route_code="RT-001", route_name="钢制碗工艺", product_item_id=2, version_no="V1.0", is_default=1, status="ACTIVE")
        self.route_op1 = ProcessRouteOperation(id=1, route_id=1, seq_no=1, process_id=1, operation_name="1序", work_center_id=1, std_cycle_seconds=Decimal("10"), status="ACTIVE")
        self.route_op2 = ProcessRouteOperation(id=2, route_id=1, seq_no=2, process_id=2, operation_name="2序", work_center_id=1, std_cycle_seconds=Decimal("10"), status="ACTIVE")
        self.db.add_all([self.bom, self.bom_item, self.department, self.process1, self.process2, self.work_center, self.route, self.route_op1, self.route_op2])

        self.source_lot = StockLot(
            id=1,
            lot_no="JH_冷轧钢板00001_0001",
            lot_role="RAW_MATERIAL",
            item_id=1,
            warehouse_id=1,
            source_doc_type="PURCHASE_RECEIPT",
            source_doc_id=10,
            inbound_qty=Decimal("0"),
            inbound_weight_kg=Decimal("500"),
            remaining_qty=Decimal("0"),
            remaining_weight_kg=Decimal("300"),
            locked_qty=Decimal("0"),
            locked_weight_kg=Decimal("0"),
            unit_cost=Decimal("4"),
            quality_status="PASS",
            status="AVAILABLE",
        )
        self.balance = StockBalance(
            id=1,
            item_id=1,
            warehouse_id=1,
            qty_on_hand=Decimal("0"),
            weight_on_hand_kg=Decimal("300"),
            qty_available=Decimal("0"),
            weight_available_kg=Decimal("300"),
            qty_allocated=Decimal("0"),
            weight_allocated_kg=Decimal("0"),
            avg_unit_cost=Decimal("4"),
        )
        self.work_order = WorkOrder(
            id=1,
            work_order_no="WO-001",
            work_order_type="NORMAL",
            product_item_id=2,
            route_id=1,
            bom_id=1,
            planned_qty=Decimal("333"),
            released_qty=Decimal("333"),
            finished_qty=Decimal("0"),
            scrap_qty=Decimal("0"),
            status="RELEASED",
        )
        self.material_row = WorkOrderMaterial(
            id=1,
            work_order_id=1,
            bom_item_id=1,
            material_item_id=1,
            required_qty=Decimal("0"),
            required_weight_kg=Decimal("200"),
            issued_qty=Decimal("0"),
            issued_weight_kg=Decimal("200"),
            returned_qty=Decimal("0"),
            returned_weight_kg=Decimal("0"),
            status="ISSUED",
        )
        self.issue = WorkOrderMaterialIssue(
            id=1,
            work_order_material_id=1,
            work_order_id=1,
            material_item_id=1,
            source_lot_id=1,
            material_sub_batch_no="JH_冷轧钢板00001_0001",
            issued_qty=Decimal("0"),
            issued_weight_kg=Decimal("200"),
            unit_cost=Decimal("4"),
            amount=Decimal("800"),
            issue_time=datetime.now(),
        )
        self.op1 = WorkOrderOperation(id=1, work_order_id=1, route_operation_id=1, seq_no=1, operation_name="1序", work_center_id=1, planned_qty=Decimal("333"), report_qty=Decimal("210"), good_qty=Decimal("206"), scrap_qty=Decimal("4"), status="RUNNING")
        self.op2 = WorkOrderOperation(id=2, work_order_id=1, route_operation_id=2, seq_no=2, operation_name="2序", work_center_id=1, planned_qty=Decimal("333"), report_qty=Decimal("200"), good_qty=Decimal("198"), scrap_qty=Decimal("2"), status="RUNNING")
        self.db.add_all([self.source_lot, self.balance, self.work_order, self.material_row, self.issue, self.op1, self.op2])
        self.db.commit()

    def tearDown(self) -> None:
        self.db.close()

    def test_limit_snapshot_uses_max_report_qty_for_used_weight_and_sum_scrap_qty_for_scrap_weight(self) -> None:
        snapshot = get_work_order_limit_snapshot(self.db, 1)

        self.assertEqual(float(snapshot.issued_weight_kg), 200)
        self.assertEqual(float(snapshot.used_material_weight_kg), 126)
        self.assertEqual(float(snapshot.returned_material_weight_kg), 0)
        self.assertEqual(float(snapshot.max_surplus_inbound_weight_kg), 74)
        self.assertEqual(float(snapshot.final_operation_good_qty), 198)
        self.assertEqual(float(snapshot.finished_received_qty), 0)
        self.assertEqual(float(snapshot.max_finished_inbound_qty), 198)
        self.assertEqual(float(snapshot.total_operation_scrap_qty), 6)
        self.assertEqual(float(snapshot.max_scrap_inbound_weight_kg), 36.3)
        self.assertEqual(float(snapshot.tolerance_finished_inbound_qty), 227.7)
        self.assertEqual(float(snapshot.tolerance_scrap_inbound_weight_kg), 41.745)
        self.assertEqual(float(snapshot.tolerance_surplus_inbound_weight_kg), 85.1)

    def test_ledger_rows_include_material_operation_and_limit_details(self) -> None:
        rows = get_work_order_ledger_rows(self.db, limit=20)

        self.assertEqual(len(rows), 1)
        row = rows[0]
        self.assertEqual(row.work_order_no, "WO-001")
        self.assertEqual(row.product_name, "钢制碗")
        self.assertEqual(row.material_summary, "冷轧钢板")
        self.assertEqual(row.source_lot_summary, "JH_冷轧钢板00001_0001")
        self.assertEqual(float(row.planned_qty), 333)
        self.assertEqual(float(row.issued_weight_kg), 200)
        self.assertEqual(float(row.used_material_weight_kg), 126)
        self.assertEqual(float(row.returned_material_weight_kg), 0)
        self.assertEqual(float(row.finished_received_qty), 0)
        self.assertEqual(len(row.operations), 2)
        self.assertEqual(row.operations[0].operation_name, "1序")
        self.assertEqual(float(row.operations[0].good_qty), 206)
        self.assertEqual(float(row.operations[0].scrap_qty), 4)
        self.assertEqual(float(row.limits.max_scrap_inbound_weight_kg), 36.3)

    def test_finished_inbound_accepts_15_percent_tolerance_and_rejects_above_tolerance(self) -> None:
        with self.assertRaises(HTTPException) as cm:
            create_completion_receipt(
                CompletionReceiptCreate(
                    work_order_id=1,
                    warehouse_id=2,
                    receiver_employee_id=None,
                    items=[CompletionReceiptItemCreate(product_item_id=2, receipt_qty=227.701, receipt_weight_kg=0)],
                ),
                context=Context(),
                db=self.db,
            )
        self.assertIn("超过允许入库上限", cm.exception.detail)

        accepted = create_completion_receipt(
            CompletionReceiptCreate(
                work_order_id=1,
                warehouse_id=2,
                receiver_employee_id=None,
                items=[CompletionReceiptItemCreate(product_item_id=2, receipt_qty=227.7, receipt_weight_kg=0)],
            ),
            context=Context(),
            db=self.db,
        )

        self.assertEqual(float(accepted.total_receipt_qty), 227.7)

    def test_production_scrap_inbound_requires_work_order_and_respects_tolerance(self) -> None:
        row = create_warehouse_inbound(
            WarehouseInboundCreate(
                biz_type="PRODUCTION_SCRAP_IN",
                item_id=1,
                warehouse_id=3,
                inbound_weight_kg=41.745,
                source_doc_type="WORK_ORDER",
                source_doc_id=1,
                source_material_sub_batch_no="JH_冷轧钢板00001_0001",
                source_material_summary="WO-001生产废料",
                remark="生产废料入库",
            ),
            context=Context(),
            db=self.db,
        )

        lot = self.db.get(StockLot, row.lot_id)
        txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == row.lot_id))
        self.assertEqual(lot.source_doc_type, "WORK_ORDER")
        self.assertEqual(lot.source_doc_id, 1)
        self.assertEqual(txn.source_doc_type, "WORK_ORDER")
        self.assertEqual(txn.source_doc_id, 1)

        with self.assertRaises(HTTPException) as cm:
            create_warehouse_inbound(
                WarehouseInboundCreate(
                    biz_type="PRODUCTION_SCRAP_IN",
                    item_id=1,
                    warehouse_id=3,
                    inbound_weight_kg=0.001,
                    source_doc_type="WORK_ORDER",
                    source_doc_id=1,
                    remark="再次超额入库",
                ),
                context=Context(),
                db=self.db,
            )
        self.assertIn("超过允许入库上限", cm.exception.detail)

    def test_production_surplus_returns_to_original_lot_and_updates_work_order_returned_weight(self) -> None:
        row = create_warehouse_inbound(
            WarehouseInboundCreate(
                biz_type="PRODUCTION_SURPLUS",
                item_id=1,
                warehouse_id=1,
                source_lot_id=1,
                inbound_weight_kg=85.1,
                source_doc_type="WORK_ORDER",
                source_doc_id=1,
                remark="生产余料归还",
            ),
            context=Context(),
            db=self.db,
        )

        self.db.refresh(self.source_lot)
        self.db.refresh(self.material_row)
        txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "PRODUCTION_SURPLUS_IN"))
        self.assertEqual(row.lot_id, 1)
        self.assertEqual(float(self.source_lot.remaining_weight_kg), 385.1)
        self.assertEqual(float(self.material_row.returned_weight_kg), 85.1)
        self.assertEqual(txn.source_doc_type, "WORK_ORDER")
        self.assertEqual(txn.source_doc_id, 1)
        self.assertEqual(txn.source_line_id, 1)
```

- [ ] **Step 2: Run the new tests and verify they fail before implementation**

Run:

```bash
cd /Volumes/ForgeFlow-ERP/backend
PYTHONPATH=. .venv/bin/pytest tests/test_production_work_order_ledger.py -v
```

Expected: FAIL because `app.services.production_work_order_ledger` does not exist.

---

### Task 2: Add Backend Ledger Schemas and Service

**Files:**
- Modify: `backend/app/schemas/operations.py`
- Create: `backend/app/services/production_work_order_ledger.py`

- [ ] **Step 1: Add ledger response schemas**

Add these schemas after `WorkOrderOperationRead` in `backend/app/schemas/operations.py`:

```python
class WorkOrderLedgerMaterialRead(BaseModel):
    material_item_id: int
    material_code: str
    material_name: str
    source_lot_id: int
    source_lot_no: str
    issued_weight_kg: float
    returned_weight_kg: float = 0
    unit_cost: float = 0
    amount: float = 0


class WorkOrderLedgerOperationRead(BaseModel):
    work_order_operation_id: int
    seq_no: int
    operation_name: str
    report_qty: float
    good_qty: float
    scrap_qty: float
    status: str


class WorkOrderLedgerLimitRead(BaseModel):
    gross_weight_per_piece_kg: float
    net_weight_per_piece_kg: float
    issued_weight_kg: float
    used_material_weight_kg: float
    returned_material_weight_kg: float
    final_operation_good_qty: float
    finished_received_qty: float
    total_operation_scrap_qty: float
    scrap_inbound_weight_kg: float
    max_finished_inbound_qty: float
    max_scrap_inbound_weight_kg: float
    max_surplus_inbound_weight_kg: float
    tolerance_finished_inbound_qty: float
    tolerance_scrap_inbound_weight_kg: float
    tolerance_surplus_inbound_weight_kg: float


class WorkOrderLedgerRead(BaseModel):
    work_order_id: int
    work_order_no: str
    product_item_id: int
    product_code: str
    product_name: str
    planned_qty: float
    status: str
    material_summary: str
    source_lot_summary: str
    issued_weight_kg: float
    used_material_weight_kg: float
    returned_material_weight_kg: float
    finished_received_qty: float
    max_finished_inbound_qty: float
    max_scrap_inbound_weight_kg: float
    max_surplus_inbound_weight_kg: float
    limits: WorkOrderLedgerLimitRead
    materials: list[WorkOrderLedgerMaterialRead]
    operations: list[WorkOrderLedgerOperationRead]
```

- [ ] **Step 2: Create the service with exact calculations**

Create `backend/app/services/production_work_order_ledger.py`:

```python
from __future__ import annotations

from decimal import Decimal

from fastapi import HTTPException
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session

from app.models.master_data import BomItem, Item
from app.models.operations import (
    CompletionReceipt,
    CompletionReceiptItem,
    InventoryTxn,
    StockLot,
    WorkOrder,
    WorkOrderMaterial,
    WorkOrderMaterialIssue,
    WorkOrderOperation,
)
from app.services.operations import to_decimal


INBOUND_TOLERANCE_MULTIPLIER = Decimal("1.15")


class WorkOrderLimitSnapshot(BaseModel):
    work_order_id: int
    gross_weight_per_piece_kg: Decimal
    net_weight_per_piece_kg: Decimal
    issued_weight_kg: Decimal
    used_material_weight_kg: Decimal
    returned_material_weight_kg: Decimal
    final_operation_good_qty: Decimal
    finished_received_qty: Decimal
    total_operation_scrap_qty: Decimal
    scrap_inbound_weight_kg: Decimal
    max_finished_inbound_qty: Decimal
    max_scrap_inbound_weight_kg: Decimal
    max_surplus_inbound_weight_kg: Decimal
    tolerance_finished_inbound_qty: Decimal
    tolerance_scrap_inbound_weight_kg: Decimal
    tolerance_surplus_inbound_weight_kg: Decimal


class WorkOrderLedgerMaterialRow(BaseModel):
    material_item_id: int
    material_code: str
    material_name: str
    source_lot_id: int
    source_lot_no: str
    issued_weight_kg: Decimal
    returned_weight_kg: Decimal = Decimal("0")
    unit_cost: Decimal = Decimal("0")
    amount: Decimal = Decimal("0")


class WorkOrderLedgerOperationRow(BaseModel):
    work_order_operation_id: int
    seq_no: int
    operation_name: str
    report_qty: Decimal
    good_qty: Decimal
    scrap_qty: Decimal
    status: str


class WorkOrderLedgerRow(BaseModel):
    work_order_id: int
    work_order_no: str
    product_item_id: int
    product_code: str
    product_name: str
    planned_qty: Decimal
    status: str
    material_summary: str
    source_lot_summary: str
    issued_weight_kg: Decimal
    used_material_weight_kg: Decimal
    returned_material_weight_kg: Decimal
    finished_received_qty: Decimal
    max_finished_inbound_qty: Decimal
    max_scrap_inbound_weight_kg: Decimal
    max_surplus_inbound_weight_kg: Decimal
    limits: WorkOrderLimitSnapshot
    materials: list[WorkOrderLedgerMaterialRow]
    operations: list[WorkOrderLedgerOperationRow]


def _non_negative(value: Decimal) -> Decimal:
    return max(value, Decimal("0"))


def _round_decimal(value: Decimal, places: str = "0.001") -> Decimal:
    return to_decimal(value, places)


def _get_work_order(db: Session, work_order_id: int) -> WorkOrder:
    work_order = db.get(WorkOrder, work_order_id)
    if not work_order:
        raise HTTPException(status_code=404, detail="生产工单不存在")
    return work_order


def _get_weight_profile(db: Session, work_order: WorkOrder) -> tuple[Decimal, Decimal]:
    bom_item = db.scalar(
        select(BomItem)
        .where(BomItem.bom_id == work_order.bom_id)
        .order_by(BomItem.seq_no)
        .limit(1)
    )
    product = db.get(Item, work_order.product_item_id)
    gross = to_decimal(bom_item.gross_weight_per_piece_kg if bom_item else 0)
    net = to_decimal(bom_item.net_weight_per_piece_kg if bom_item else 0)
    if net <= 0 and product:
        net = to_decimal(product.unit_weight_kg)
    if gross <= 0:
        gross = net
    if gross <= 0:
        raise HTTPException(status_code=400, detail="产品需规清单缺少产品毛重，无法计算工单入库上限")
    if net < 0:
        net = Decimal("0")
    if gross < net:
        gross = net
    return gross, net


def _get_operations(db: Session, work_order_id: int) -> list[WorkOrderOperation]:
    return db.scalars(
        select(WorkOrderOperation)
        .where(WorkOrderOperation.work_order_id == work_order_id)
        .order_by(WorkOrderOperation.seq_no, WorkOrderOperation.id)
    ).all()


def _get_finished_received_qty(db: Session, work_order_id: int) -> Decimal:
    value = db.scalar(
        select(func.coalesce(func.sum(CompletionReceiptItem.receipt_qty), 0))
        .join(CompletionReceipt, CompletionReceipt.id == CompletionReceiptItem.completion_receipt_id)
        .where(CompletionReceipt.work_order_id == work_order_id)
    )
    return to_decimal(value)


def _get_scrap_inbound_weight(db: Session, work_order_id: int) -> Decimal:
    value = db.scalar(
        select(func.coalesce(func.sum(InventoryTxn.weight_change_kg), 0))
        .where(
            InventoryTxn.txn_type == "PRODUCTION_SCRAP_IN",
            InventoryTxn.source_doc_type == "WORK_ORDER",
            InventoryTxn.source_doc_id == work_order_id,
        )
    )
    return _non_negative(to_decimal(value))


def get_work_order_limit_snapshot(db: Session, work_order_id: int) -> WorkOrderLimitSnapshot:
    work_order = _get_work_order(db, work_order_id)
    gross_weight, net_weight = _get_weight_profile(db, work_order)
    operations = _get_operations(db, work_order_id)
    issued_weight = to_decimal(
        db.scalar(
            select(func.coalesce(func.sum(WorkOrderMaterialIssue.issued_weight_kg), 0))
            .where(WorkOrderMaterialIssue.work_order_id == work_order_id)
        )
    )
    returned_weight = to_decimal(
        db.scalar(
            select(func.coalesce(func.sum(WorkOrderMaterial.returned_weight_kg), 0))
            .where(WorkOrderMaterial.work_order_id == work_order_id)
        )
    )
    max_report_qty = max((to_decimal(operation.report_qty) for operation in operations), default=Decimal("0"))
    used_material_weight = _round_decimal(max_report_qty * gross_weight)
    final_good_qty = to_decimal(operations[-1].good_qty) if operations else Decimal("0")
    finished_received_qty = _get_finished_received_qty(db, work_order_id)
    total_scrap_qty = sum((to_decimal(operation.scrap_qty) for operation in operations), Decimal("0"))
    scrap_inbound_weight = _get_scrap_inbound_weight(db, work_order_id)
    edge_scrap_weight = _non_negative((to_decimal(work_order.planned_qty) - total_scrap_qty) * _non_negative(gross_weight - net_weight))
    rejected_product_scrap_weight = total_scrap_qty * gross_weight
    max_finished_inbound_qty = _non_negative(final_good_qty - finished_received_qty)
    max_scrap_inbound_weight = _non_negative(edge_scrap_weight + rejected_product_scrap_weight - scrap_inbound_weight)
    max_surplus_inbound_weight = _non_negative(issued_weight - used_material_weight - returned_weight)
    return WorkOrderLimitSnapshot(
        work_order_id=work_order_id,
        gross_weight_per_piece_kg=_round_decimal(gross_weight),
        net_weight_per_piece_kg=_round_decimal(net_weight),
        issued_weight_kg=_round_decimal(issued_weight),
        used_material_weight_kg=_round_decimal(used_material_weight),
        returned_material_weight_kg=_round_decimal(returned_weight),
        final_operation_good_qty=_round_decimal(final_good_qty),
        finished_received_qty=_round_decimal(finished_received_qty),
        total_operation_scrap_qty=_round_decimal(total_scrap_qty),
        scrap_inbound_weight_kg=_round_decimal(scrap_inbound_weight),
        max_finished_inbound_qty=_round_decimal(max_finished_inbound_qty),
        max_scrap_inbound_weight_kg=_round_decimal(max_scrap_inbound_weight),
        max_surplus_inbound_weight_kg=_round_decimal(max_surplus_inbound_weight),
        tolerance_finished_inbound_qty=_round_decimal(max_finished_inbound_qty * INBOUND_TOLERANCE_MULTIPLIER),
        tolerance_scrap_inbound_weight_kg=_round_decimal(max_scrap_inbound_weight * INBOUND_TOLERANCE_MULTIPLIER),
        tolerance_surplus_inbound_weight_kg=_round_decimal(max_surplus_inbound_weight * INBOUND_TOLERANCE_MULTIPLIER),
    )


def validate_inbound_with_tolerance(label: str, requested: Decimal, calculated_max: Decimal) -> None:
    if requested <= 0:
        raise HTTPException(status_code=400, detail=f"{label}必须大于0")
    allowed = _round_decimal(_non_negative(calculated_max) * INBOUND_TOLERANCE_MULTIPLIER)
    if requested > allowed:
        raise HTTPException(status_code=400, detail=f"{label}超过允许入库上限 {allowed}")


def validate_work_order_source_lot(db: Session, work_order_id: int, source_lot_id: int, item_id: int) -> WorkOrderMaterial:
    row = db.execute(
        select(WorkOrderMaterial)
        .join(WorkOrderMaterialIssue, WorkOrderMaterialIssue.work_order_material_id == WorkOrderMaterial.id)
        .where(
            WorkOrderMaterial.work_order_id == work_order_id,
            WorkOrderMaterial.material_item_id == item_id,
            WorkOrderMaterialIssue.source_lot_id == source_lot_id,
        )
        .limit(1)
    ).scalar_one_or_none()
    if not row:
        raise HTTPException(status_code=400, detail="来源库存批次号不属于该生产工单")
    return row


def get_work_order_ledger_rows(db: Session, limit: int = 200, work_order_id: int | None = None) -> list[WorkOrderLedgerRow]:
    stmt = select(WorkOrder).order_by(WorkOrder.id.desc()).limit(limit)
    if work_order_id:
        stmt = stmt.where(WorkOrder.id == work_order_id)
    work_orders = db.scalars(stmt).all()
    rows: list[WorkOrderLedgerRow] = []
    for work_order in work_orders:
        product = db.get(Item, work_order.product_item_id)
        limits = get_work_order_limit_snapshot(db, work_order.id)
        issue_rows = db.execute(
            select(
                WorkOrderMaterialIssue.material_item_id,
                Item.item_code,
                Item.item_name,
                WorkOrderMaterialIssue.source_lot_id,
                StockLot.lot_no,
                WorkOrderMaterialIssue.issued_weight_kg,
                WorkOrderMaterialIssue.unit_cost,
                WorkOrderMaterialIssue.amount,
            )
            .join(Item, Item.id == WorkOrderMaterialIssue.material_item_id)
            .join(StockLot, StockLot.id == WorkOrderMaterialIssue.source_lot_id)
            .where(WorkOrderMaterialIssue.work_order_id == work_order.id)
            .order_by(WorkOrderMaterialIssue.id)
        ).mappings().all()
        material_rows = [
            WorkOrderLedgerMaterialRow(
                material_item_id=int(issue["material_item_id"]),
                material_code=issue["item_code"],
                material_name=issue["item_name"],
                source_lot_id=int(issue["source_lot_id"]),
                source_lot_no=issue["lot_no"],
                issued_weight_kg=_round_decimal(to_decimal(issue["issued_weight_kg"])),
                returned_weight_kg=Decimal("0"),
                unit_cost=_round_decimal(to_decimal(issue["unit_cost"]), "0.0001"),
                amount=_round_decimal(to_decimal(issue["amount"]), "0.01"),
            )
            for issue in issue_rows
        ]
        operation_rows = [
            WorkOrderLedgerOperationRow(
                work_order_operation_id=int(operation.id),
                seq_no=int(operation.seq_no),
                operation_name=operation.operation_name,
                report_qty=_round_decimal(to_decimal(operation.report_qty)),
                good_qty=_round_decimal(to_decimal(operation.good_qty)),
                scrap_qty=_round_decimal(to_decimal(operation.scrap_qty)),
                status=operation.status,
            )
            for operation in _get_operations(db, work_order.id)
        ]
        material_names = sorted({row.material_name for row in material_rows})
        source_lot_nos = [row.source_lot_no for row in material_rows]
        rows.append(
            WorkOrderLedgerRow(
                work_order_id=int(work_order.id),
                work_order_no=work_order.work_order_no,
                product_item_id=int(work_order.product_item_id),
                product_code=product.item_code if product else "",
                product_name=product.item_name if product else "",
                planned_qty=_round_decimal(to_decimal(work_order.planned_qty)),
                status=work_order.status,
                material_summary="、".join(material_names) if material_names else "-",
                source_lot_summary="、".join(source_lot_nos) if source_lot_nos else "-",
                issued_weight_kg=limits.issued_weight_kg,
                used_material_weight_kg=limits.used_material_weight_kg,
                returned_material_weight_kg=limits.returned_material_weight_kg,
                finished_received_qty=limits.finished_received_qty,
                max_finished_inbound_qty=limits.max_finished_inbound_qty,
                max_scrap_inbound_weight_kg=limits.max_scrap_inbound_weight_kg,
                max_surplus_inbound_weight_kg=limits.max_surplus_inbound_weight_kg,
                limits=limits,
                materials=material_rows,
                operations=operation_rows,
            )
        )
    return rows
```

- [ ] **Step 3: Run backend tests and verify the new service calculations pass except route validations still fail**

Run:

```bash
cd /Volumes/ForgeFlow-ERP/backend
PYTHONPATH=. .venv/bin/pytest tests/test_production_work_order_ledger.py::ProductionWorkOrderLedgerTest::test_limit_snapshot_uses_max_report_qty_for_used_weight_and_sum_scrap_qty_for_scrap_weight tests/test_production_work_order_ledger.py::ProductionWorkOrderLedgerTest::test_ledger_rows_include_material_operation_and_limit_details -v
```

Expected: PASS for the two service-level tests.

---

### Task 3: Wire Production Ledger API and Finished Inbound Tolerance

**Files:**
- Modify: `backend/app/api/routes/production.py`

- [ ] **Step 1: Import the new schema and service functions**

Add `WorkOrderLedgerRead` to the `app.schemas.operations` import list.

Add this service import near the existing `app.services.operations` import:

```python
from app.services.production_work_order_ledger import (
    get_work_order_ledger_rows,
    get_work_order_limit_snapshot,
    validate_inbound_with_tolerance,
)
```

- [ ] **Step 2: Add the ledger endpoint**

Add this route before `@router.get("/work-orders"`:

```python
@router.get("/work-order-ledger", response_model=list[WorkOrderLedgerRead])
def list_work_order_ledger(
    work_order_id: int | None = Query(default=None),
    limit: int = Query(default=200, ge=1, le=500),
    db: Session = Depends(get_db),
) -> list[WorkOrderLedgerRead]:
    if work_order_id and sync_work_order_reference_qty(db, work_order_id):
        db.commit()
    rows = get_work_order_ledger_rows(db, limit=limit, work_order_id=work_order_id)
    return [WorkOrderLedgerRead.model_validate(row.model_dump()) for row in rows]
```

- [ ] **Step 3: Replace finished inbound max calculation with the shared limit snapshot**

In `create_completion_receipt`, replace:

```python
    work_order_snapshot = db.execute(get_work_orders_query(limit=1).where(WorkOrder.id == payload.work_order_id)).mappings().first()
    if not work_order_snapshot:
        raise HTTPException(status_code=404, detail="工单不存在")
    max_receivable_qty = to_decimal(work_order_snapshot["max_receivable_qty"])
    if max_receivable_qty <= 0:
        raise HTTPException(status_code=400, detail="该工单当前没有可入库的完工数量")
```

with:

```python
    limit_snapshot = get_work_order_limit_snapshot(db, payload.work_order_id)
    max_receivable_qty = to_decimal(limit_snapshot.max_finished_inbound_qty)
    if max_receivable_qty <= 0:
        raise HTTPException(status_code=400, detail="该工单当前没有可入库的完工数量")
```

Replace the per-line check:

```python
        if receipt_qty > remaining_receivable_qty:
            raise HTTPException(status_code=400, detail=f"入库数量不能超过当前最大可入库数量 {remaining_receivable_qty}")
```

with:

```python
        validate_inbound_with_tolerance("成品入库数量", receipt_qty, remaining_receivable_qty)
```

Keep:

```python
        remaining_receivable_qty -= receipt_qty
```

so multiple lines in one receipt still consume the same calculated allowance.

- [ ] **Step 4: Run focused production tests**

Run:

```bash
cd /Volumes/ForgeFlow-ERP/backend
PYTHONPATH=. .venv/bin/pytest tests/test_production_work_order_ledger.py::ProductionWorkOrderLedgerTest::test_finished_inbound_accepts_15_percent_tolerance_and_rejects_above_tolerance tests/test_selected_stock_lot_production_issue.py -v
```

Expected: PASS.

---

### Task 4: Enforce Work-Order-Controlled Scrap and Surplus Inbound

**Files:**
- Modify: `backend/app/api/routes/inventory.py`

- [ ] **Step 1: Import ledger validation helpers**

Add this import near the other service imports:

```python
from app.services.production_work_order_ledger import (
    get_work_order_limit_snapshot,
    validate_inbound_with_tolerance,
    validate_work_order_source_lot,
)
```

- [ ] **Step 2: Add a helper that validates production inbound work-order payloads**

Add this helper above `create_warehouse_inbound`:

```python
def _get_required_production_work_order_id(payload: WarehouseInboundCreate, biz_type: str) -> int:
    if biz_type not in {"PRODUCTION_SURPLUS", "PRODUCTION_SCRAP_IN"}:
        return 0
    if str(payload.source_doc_type or "").upper() != "WORK_ORDER" or not payload.source_doc_id:
        raise HTTPException(status_code=400, detail=f"{_inventory_biz_type_label(biz_type)}必须先选择生产工单")
    return int(payload.source_doc_id)
```

- [ ] **Step 3: Calculate the work-order id before normal inbound validation**

Inside `create_warehouse_inbound`, after `biz_type` is normalized, add:

```python
    production_work_order_id = _get_required_production_work_order_id(payload, biz_type)
```

- [ ] **Step 4: Validate production surplus inbound and update work-order returned weight**

Inside the `if biz_type in _SOURCE_LOT_RETURN_INBOUND_BIZ_TYPES:` branch, after source lot validation and before increasing `source_lot.remaining_weight_kg`, add:

```python
        work_order_material_row = None
        if biz_type == "PRODUCTION_SURPLUS":
            limit_snapshot = get_work_order_limit_snapshot(db, production_work_order_id)
            validate_inbound_with_tolerance("生产余料入库重量", inbound_weight, to_decimal(limit_snapshot.max_surplus_inbound_weight_kg))
            work_order_material_row = validate_work_order_source_lot(
                db,
                production_work_order_id,
                int(source_lot.id),
                int(payload.item_id),
            )
```

After `source_lot.updated_at = datetime.now()`, add:

```python
        if work_order_material_row is not None:
            work_order_material_row.returned_weight_kg = to_decimal(work_order_material_row.returned_weight_kg) + inbound_weight
            work_order_material_row.updated_at = datetime.now()
            db.add(work_order_material_row)
```

Replace the `create_inventory_txn` source fields in the same branch:

```python
            source_doc_type=source_doc_type,
            source_doc_id=source_lot.id,
            source_line_id=None,
```

with:

```python
            source_doc_type="WORK_ORDER" if biz_type == "PRODUCTION_SURPLUS" else source_doc_type,
            source_doc_id=production_work_order_id if biz_type == "PRODUCTION_SURPLUS" else source_lot.id,
            source_line_id=source_lot.id if biz_type == "PRODUCTION_SURPLUS" else None,
```

- [ ] **Step 5: Validate production scrap inbound and link created lot/transaction to the work order**

Before the generic lot creation section sets `operation_source_doc_type`, add:

```python
    if biz_type == "PRODUCTION_SCRAP_IN":
        limit_snapshot = get_work_order_limit_snapshot(db, production_work_order_id)
        validate_inbound_with_tolerance("生产废料入库重量", inbound_weight, to_decimal(limit_snapshot.max_scrap_inbound_weight_kg))
```

After:

```python
    operation_source_doc_type = source_doc_type
    operation_source_doc_id = payload.source_doc_id or 0
    operation_source_line_id = payload.source_line_id
```

add:

```python
    if biz_type == "PRODUCTION_SCRAP_IN":
        operation_source_doc_type = "WORK_ORDER"
        operation_source_doc_id = production_work_order_id
        operation_source_line_id = None
```

- [ ] **Step 6: Run inventory linkage tests**

Run:

```bash
cd /Volumes/ForgeFlow-ERP/backend
PYTHONPATH=. .venv/bin/pytest tests/test_production_work_order_ledger.py::ProductionWorkOrderLedgerTest::test_production_scrap_inbound_requires_work_order_and_respects_tolerance tests/test_production_work_order_ledger.py::ProductionWorkOrderLedgerTest::test_production_surplus_returns_to_original_lot_and_updates_work_order_returned_weight tests/test_scrap_warehouse_flow.py::ScrapWarehouseFlowTest::test_production_surplus_returns_weight_to_selected_source_lot tests/test_scrap_warehouse_flow.py::ScrapWarehouseFlowTest::test_production_scrap_inbound_creates_scrap_lot_balance_and_txn -v
```

Expected: PASS after updating the two older scrap warehouse tests to pass `source_doc_type="WORK_ORDER"` and `source_doc_id=<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:

```js
import WorkOrderManagementView from "../views/WorkOrderManagementView.vue";
```

- [ ] **Step 2: Add work-order route to production permission ordering**

Change `productionPermissionOrder` to:

```js
const productionPermissionOrder = [
  {
    routeName: "work-order-ledger",
    permission: "MENU_WORK_ORDER"
  },
  {
    routeName: "operation-reports",
    permission: "MENU_OPERATION_REPORT"
  },
  {
    routeName: "scrap-rework",
    permission: "MENU_SCRAP_REWORK"
  }
];
```

Add `MENU_WORK_ORDER` to the `navPermissionOrder` production workspace permission list.

- [ ] **Step 3: Add route**

Add this route before `/operation-reports`:

```js
{
  path: "/work-order-ledger",
  name: "work-order-ledger",
  component: WorkOrderManagementView,
  meta: { title: "工单台账", requiresAuth: true, permission: "MENU_WORK_ORDER" }
},
```

- [ ] **Step 4: Add App production menu child**

In `frontend/src/App.vue`, add `MENU_WORK_ORDER` to the production stage `permissionsAny`.

Add this child before `工序报工`:

```js
{
  key: "work-order-ledger",
  label: "工单台账",
  to: { name: "work-order-ledger" },
  routeNames: ["work-order-ledger"],
  permission: "MENU_WORK_ORDER",
  iconPaths: [
    "M5 5H19V19H5V5Z",
    "M8 9H16",
    "M8 13H16",
    "M8 17H13",
    "M15 17L17 19L21 14"
  ]
},
```

- [ ] **Step 5: Run frontend route sanity build**

Run:

```bash
cd /Volumes/ForgeFlow-ERP/frontend
npm run build
```

Expected: build succeeds.

---

### Task 6: Convert WorkOrderManagementView into 工单台账

**Files:**
- Modify: `frontend/src/views/WorkOrderManagementView.vue`

- [ ] **Step 1: Replace data loading with the new ledger endpoint**

Replace the current `loadAll` requests for `/production/work-orders`, `/production/work-order-materials`, and `/production/work-order-operations` with:

```js
async function loadAll() {
  const [ledgerRows] = await Promise.all([
    fetchResource("/production/work-order-ledger?limit=500", [])
  ]);
  workOrders.value = Array.isArray(ledgerRows) ? ledgerRows : [];
  if (!selectedWorkOrderId.value && workOrders.value.length) {
    selectedWorkOrderId.value = workOrders.value[0].work_order_id;
  }
}
```

- [ ] **Step 2: Remove production-out creation from the ledger page**

Remove the `FormDrawer` titled `新增生产出库`, the `openCreateDrawer` handler, the `submitWorkOrder` handler, and the production-out form state. Keep detail drawer behavior for ledger rows.

- [ ] **Step 3: Update table columns**

Change the main table header to:

```vue
<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:

```vue
<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:

```vue
<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:

```js
const filteredMaterials = computed(() => activeWorkOrder.value?.materials || []);
const filteredOperations = computed(() => activeWorkOrder.value?.operations || []);
```

- [ ] **Step 6: Build check**

Run:

```bash
cd /Volumes/ForgeFlow-ERP/frontend
npm run build
```

Expected: build succeeds.

---

### Task 7: Update Warehouse Drawers to Select Production Work Orders

**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.vue`

- [ ] **Step 1: Load ledger rows alongside existing work-order rows**

Add state:

```js
const workOrderLedgerRows = ref([]);
const selectedGenericWorkOrderId = ref("");
```

In `loadAll`, add:

```js
fetchResource("/production/work-order-ledger?limit=500", []),
```

Assign it:

```js
workOrderLedgerRows.value = Array.isArray(workOrderLedgerResult) ? workOrderLedgerResult : [];
```

- [ ] **Step 2: Add computed work-order option lists**

Add:

```js
const productionFinishedWorkOrderOptions = computed(() =>
  workOrderLedgerRows.value.filter((row) => Number(row.max_finished_inbound_qty || 0) > 0)
);

const productionScrapWorkOrderOptions = computed(() =>
  workOrderLedgerRows.value.filter((row) => Number(row.max_scrap_inbound_weight_kg || 0) > 0)
);

const productionSurplusWorkOrderOptions = computed(() =>
  workOrderLedgerRows.value.filter((row) => Number(row.max_surplus_inbound_weight_kg || 0) > 0)
);

const selectedGenericWorkOrder = computed(() =>
  workOrderLedgerRows.value.find((row) => Number(row.work_order_id) === Number(selectedGenericWorkOrderId.value)) || null
);
```

- [ ] **Step 3: Rename finished production inbound selector**

In the finished production inbound drawer, change label text from `库存批次号` to `生产工单`, and change the disabled option text from `请选择库存批次号` to `请选择生产工单`.

Change option text to:

```vue
{{ workOrder.work_order_no }} · {{ workOrder.product_name }} · 可入库 {{ formatQty(workOrder.max_receivable_qty) }}
```

Then switch the options to the new ledger row list and display tolerance:

```vue
<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`:

```js
function applyCompletionWorkOrder() {
  const workOrder = workOrderLedgerRows.value.find((item) => item.work_order_id === Number(completionForm.work_order_id));
  if (!workOrder) {
    return;
  }
  const product = products.value.find((item) => item.item_id === Number(workOrder.product_item_id));
  completionForm.product_item_id = workOrder.product_item_id;
  completionForm.max_receivable_qty = Number(workOrder.limits?.max_finished_inbound_qty || 0);
  completionForm.tolerance_receivable_qty = Number(workOrder.limits?.tolerance_finished_inbound_qty || 0);
  completionForm.unit_weight_kg = Number(product?.unit_weight_kg || 0);
  completionForm.receipt_qty = Number(workOrder.limits?.max_finished_inbound_qty || 0);
  recalculateCompletionWeight();
}
```

Update `recalculateCompletionWeight` to clamp to `completionForm.tolerance_receivable_qty`, not the raw calculated max:

```js
function recalculateCompletionWeight() {
  const toleranceQty = Number(completionForm.tolerance_receivable_qty || completionForm.max_receivable_qty || 0);
  if (Number(completionForm.receipt_qty || 0) > toleranceQty) {
    completionForm.receipt_qty = toleranceQty;
  }
  completionForm.receipt_weight_kg = Number((Number(completionForm.receipt_qty || 0) * Number(completionForm.unit_weight_kg || 0)).toFixed(3));
}
```

- [ ] **Step 4: Add production work-order selector to generic drawer for production scrap and surplus**

Add this block near the top of the generic drawer form, before item selection:

```vue
<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:

```js
function applyGenericProductionWorkOrder() {
  const workOrder = selectedGenericWorkOrder.value;
  if (!workOrder) {
    return;
  }
  if (activeOperation.value?.bizType === "PRODUCTION_SCRAP_IN") {
    genericForm.item_id = workOrder.materials?.[0]?.material_item_id || "";
    genericForm.item_option_key = String(genericForm.item_id || "");
    genericForm.weight_kg = Number(workOrder.limits?.max_scrap_inbound_weight_kg || 0);
    genericForm.source_material_sub_batch_no = workOrder.source_lot_summary || "";
    genericForm.source_material_summary = `${workOrder.work_order_no}生产废料`;
  }
  if (activeOperation.value?.bizType === "PRODUCTION_SURPLUS") {
    genericForm.item_id = workOrder.materials?.[0]?.material_item_id || "";
    genericForm.item_option_key = String(genericForm.item_id || "");
    genericForm.weight_kg = Number(workOrder.limits?.max_surplus_inbound_weight_kg || 0);
    selectedGenericSourceLots.value = (workOrder.materials || []).map((material) => ({
      lot_id: material.source_lot_id,
      item_id: material.material_item_id,
      item_code: material.material_code,
      item_name: material.material_name,
      lot_no: material.source_lot_no,
      remaining_weight_kg: 0,
      unit_cost: material.unit_cost,
      return_weight_kg: 0
    }));
  }
}
```

- [ ] **Step 5: Send work-order linkage for production scrap and surplus inbound**

In the `isSourceLotReturnInbound` loop payload, add:

```js
source_doc_type: activeOperation.value.bizType === "PRODUCTION_SURPLUS" ? "WORK_ORDER" : null,
source_doc_id: activeOperation.value.bizType === "PRODUCTION_SURPLUS" ? Number(selectedGenericWorkOrderId.value) : null,
```

In the generic inbound payload for production scrap, set:

```js
source_doc_type: activeOperation.value.bizType === "PRODUCTION_SCRAP_IN" ? "WORK_ORDER" : activeWarehouseType.value === "SEMI" ? "PRODUCT_ROUTE_OPERATION" : null,
source_doc_id: activeOperation.value.bizType === "PRODUCTION_SCRAP_IN" ? Number(selectedGenericWorkOrderId.value) : activeWarehouseType.value === "SEMI" ? Number(selectedSourceDocId || 0) : null,
```

- [ ] **Step 6: Add frontend validation**

At the top of `submitGenericOperation`, after logistics validation, add:

```js
if ((activeOperation.value?.bizType === "PRODUCTION_SCRAP_IN" || activeOperation.value?.bizType === "PRODUCTION_SURPLUS") && !Number(selectedGenericWorkOrderId.value)) {
  errorMessage.value = `${activeOperation.value.label}必须先选择生产工单`;
  return;
}
```

- [ ] **Step 7: Run frontend build**

Run:

```bash
cd /Volumes/ForgeFlow-ERP/frontend
npm run build
```

Expected: build succeeds.

---

### Task 8: Add Frontend Static Regression Test

**Files:**
- Create: `frontend/scripts/test-production-work-order-ledger.mjs`

- [ ] **Step 1: Create the static test**

Create `frontend/scripts/test-production-work-order-ledger.mjs`:

```js
import fs from "node:fs";
import path from "node:path";
import process from "node:process";

const root = process.cwd();

function read(relativePath) {
  return fs.readFileSync(path.join(root, relativePath), "utf8");
}

function assertIncludes(content, needle, message) {
  if (!content.includes(needle)) {
    throw new Error(message);
  }
}

const router = read("src/router/index.js");
const app = read("src/App.vue");
const inventory = read("src/views/InventoryLedgerView.vue");
const ledger = read("src/views/WorkOrderManagementView.vue");

assertIncludes(router, "WorkOrderManagementView", "router must import WorkOrderManagementView");
assertIncludes(router, 'name: "work-order-ledger"', "router must define work-order-ledger route");
assertIncludes(router, 'permission: "MENU_WORK_ORDER"', "work-order-ledger route must use MENU_WORK_ORDER");
assertIncludes(app, 'label: "工单台账"', "production menu must include 工单台账");
assertIncludes(app, 'to: { name: "work-order-ledger" }', "工单台账 menu must point to work-order-ledger route");
assertIncludes(ledger, "/production/work-order-ledger?limit=500", "ledger view must load production work-order ledger endpoint");
assertIncludes(ledger, "已用领料重量", "ledger view must show used material weight");
assertIncludes(ledger, "归还原料重量", "ledger view must show returned raw material weight");
assertIncludes(ledger, "可入库废料", "ledger detail must show scrap inbound limit");
assertIncludes(inventory, 'source_doc_type: activeOperation.value.bizType === "PRODUCTION_SURPLUS" ? "WORK_ORDER"', "production surplus inbound must link to work order");
assertIncludes(inventory, 'activeOperation.value.bizType === "PRODUCTION_SCRAP_IN" ? "WORK_ORDER"', "production scrap inbound must link to work order");
assertIncludes(inventory, "生产工单", "warehouse drawers must expose production work order selector");

console.log("production work-order ledger static checks passed");
```

- [ ] **Step 2: Run the static test**

Run:

```bash
cd /Volumes/ForgeFlow-ERP/frontend
node scripts/test-production-work-order-ledger.mjs
```

Expected:

```text
production work-order ledger static checks passed
```

---

### Task 9: Full Verification

**Files:**
- Verify only.

- [ ] **Step 1: Run backend focused tests**

Run:

```bash
cd /Volumes/ForgeFlow-ERP/backend
PYTHONPATH=. .venv/bin/pytest tests/test_production_work_order_ledger.py tests/test_selected_stock_lot_production_issue.py tests/test_scrap_warehouse_flow.py::ScrapWarehouseFlowTest::test_production_surplus_returns_weight_to_selected_source_lot tests/test_scrap_warehouse_flow.py::ScrapWarehouseFlowTest::test_production_scrap_inbound_creates_scrap_lot_balance_and_txn -v
```

Expected: all selected tests pass.

- [ ] **Step 2: Run frontend static tests**

Run:

```bash
cd /Volumes/ForgeFlow-ERP/frontend
node scripts/test-production-work-order-ledger.mjs
node scripts/test-inventory-ledger-options.mjs
npm run build
```

Expected: both static scripts pass and Vite build succeeds.

- [ ] **Step 3: Manual UI smoke test**

Start the backend and frontend using the project’s normal local commands. In the browser, verify:

```text
1. 左侧生产执行展开后出现“工单台账”。
2. 原材料库执行“生产出库”后自动生成一条工单台账记录。
3. 小程序或测试报工同步后，工单台账的各工序良品/废品累计更新。
4. 成品库“生产入库”第一步选择生产工单，页面显示可入库数量和容差上限。
5. 废料库“生产废料入库”第一步选择生产工单，页面显示可入库废料重量和容差上限。
6. 原材料库“生产余料入库”第一步选择生产工单，来源库存批次号只出现该工单领用过的批次。
7. 超过 15% 容差提交时后端返回中文错误提示。
```

Expected: all seven checks pass.

---

## Self-Review

- Spec coverage: The plan covers the production execution menu, automatic work orders from production outbound, work-order ledger fields, finished inbound by work order, scrap inbound by work order, surplus inbound by work order, and the 15% backend tolerance rule.
- Warehouse inbound coverage: Finished warehouse quantity-led inbound, scrap warehouse weight-only inbound, and raw-material warehouse weight-only surplus return are explicit backend and frontend tasks.
- Placeholder scan: The plan contains no placeholder markers or unspecified implementation steps.
- Type consistency: New schema names, service function names, and frontend endpoint names are consistent across tasks.
