ForgeFlow-ERP/docs/superpowers/plans/2026-06-03-finished-rework-inbound.md
2026-06-12 16:00:56 +08:00

42 KiB
Raw Blame History

成品库返工入库 Implementation Plan

状态:已暂停,后续版本再做。

2026-06-03 业务决策:本版本不实现 成品库 -> 返工入库。不要按本文执行代码改造;本文仅作为后续版本参考资料保留。

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 嘉恒仓库 -> 成品库 -> 返工入库, binding finished-goods rework inbound to existing 退货库 -> 返工出库 disposition records, with raw-material stock-lot traceability and quantity-based finished inventory updates.

Architecture: Reuse the existing generic warehouse inbound route and drawer. Add a new inbound business type REWORK_IN that is allowed only in FINISHED warehouses, validates RETURN_DISPOSITION source documents, derives remaining rework-return quantity from existing finished inbound stock lots, and records raw-material source stock lot trace fields. The frontend adds a finished-warehouse operation, loads return dispositions, filters pending/partial rework dispositions by product, and submits source_doc_type/source_doc_id/source_line_id through the generic inbound flow.

Tech Stack: FastAPI route functions, SQLAlchemy ORM/query helpers, Pydantic schemas, SQLite-backed pytest/unittest tests, Vue 3 Composition API, static Node.js UI guard scripts.

Repository note: The current mounted directory /Users/souplearn/Gitlab/py/ForgeFlow-ERP is not a git repository. Replace commit checkpoints with verification checkpoints in this workspace. If executing in a real git worktree, commit after each completed task.


Scope Check

This plan implements one connected subsystem: finished-warehouse rework inbound. It touches backend validation/query/schema code, frontend warehouse UI, dictionaries, and tests that prove the whole loop works. It does not modify the mini-program, production work-order reporting, or return rework outbound behavior except for querying its results.

File Structure

  • Modify backend/app/schemas/operations.py: add REWORK_IN to inbound business types and expose return-disposition rework inbound summary fields.
  • Modify backend/app/services/operations.py: extend get_return_dispositions_query() with product id, return quantity, already rework-inbound quantity, remaining quantity, and derived Chinese status.
  • Modify backend/app/api/routes/inventory.py: add REWORK_IN mapping, finished-warehouse allowance, validation against RETURN_DISPOSITION, source-lot trace handling, material-cost defaulting, and quantity amount basis.
  • Modify backend/tests/test_return_warehouse_flow.py: add backend tests for successful rework inbound, partial/complete remaining quantity derivation, over-inbound rejection, and wrong source rejection.
  • Modify frontend/src/views/InventoryLedgerView.vue: add 返工入库 operation, return-disposition state, drawer field, validation, source-lot selector integration, and submit payload mapping.
  • Modify frontend/src/utils/dictionaries.js: add Chinese labels for REWORK_IN.
  • Create frontend/scripts/test-finished-rework-inbound.mjs: static guard for the finished rework inbound UI and backend integration hooks.

Task 1: Backend Failing Tests

Files:

  • Modify: backend/tests/test_return_warehouse_flow.py

  • Step 1: Add backend tests for finished rework inbound

