1006 lines
42 KiB
Markdown
1006 lines
42 KiB
Markdown
# 成品库返工入库 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.
|