ForgeFlow-ERP/docs/superpowers/plans/2026-06-03-finished-rework-inbound.md
2026-06-14 21:05:49 +08:00

1006 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 成品库返工入库 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
<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:
```vue
<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:
```vue
<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:
```vue
:allow-unavailable="isSourceLotReturnInbound || isSemiSourceLotTraceInbound || isFinishedReworkInbound || isOutsourcingScrapInbound"
```
- [ ] **Step 4: Validate and submit rework inbound**
In `submitGenericOperation()`, inside the inbound branch before building `inboundWeightKg`, add:
```javascript
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`:
```javascript
const inboundWeightKg = activeWarehouseType.value === "AUX" || isFinishedOutsourcingInbound.value || isFinishedReworkInbound.value
? 0
: isSemiSourceLotTraceInbound.value && selectedSemiMeasureBasis.value === "PIECE"
? 0
: Number(genericForm.weight_kg || 0);
```
Update `inboundQty`:
```javascript
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:
```javascript
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:
```javascript
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:
```javascript
: isFinishedReworkInbound.value
? selectedReworkInboundDisposition.value?.disposition_no || genericForm.provider_name || null
```
- [ ] **Step 5: Add dictionary labels**
In `frontend/src/utils/dictionaries.js`, add:
```javascript
REWORK_IN: "返工入库",
```
to `INVENTORY_TXN_TYPE_LABELS`, near other inbound labels. Add this to `SOURCE_DOC_TYPE_LABELS` if it is not already present:
```javascript
RETURN_DISPOSITION: "退货处置",
```
- [ ] **Step 6: Run frontend static tests**
Run:
```bash
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:
```bash
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:
```bash
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:
```text
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:
```bash
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.