Append the following test methods inside ReturnWarehouseFlowTest, after test_return_rework_outbound_deducts_return_lot_and_creates_rework_order:

    def test_finished_rework_inbound_adds_finished_inventory_and_trace(self) -> None:
        from decimal import Decimal

        from app.api.routes.inventory import create_warehouse_inbound
        from app.api.routes.returns import create_return_order, create_return_rework_outbound
        from app.models.master_data import BomItem, StockBalance, Warehouse
        from app.models.operations import InventoryTxn, ReturnDisposition, StockLot, WarehouseLocation
        from app.schemas.operations import ReturnDispositionCreate, ReturnItemCreate, ReturnOrderCreate, WarehouseInboundCreate

        class Context:
            user = type("User", (), {"id": 1})()

        seeded = self.seed_delivery_trace()
        now = datetime.now(UTC)
        raw_warehouse = Warehouse(
            id=4,
            warehouse_code="WH-RAW-01",
            warehouse_name="原材料库",
            warehouse_type="RAW",
            status="ACTIVE",
            created_at=now,
            updated_at=now,
        )
        raw_location = WarehouseLocation(
            id=4,
            warehouse_id=4,
            location_code="RAW-A-01",
            location_name="原料库位",
            is_default=1,
            is_locked=0,
            status="ACTIVE",
            created_at=now,
            updated_at=now,
        )
        bom_item = BomItem(
            id=1,
            bom_id=1,
            seq_no=1,
            material_item_id=1,
            usage_type="WEIGHT",
            qty_per_piece=Decimal("0"),
            net_weight_per_piece_kg=Decimal("2"),
            gross_weight_per_piece_kg=Decimal("2.5"),
            loss_rate=Decimal("0"),
            issue_over_tolerance_rate=Decimal("0"),
            created_at=now,
            updated_at=now,
        )
        trace_lot = StockLot(
            id=99,
            lot_no="JH_冷轧钢板00001_0002",
            lot_role="INBOUND_RAW",
            item_id=1,
            warehouse_id=4,
            location_id=4,
            source_doc_type="OPENING_STOCK",
            source_doc_id=99,
            inbound_qty=0,
            inbound_weight_kg=300,
            remaining_qty=0,
            remaining_weight_kg=120,
            locked_qty=0,
            locked_weight_kg=0,
            unit_cost=Decimal("6"),
            quality_status="PASS",
            status="AVAILABLE",
            created_at=now,
            updated_at=now,
        )
        self.db.add_all([raw_warehouse, raw_location, bom_item, trace_lot])
        self.db.commit()

        create_return_order(
            ReturnOrderCreate(
                customer_id=seeded["customer_id"],
                delivery_id=seeded["delivery_id"],
                warehouse_id=seeded["return_warehouse_id"],
                return_reason="可返工",
                items=[
                    ReturnItemCreate(
                        delivery_item_id=seeded["delivery_item_id"],
                        product_item_id=seeded["product_item_id"],
                        return_qty=3,
                        return_weight_kg=Decimal("7.5"),
                    )
                ],
            ),
            context=Context(),
            db=self.db,
        )
        disposition_read = create_return_rework_outbound(
            ReturnDispositionCreate(return_item_id=1, disposition_type="REWORK", extra_cost_amount=18, remark="返工出库"),
            context=Context(),
            db=self.db,
        )
        created_lot = create_warehouse_inbound(
            WarehouseInboundCreate(
                biz_type="REWORK_IN",
                item_id=seeded["product_item_id"],
                warehouse_id=seeded["finished_warehouse_id"],
                inbound_qty=2,
                inbound_weight_kg=0,
                source_doc_type="RETURN_DISPOSITION",
                source_doc_id=disposition_read.return_disposition_id,
                source_line_id=1,
                source_lot_ids=[99],
                provider_name="返工班组A",
                remark="返工完成入库",
            ),
            context=Context(),
            db=self.db,
        )

        disposition = self.db.scalar(select(ReturnDisposition))
        lot = self.db.scalar(select(StockLot).where(StockLot.id == created_lot.lot_id))
        balance = self.db.scalar(
            select(StockBalance).where(
                StockBalance.item_id == seeded["product_item_id"],
                StockBalance.warehouse_id == seeded["finished_warehouse_id"],
            )
        )
        txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "REWORK_IN"))

        self.assertEqual(lot.source_doc_type, "RETURN_DISPOSITION")
        self.assertEqual(lot.source_doc_id, disposition.id)
        self.assertEqual(lot.source_line_id, 1)
        self.assertEqual(lot.source_material_lot_id, 99)
        self.assertEqual(lot.source_material_sub_batch_no, "JH_冷轧钢板00001_0002")
        self.assertIn("来源库存批次号JH_冷轧钢板00001_0002", lot.source_material_summary)
        self.assertEqual(lot.inbound_qty, Decimal("2.000000"))
        self.assertEqual(lot.remaining_qty, Decimal("2.000000"))
        self.assertEqual(lot.inbound_weight_kg, Decimal("0.000000"))
        self.assertEqual(lot.unit_cost, Decimal("15.000000"))
        self.assertEqual(balance.qty_on_hand, Decimal("2.000000"))
        self.assertEqual(balance.weight_on_hand_kg, Decimal("0.000000"))
        self.assertEqual(txn.qty_change, Decimal("2.000000"))
        self.assertEqual(txn.weight_change_kg, Decimal("0.000000"))
        self.assertEqual(txn.source_doc_type, "RETURN_DISPOSITION")
        self.assertEqual(txn.source_doc_id, disposition.id)
        self.assertEqual(txn.amount_basis, "QTY")

    def test_return_disposition_query_derives_rework_inbound_remaining_qty(self) -> None:
        from decimal import Decimal

        from app.api.routes.inventory import create_warehouse_inbound
        from app.api.routes.returns import create_return_order, create_return_rework_outbound
        from app.models.master_data import BomItem, Warehouse
        from app.models.operations import StockLot, WarehouseLocation
        from app.schemas.operations import ReturnDispositionCreate, ReturnItemCreate, ReturnOrderCreate, WarehouseInboundCreate
        from app.services.operations import get_return_dispositions_query

        class Context:
            user = type("User", (), {"id": 1})()

        seeded = self.seed_delivery_trace()
        now = datetime.now(UTC)
        self.db.add_all([
            Warehouse(id=4, warehouse_code="WH-RAW-01", warehouse_name="原材料库", warehouse_type="RAW", status="ACTIVE", created_at=now, updated_at=now),
            WarehouseLocation(id=4, warehouse_id=4, location_code="RAW-A-01", location_name="原料库位", is_default=1, is_locked=0, status="ACTIVE", created_at=now, updated_at=now),
            BomItem(id=1, bom_id=1, seq_no=1, material_item_id=1, usage_type="WEIGHT", qty_per_piece=0, net_weight_per_piece_kg=2, gross_weight_per_piece_kg=2.5, loss_rate=0, issue_over_tolerance_rate=0, created_at=now, updated_at=now),
            StockLot(id=99, lot_no="JH_冷轧钢板00001_0002", lot_role="INBOUND_RAW", item_id=1, warehouse_id=4, location_id=4, source_doc_type="OPENING_STOCK", source_doc_id=99, inbound_qty=0, inbound_weight_kg=300, remaining_qty=0, remaining_weight_kg=120, locked_qty=0, locked_weight_kg=0, unit_cost=6, quality_status="PASS", status="AVAILABLE", created_at=now, updated_at=now),
        ])
        self.db.commit()
        create_return_order(
            ReturnOrderCreate(
                customer_id=seeded["customer_id"],
                delivery_id=seeded["delivery_id"],
                warehouse_id=seeded["return_warehouse_id"],
                return_reason="可返工",
                items=[ReturnItemCreate(delivery_item_id=seeded["delivery_item_id"], product_item_id=seeded["product_item_id"], return_qty=3, return_weight_kg=Decimal("7.5"))],
            ),
            context=Context(),
            db=self.db,
        )
        disposition = create_return_rework_outbound(
            ReturnDispositionCreate(return_item_id=1, disposition_type="REWORK", remark="返工出库"),
            context=Context(),
            db=self.db,
        )
        create_warehouse_inbound(
            WarehouseInboundCreate(
                biz_type="REWORK_IN",
                item_id=seeded["product_item_id"],
                warehouse_id=seeded["finished_warehouse_id"],
                inbound_qty=1,
                source_doc_type="RETURN_DISPOSITION",
                source_doc_id=disposition.return_disposition_id,
                source_line_id=1,
                source_lot_ids=[99],
            ),
            context=Context(),
            db=self.db,
        )

        row = self.db.execute(get_return_dispositions_query(limit=10)).mappings().first()

        self.assertEqual(row["product_item_id"], seeded["product_item_id"])
        self.assertEqual(row["return_qty"], Decimal("3.000000"))
        self.assertEqual(row["rework_inbound_qty"], Decimal("1.000000"))
        self.assertEqual(row["remaining_rework_inbound_qty"], Decimal("2.000000"))
        self.assertEqual(row["rework_inbound_status"], "部分回库")

    def test_finished_rework_inbound_rejects_over_remaining_or_wrong_source(self) -> None:
        from decimal import Decimal

        from fastapi import HTTPException

        from app.api.routes.inventory import create_warehouse_inbound
        from app.api.routes.returns import create_return_order, create_return_rework_outbound
        from app.schemas.operations import ReturnDispositionCreate, ReturnItemCreate, ReturnOrderCreate, WarehouseInboundCreate

        class Context:
            user = type("User", (), {"id": 1})()

        seeded = self.seed_delivery_trace()
        create_return_order(
            ReturnOrderCreate(
                customer_id=seeded["customer_id"],
                delivery_id=seeded["delivery_id"],
                warehouse_id=seeded["return_warehouse_id"],
                return_reason="可返工",
                items=[ReturnItemCreate(delivery_item_id=seeded["delivery_item_id"], product_item_id=seeded["product_item_id"], return_qty=3, return_weight_kg=Decimal("7.5"))],
            ),
            context=Context(),
            db=self.db,
        )
        disposition = create_return_rework_outbound(
            ReturnDispositionCreate(return_item_id=1, disposition_type="REWORK", remark="返工出库"),
            context=Context(),
            db=self.db,
        )

        with self.assertRaises(HTTPException) as missing_source:
            create_warehouse_inbound(
                WarehouseInboundCreate(
                    biz_type="REWORK_IN",
                    item_id=seeded["product_item_id"],
                    warehouse_id=seeded["finished_warehouse_id"],
                    inbound_qty=1,
                ),
                context=Context(),
                db=self.db,
            )
        self.assertEqual(missing_source.exception.detail, "返工入库必须选择返工出库记录")

        with self.assertRaises(HTTPException) as over_remaining:
            create_warehouse_inbound(
                WarehouseInboundCreate(
                    biz_type="REWORK_IN",
                    item_id=seeded["product_item_id"],
                    warehouse_id=seeded["finished_warehouse_id"],
                    inbound_qty=4,
                    source_doc_type="RETURN_DISPOSITION",
                    source_doc_id=disposition.return_disposition_id,
                    source_line_id=1,
                ),
                context=Context(),
                db=self.db,
            )
        self.assertEqual(over_remaining.exception.detail, "返工入库数量不能超过剩余可回库数量")
  • Step 2: Run backend tests and verify failure

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
PYTHONPATH=. .venv/bin/pytest tests/test_return_warehouse_flow.py -q

