# 成品库返工入库 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`: ```python 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: ```bash 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`: ```python "PRODUCTION_FINISHED",# 生产入库(成品) "REWORK_IN", # 返工入库(成品) ``` Extend `ReturnDispositionRead` with these fields: ```python 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: ```python 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: ```python 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()`: ```python 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: ```python _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: ```python "FINISHED": {"PRODUCTION_FINISHED", "OUTSOURCING_IN", "REWORK_IN", "OPENING"}, ``` Add the Chinese label: ```python "REWORK_IN": "返工入库", ``` In `create_warehouse_inbound()`, add this flag next to `is_finished_outsourcing_quantity_inbound`: ```python is_finished_rework_quantity_inbound = warehouse_type == "FINISHED" and biz_type == "REWORK_IN" ``` Update product source trace detection: ```python 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: ```python 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: ```python 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: ```python elif biz_type == "REWORK_IN": lot_no = _build_surplus_lot_no(db, item, warehouse, "FGFW") ``` Update inventory transaction `amount_basis`: ```python 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: ```bash 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: ```bash 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`: ```javascript 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: ```bash 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`: ```javascript const returnItems = ref([]); const returnDispositions = ref([]); ``` In the finished inbound operation list, insert `返工入库` between `委外入库` and `期初入库`: ```javascript 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: ```javascript returnItemRows, returnDispositionRows ``` ```javascript fetchResource("/returns/items?limit=500", []), fetchResource("/returns/dispositions?limit=500", []) ``` After `returnItems.value = returnItemRows;`, add: ```javascript returnDispositions.value = returnDispositionRows; ``` - [ ] **Step 2: Add computed flags and option filtering** Add this computed next to `isFinishedOutsourcingInbound`: ```javascript const isFinishedReworkInbound = computed(() => activeOperation.value?.direction === "in" && activeWarehouseType.value === "FINISHED" && activeOperation.value?.bizType === "REWORK_IN" ); ``` Update `isProductTraceOnlySourceLotInbound`: ```javascript const isProductTraceOnlySourceLotInbound = computed(() => isSemiSourceLotTraceInbound.value || isFinishedOutsourcingInbound.value || isFinishedReworkInbound.value ); ``` Add these computed values near the return item computed values: ```javascript 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: ```vue 剩余可回库 {{ formatQty(selectedReworkInboundDisposition.remaining_rework_inbound_qty) }},已回库 {{ formatQty(selectedReworkInboundDisposition.rework_inbound_qty) }}。 ``` Update the manual batch-number hiding condition so rework inbound does not show the manual batch field: ```vue