42 KiB
成品库返工入库 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: addREWORK_INto inbound business types and expose return-disposition rework inbound summary fields. - Modify
backend/app/services/operations.py: extendget_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: addREWORK_INmapping, finished-warehouse allowance, validation againstRETURN_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 forREWORK_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_INinto 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, andrework_inbound_statusare used consistently across schema, query, backend validation, frontend filtering, and tests.