Expected: FAIL. The first failure should mention that REWORK_IN is unknown or not allowed, or that the new query fields such as rework_inbound_qty are missing.

Task 2: Backend Implementation

Files:

  • Modify: backend/app/schemas/operations.py

  • Modify: backend/app/services/operations.py

  • Modify: backend/app/api/routes/inventory.py

  • Test: backend/tests/test_return_warehouse_flow.py

  • Step 1: Extend schemas for rework inbound and disposition summaries

In backend/app/schemas/operations.py, add REWORK_IN to BIZ_TYPE_INBOUND after PRODUCTION_FINISHED:

    "PRODUCTION_FINISHED",# 生产入库(成品)
    "REWORK_IN",          # 返工入库(成品)

Extend ReturnDispositionRead with these fields:

    product_item_id: int | None = None
    return_qty: float = 0
    return_weight_kg: float = 0
    rework_inbound_qty: float = 0
    remaining_rework_inbound_qty: float = 0
    rework_inbound_status: str | None = None
  • Step 2: Derive rework inbound quantities in the return disposition query

In backend/app/services/operations.py, replace get_return_dispositions_query() with this function body:

def get_return_dispositions_query(limit: int = 100) -> Select:
    rework_work_order = aliased(WorkOrder)
    rework_inbound_summary = (
        select(
            StockLot.source_doc_id.label("return_disposition_id"),
            func.coalesce(func.sum(StockLot.inbound_qty), 0).label("rework_inbound_qty"),
        )
        .where(
            StockLot.source_doc_type == "RETURN_DISPOSITION",
            StockLot.lot_role == "REWORK_FINISHED",
        )
        .group_by(StockLot.source_doc_id)
        .subquery()
    )
    rework_inbound_qty = func.coalesce(rework_inbound_summary.c.rework_inbound_qty, 0)
    remaining_qty = func.coalesce(ReturnItem.return_qty, 0) - rework_inbound_qty
    rework_status = case(
        (ReturnDisposition.disposition_type != "REWORK", None),
        (rework_inbound_qty <= 0, "待回库"),
        (remaining_qty <= 0, "已回库"),
        else_="部分回库",
    )
    return (
        select(
            ReturnDisposition.id.label("return_disposition_id"),
            ReturnDisposition.disposition_no.label("disposition_no"),
            ReturnDisposition.return_item_id.label("return_item_id"),
            ReturnOrder.return_no.label("return_no"),
            ReturnItem.product_item_id.label("product_item_id"),
            Item.item_code.label("product_code"),
            Item.item_name.label("product_name"),
            ReturnItem.return_qty.label("return_qty"),
            ReturnItem.return_weight_kg.label("return_weight_kg"),
            rework_inbound_qty.label("rework_inbound_qty"),
            remaining_qty.label("remaining_rework_inbound_qty"),
            rework_status.label("rework_inbound_status"),
            ReturnDisposition.disposition_type.label("disposition_type"),
            ReturnDisposition.rework_work_order_id.label("rework_work_order_id"),
            rework_work_order.work_order_no.label("rework_work_order_no"),
            ReturnDisposition.scrap_weight_kg.label("scrap_weight_kg"),
            ReturnDisposition.extra_cost_amount.label("extra_cost_amount"),
            ReturnDisposition.scrap_sale_amount.label("scrap_sale_amount"),
            ReturnDisposition.closed_at.label("closed_at"),
            ReturnDisposition.status.label("status"),
            ReturnDisposition.remark.label("remark"),
        )
        .join(ReturnItem, ReturnItem.id == ReturnDisposition.return_item_id)
        .join(ReturnOrder, ReturnOrder.id == ReturnItem.return_order_id)
        .join(Item, Item.id == ReturnItem.product_item_id)
        .outerjoin(rework_work_order, rework_work_order.id == ReturnDisposition.rework_work_order_id)
        .outerjoin(rework_inbound_summary, rework_inbound_summary.c.return_disposition_id == ReturnDisposition.id)
        .order_by(ReturnDisposition.id.desc())
        .limit(limit)
    )
  • Step 3: Add rework inbound validation helpers

In backend/app/api/routes/inventory.py, extend the operations import:

from app.models.operations import (
    InventoryTxn,
    ProcessRoute,
    ProcessRouteOperation,
    PurchaseOrder,
    PurchaseOrderItem,
    PurchaseReceipt,
    PurchaseReceiptItem,
    PurchaseReturn,
    PurchaseReturnItem,
    ReturnDisposition,
    ReturnItem,
    StockLot,
    Stocktake,
    StocktakeLine,
    StocktakeWarehouse,
    WarehouseLocation,
    WorkOrder,
)

Add these helper functions above create_warehouse_inbound():

def _get_return_disposition_for_rework_inbound(
    db: Session,
    payload: WarehouseInboundCreate,
    product_item_id: int,
    inbound_qty: Decimal,
) -> tuple[ReturnDisposition, ReturnItem, Decimal]:
    if str(payload.source_doc_type or "").upper() != "RETURN_DISPOSITION" or not payload.source_doc_id:
        raise HTTPException(status_code=400, detail="返工入库必须选择返工出库记录")
    disposition = db.get(ReturnDisposition, int(payload.source_doc_id))
    if not disposition:
        raise HTTPException(status_code=404, detail="返工出库记录不存在")
    if str(disposition.disposition_type or "").upper() != "REWORK":
        raise HTTPException(status_code=400, detail="返工入库只能关联返工出库记录")
    if str(disposition.status or "").upper() != "CLOSED":
        raise HTTPException(status_code=400, detail="返工出库记录未完成,不能返工入库")
    return_item = db.get(ReturnItem, int(disposition.return_item_id))
    if not return_item:
        raise HTTPException(status_code=404, detail="退货明细不存在")
    if int(return_item.product_item_id) != int(product_item_id):
        raise HTTPException(status_code=400, detail="该返工记录不属于当前产品")
    inbound_before = db.scalar(
        select(func.coalesce(func.sum(StockLot.inbound_qty), 0)).where(
            StockLot.source_doc_type == "RETURN_DISPOSITION",
            StockLot.source_doc_id == disposition.id,
            StockLot.lot_role == "REWORK_FINISHED",
        )
    )
    returned_before = to_decimal(inbound_before)
    remaining_qty = to_decimal(return_item.return_qty) - returned_before
    if inbound_qty <= 0:
        raise HTTPException(status_code=400, detail="返工入库数量必须大于0")
    if inbound_qty > remaining_qty:
        raise HTTPException(status_code=400, detail="返工入库数量不能超过剩余可回库数量")
    return disposition, return_item, remaining_qty


def _calculate_finished_material_unit_cost(item: Item, source_lots: list[StockLot]) -> Decimal:
    if not source_lots:
        return Decimal("0")
    total_available_weight = sum((to_decimal(lot.remaining_weight_kg) for lot in source_lots), Decimal("0"))
    if total_available_weight <= 0:
        weighted_unit_cost = sum((to_decimal(lot.unit_cost, "0.0001") for lot in source_lots), Decimal("0")) / Decimal(len(source_lots))
    else:
        weighted_unit_cost = sum(
            (to_decimal(lot.unit_cost, "0.0001") * to_decimal(lot.remaining_weight_kg) for lot in source_lots),
            Decimal("0"),
        ) / total_available_weight
    return to_decimal(weighted_unit_cost * to_decimal(item.unit_weight_kg), "0.0001")
  • Step 4: Wire REWORK_IN into inbound maps and quantity logic

In backend/app/api/routes/inventory.py, make these exact mapping changes:

_INBOUND_BIZ_MAP = {
    "OPENING":             ("OPENING_STOCK",       "OPENING_IN",          "OPENING_STOCK",     Decimal("0")),
    "CUSTOMER_SUPPLIED":   ("CUSTOMER_SUPPLIED_RAW","CUSTOMER_SUPPLIED_IN","CUSTOMER_SUPPLIED_IN", Decimal("0")),
    "PRODUCTION_SURPLUS":  ("PRODUCTION_SURPLUS",  "PRODUCTION_SURPLUS_IN","PRODUCTION_SURPLUS_IN", None),
    "OUTSOURCING_SURPLUS": ("OUTSOURCING_SURPLUS", "OUTSOURCING_SURPLUS_IN","OUTSOURCING_SURPLUS_IN", None),
    "PRODUCTION_SCRAP_IN": ("PRODUCTION_SCRAP",    "PRODUCTION_SCRAP_IN", "PRODUCTION_SCRAP_IN", None),
    "OUTSOURCING_SCRAP_IN":("OUTSOURCING_SCRAP",   "OUTSOURCING_SCRAP_IN","OUTSOURCING_SCRAP_IN", None),
    "WIP_IN":              ("WIP_STAGING",         "WIP_IN",              "WIP_IN",            None),
    "OUTSOURCING_IN":      ("OUTSOURCING_IN",      "OUTSOURCING_IN",      "OUTSOURCING_IN",    None),
    "PRODUCTION_FINISHED": ("INBOUND_FINISHED",    "PRODUCTION_FINISHED_IN","PRODUCTION_FINISHED_IN", None),
    "REWORK_IN":           ("REWORK_FINISHED",     "REWORK_IN",           "RETURN_DISPOSITION", None),
}

Update finished inbound allowance:

    "FINISHED": {"PRODUCTION_FINISHED", "OUTSOURCING_IN", "REWORK_IN", "OPENING"},

Add the Chinese label:

    "REWORK_IN": "返工入库",

In create_warehouse_inbound(), add this flag next to is_finished_outsourcing_quantity_inbound:

    is_finished_rework_quantity_inbound = warehouse_type == "FINISHED" and biz_type == "REWORK_IN"

Update product source trace detection:

    is_product_raw_source_lot_trace_inbound = (
        is_semi_source_lot_trace_inbound
        or is_finished_outsourcing_quantity_inbound
        or is_finished_rework_quantity_inbound
        or is_outsourcing_scrap_source_lot_trace_inbound
    )

Update quantity validation:

    elif is_finished_outsourcing_quantity_inbound or is_finished_rework_quantity_inbound:
        if inbound_qty <= 0:
            label = "成品返工入库" if is_finished_rework_quantity_inbound else "成品委外入库"
            raise HTTPException(status_code=400, detail=f"{label}数量必须大于0")
        inbound_weight = Decimal("0")

After product_source_lots is resolved, add:

    rework_disposition: ReturnDisposition | None = None
    rework_return_item: ReturnItem | None = None
    if is_finished_rework_quantity_inbound:
        rework_disposition, rework_return_item, _ = _get_return_disposition_for_rework_inbound(
            db,
            payload,
            payload.item_id,
            inbound_qty,
        )
        operation_source_doc_type = "RETURN_DISPOSITION"
        operation_source_doc_id = rework_disposition.id
        operation_source_line_id = int(rework_return_item.id)
        if payload.unit_cost is None and product_source_lots:
            unit_cost = _calculate_finished_material_unit_cost(item, product_source_lots)

Update lot number generation:

        elif biz_type == "REWORK_IN":
            lot_no = _build_surplus_lot_no(db, item, warehouse, "FGFW")

Update inventory transaction amount_basis:

        amount_basis="QTY" if (is_aux_quantity_inbound or is_finished_outsourcing_quantity_inbound or is_finished_rework_quantity_inbound) else "WEIGHT",
  • Step 5: Run backend tests and verify pass

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
PYTHONPATH=. .venv/bin/pytest tests/test_return_warehouse_flow.py -q

Expected: PASS for test_return_warehouse_flow.py.

  • Step 6: Run adjacent backend regression tests

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
PYTHONPATH=. .venv/bin/pytest tests/test_sales_order_delivery_trace.py tests/test_scrap_warehouse_flow.py tests/test_warehouse_transaction_ledger.py -q

Expected: PASS. These cover finished stock trace display, return/scrap warehouse interactions, and inventory ledger queries.

Task 3: Frontend Failing Static Tests

Files:

  • Create: frontend/scripts/test-finished-rework-inbound.mjs

  • Modify: frontend/src/views/InventoryLedgerView.vue

  • Modify: frontend/src/utils/dictionaries.js

  • Step 1: Create the frontend guard script

Create frontend/scripts/test-finished-rework-inbound.mjs:

import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";

const root = process.cwd();
const inventoryPath = path.join(root, "src/views/InventoryLedgerView.vue");
const dictionariesPath = path.join(root, "src/utils/dictionaries.js");
const backendInventoryPath = path.join(root, "../backend/app/api/routes/inventory.py");
const operationsSchemaPath = path.join(root, "../backend/app/schemas/operations.py");

const inventory = fs.readFileSync(inventoryPath, "utf8");
const dictionaries = fs.readFileSync(dictionariesPath, "utf8");
const backendInventory = fs.readFileSync(backendInventoryPath, "utf8");
const operationsSchema = fs.readFileSync(operationsSchemaPath, "utf8");

assert.match(
  inventory,
  /makeOperation\("finished-rework-in",\s*"in",\s*"REWORK_IN",\s*"返工入库"/,
  "finished warehouse should expose rework inbound operation"
);
assert.match(
  inventory,
  /const returnDispositions = ref\(\[\]\);/,
  "inventory ledger should keep return disposition rows for rework inbound"
);
assert.match(
  inventory,
  /fetchResource\("\/returns\/dispositions\?limit=500", \[\]\)/,
  "inventory ledger should load return dispositions"
);
assert.match(
  inventory,
  /const isFinishedReworkInbound = computed\(\(\) =>[\s\S]*activeWarehouseType\.value === "FINISHED"[\s\S]*activeOperation\.value\?\.bizType === "REWORK_IN"/,
  "finished rework inbound should have an explicit computed flag"
);
assert.match(
  inventory,
  /const reworkInboundDispositionOptions = computed\(\(\) =>[\s\S]*remaining_rework_inbound_qty/,
  "finished rework inbound should filter disposition options by remaining rework inbound quantity"
);
assert.match(
  inventory,
  /返工出库记录[\s\S]*reworkInboundDispositionOptions/,
  "generic drawer should show a rework disposition selector"
);
assert.match(
  inventory,
  /isProductTraceOnlySourceLotInbound\.value \|\| isOutsourcingScrapInbound\.value[\s\S]*selectedTraceSourceLotIds/,
  "finished rework inbound should reuse product raw-material source lot trace submission"
);
assert.match(
  inventory,
  /source_doc_type:\s*isFinishedReworkInbound\.value\s*\?\s*"RETURN_DISPOSITION"/,
  "finished rework inbound should submit RETURN_DISPOSITION as the source document"
);
assert.match(
  inventory,
  /source_doc_id:\s*isFinishedReworkInbound\.value\s*\?\s*Number\(selectedReworkInboundDisposition\.value\?\.return_disposition_id/,
  "finished rework inbound should submit selected return disposition id"
);
assert.match(
  dictionaries,
  /REWORK_IN:\s*"返工入库"/,
  "dictionary should show REWORK_IN as Chinese"
);
assert.match(
  backendInventory,
  /"REWORK_IN":\s*\("REWORK_FINISHED",\s*"REWORK_IN",\s*"RETURN_DISPOSITION",\s*None\)/,
  "backend should map REWORK_IN to return disposition source documents"
);
assert.match(
  operationsSchema,
  /"REWORK_IN",\s*# 返工入库/,
  "backend schema should allow REWORK_IN inbound business type"
);

console.log("finished rework inbound checks passed");
  • Step 2: Run the frontend guard and verify failure

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-finished-rework-inbound.mjs

Expected: FAIL because finished-rework-in, returnDispositions, or isFinishedReworkInbound do not exist yet.

Task 4: Frontend Implementation

Files:

  • Modify: frontend/src/views/InventoryLedgerView.vue

  • Modify: frontend/src/utils/dictionaries.js

  • Test: frontend/scripts/test-finished-rework-inbound.mjs

  • Step 1: Add the 返工入库 operation and state

In frontend/src/views/InventoryLedgerView.vue, add a ref beside returnItems:

const returnItems = ref([]);
const returnDispositions = ref([]);

In the finished inbound operation list, insert 返工入库 between 委外入库 and 期初入库:

      makeOperation("finished-production-in", "in", "PRODUCTION_FINISHED", "生产入库", "生产完工成品入库,并生成成品批次。", "completion"),
      makeOperation("finished-outsourcing-in", "in", "OUTSOURCING_IN", "委外入库", "委外加工完成后的成品入库。"),
      makeOperation("finished-rework-in", "in", "REWORK_IN", "返工入库", "客户退货返工完成后的成品回库。"),
      makeOperation("finished-opening-in", "in", "OPENING", "期初入库", "仓库初始化时导入的初始成品。")

In loadAll(), add returnDispositionRows to the destructured result and fetch list:

    returnItemRows,
    returnDispositionRows
    fetchResource("/returns/items?limit=500", []),
    fetchResource("/returns/dispositions?limit=500", [])

After returnItems.value = returnItemRows;, add:

  returnDispositions.value = returnDispositionRows;
  • Step 2: Add computed flags and option filtering

Add this computed next to isFinishedOutsourcingInbound:

const isFinishedReworkInbound = computed(() =>
  activeOperation.value?.direction === "in" &&
  activeWarehouseType.value === "FINISHED" &&
  activeOperation.value?.bizType === "REWORK_IN"
);

Update isProductTraceOnlySourceLotInbound:

const isProductTraceOnlySourceLotInbound = computed(() =>
  isSemiSourceLotTraceInbound.value || isFinishedOutsourcingInbound.value || isFinishedReworkInbound.value
);

Add these computed values near the return item computed values:

const reworkInboundDispositionOptions = computed(() => {
  const productItemId = Number(genericForm.item_id || 0);
  return returnDispositions.value
    .filter((item) => String(item.disposition_type || "").toUpperCase() === "REWORK")
    .filter((item) => String(item.status || "").toUpperCase() === "CLOSED")
    .filter((item) => Number(item.remaining_rework_inbound_qty || 0) > 0)
    .filter((item) => !productItemId || Number(item.product_item_id || 0) === productItemId)
    .sort((left, right) => Number(right.return_disposition_id || 0) - Number(left.return_disposition_id || 0));
});

const selectedReworkInboundDisposition = computed(() =>
  reworkInboundDispositionOptions.value.find((item) => Number(item.return_disposition_id) === Number(genericForm.source_doc_id)) || null
);

function formatReworkInboundDispositionOption(item) {
  const productText = item.product_name || item.product_code || "未命名产品";
  const returnText = item.return_no ? `退货单 ${item.return_no}` : "退货单未记录";
  const reworkText = item.disposition_no ? `返工 ${item.disposition_no}` : "返工记录";
  const remainingText = `可回库 ${formatQty(item.remaining_rework_inbound_qty || 0)}`;
  const statusText = item.rework_inbound_status || "待回库";
  return `${productText} · ${returnText} · ${reworkText} · ${remainingText} · ${statusText}`;
}
  • Step 3: Add the selector field in the generic drawer

In the generic drawer template, after the item select field and before the quantity/weight row, add:

        <label v-if="isFinishedReworkInbound" class="form-field">
          <span>返工出库记录</span>
          <select v-model.number="genericForm.source_doc_id" required>
            <option disabled value="">请选择返工出库记录</option>
            <option v-for="item in reworkInboundDispositionOptions" :key="item.return_disposition_id" :value="item.return_disposition_id">
              {{ formatReworkInboundDispositionOption(item) }}
            </option>
          </select>
        </label>
        <small v-if="isFinishedReworkInbound && selectedReworkInboundDisposition">
          剩余可回库 {{ formatQty(selectedReworkInboundDisposition.remaining_rework_inbound_qty) }}已回库 {{ formatQty(selectedReworkInboundDisposition.rework_inbound_qty) }}
        </small>

Update the manual batch-number hiding condition so rework inbound does not show the manual batch field:

        <div v-if="activeOperation?.direction !== 'out' && !isCustomerSuppliedInbound && !isSourceLotReturnInbound && !isSemiSourceLotTraceInbound && !isFinishedOutsourcingInbound && !isFinishedReworkInbound && !isProductionScrapInbound && !isOutsourcingScrapInbound" class="double-field">

Update the source/provider field condition so rework inbound can record the rework team:

        <label v-else-if="['OUTSOURCING_IN', 'OUTSOURCING_SURPLUS', 'REWORK_IN'].includes(activeOperation?.bizType)" class="form-field">
          <span>来源/提供方</span>
          <input v-model.trim="genericForm.provider_name" type="text" placeholder="委外厂、返工班组或来源说明" />
        </label>

Update the source-lot selector allow-unavailable binding:

            :allow-unavailable="isSourceLotReturnInbound || isSemiSourceLotTraceInbound || isFinishedReworkInbound || isOutsourcingScrapInbound"
  • Step 4: Validate and submit rework inbound

In submitGenericOperation(), inside the inbound branch before building inboundWeightKg, add:

      if (isFinishedReworkInbound.value) {
        const inboundQty = Number(genericForm.qty || 0);
        if (!selectedReworkInboundDisposition.value) {
          errorMessage.value = "请选择返工出库记录";
          submittingOperation.value = false;
          return;
        }
        if (!Number.isFinite(inboundQty) || inboundQty <= 0) {
          errorMessage.value = "成品返工入库数量必须大于0";
          submittingOperation.value = false;
          return;
        }
        const remainingQty = Number(selectedReworkInboundDisposition.value.remaining_rework_inbound_qty || 0);
        if (inboundQty > remainingQty) {
          errorMessage.value = "返工入库数量不能超过剩余可回库数量";
          submittingOperation.value = false;
          return;
        }
      }

Update inboundWeightKg:

      const inboundWeightKg = activeWarehouseType.value === "AUX" || isFinishedOutsourcingInbound.value || isFinishedReworkInbound.value
        ? 0
        : isSemiSourceLotTraceInbound.value && selectedSemiMeasureBasis.value === "PIECE"
          ? 0
          : Number(genericForm.weight_kg || 0);

Update inboundQty:

      const inboundQty = activeWarehouseType.value === "AUX" || isFinishedOutsourcingInbound.value || isFinishedReworkInbound.value
        ? Number(genericForm.qty || 0)
        : isSemiSourceLotTraceInbound.value && selectedSemiMeasureBasis.value === "PIECE"
          ? Number(genericForm.qty || 0)
          : 0;

Update unit_cost in the inbound payload:

        unit_cost: isSemiSourceLotTraceInbound.value || isFinishedReworkInbound.value ? null : activeOperation.value.bizType === "CUSTOMER_SUPPLIED" ? 0 : Number(genericForm.unit_cost || 0),

Update source document fields in the inbound payload:

        source_doc_type: isFinishedReworkInbound.value
          ? "RETURN_DISPOSITION"
          : isProductionScrapInbound.value
            ? "WORK_ORDER"
            : activeWarehouseType.value === "SEMI"
              ? "PRODUCT_ROUTE_OPERATION"
              : null,
        source_doc_id: isFinishedReworkInbound.value
          ? Number(selectedReworkInboundDisposition.value?.return_disposition_id || 0)
          : isProductionScrapInbound.value
            ? Number(selectedGenericWorkOrder.value.work_order_id)
            : activeWarehouseType.value === "SEMI"
              ? Number(selectedSourceDocId || 0)
              : null,
        source_line_id: isFinishedReworkInbound.value
          ? Number(selectedReworkInboundDisposition.value?.return_item_id || 0)
          : activeWarehouseType.value === "SEMI"
            ? Number(selectedSourceLineId || 0)
            : null,

Update source_material_summary branch:

            : isFinishedReworkInbound.value
              ? selectedReworkInboundDisposition.value?.disposition_no || genericForm.provider_name || null
  • Step 5: Add dictionary labels

In frontend/src/utils/dictionaries.js, add:

  REWORK_IN: "返工入库",

to INVENTORY_TXN_TYPE_LABELS, near other inbound labels. Add this to SOURCE_DOC_TYPE_LABELS if it is not already present:

  RETURN_DISPOSITION: "退货处置",
  • Step 6: Run frontend static tests

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-finished-rework-inbound.mjs
node scripts/test-inventory-ledger-return-warehouse.mjs
node scripts/test-return-delivery-line-labels.mjs
node scripts/test-return-warehouse-source-lot-display.mjs
node scripts/test-outsourcing-scrap-in-source-lots.mjs
node scripts/test-inventory-ledger-options.mjs

Expected: all scripts print their success messages and exit with code 0.

Task 5: End-to-End Build and Runtime Verification

Files:

  • Verify: backend/app/api/routes/inventory.py

  • Verify: frontend/src/views/InventoryLedgerView.vue

  • Step 1: Run backend targeted test set

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
PYTHONPATH=. .venv/bin/pytest tests/test_return_warehouse_flow.py tests/test_sales_order_delivery_trace.py tests/test_scrap_warehouse_flow.py tests/test_warehouse_transaction_ledger.py -q

Expected: PASS.

  • Step 2: Run frontend production build

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build

Expected: build completes without TypeScript/Vite errors.

  • Step 3: Browser smoke test

Open the existing local app in the Codex in-app browser at:

http://127.0.0.1:5173/

Manual checks:

  • Login if needed.

  • Navigate to 嘉恒仓库.

  • Select 成品库.

  • Confirm the inbound operation row shows 返工入库.

  • Click 返工入库.

  • Confirm the drawer shows 产品, 返工出库记录, 来源库存批次号, 入库数量, 来源/提供方, and 备注.

  • Select a product that has a rework outbound record.

  • Confirm the 返工出库记录 options show remaining rework-inbound quantity in Chinese.

  • Confirm the raw-material stock-lot selector uses JH... source lot numbers and not internal return/finished lot numbers.

  • Step 4: Record verification checkpoint

Because this workspace is not a git repository, record the final verification in the assistant response instead of committing. If executing in a git worktree, run:

git status --short
git add backend/app/schemas/operations.py backend/app/services/operations.py backend/app/api/routes/inventory.py backend/tests/test_return_warehouse_flow.py frontend/src/views/InventoryLedgerView.vue frontend/src/utils/dictionaries.js frontend/scripts/test-finished-rework-inbound.mjs docs/superpowers/plans/2026-06-03-finished-rework-inbound.md
git commit -m "feat: add finished rework inbound"

Expected in this mounted workspace: git status --short reports fatal: not a git repository, so no commit is created here.

Self-Review

  • Spec coverage: The plan covers the finished warehouse operation, mandatory return-disposition binding, product-filtered rework records, raw-material source stock lot traceability, quantity-based finished inventory updates, derived rework return status, Chinese labels, frontend UI, backend validation, and tests.
  • Placeholder scan: No TBD, TODO, or undefined "add later" steps are present. Each implementation step includes concrete code or exact commands.
  • Type consistency: REWORK_IN, RETURN_DISPOSITION, return_disposition_id, return_item_id, rework_inbound_qty, remaining_rework_inbound_qty, and rework_inbound_status are used consistently across schema, query, backend validation, frontend filtering, and tests.