# 退货返工小程序报工闭环 Implementation Plan

> **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:** 将客户退货可返工品从 `退货库 -> 返工出库` 串到 `返工工单 -> 小程序报工 -> 成品库返工入库/废料库返工废料入库 -> 返工工单结单`，形成完整闭环。

**Architecture:** ERP 负责退货、返工工单、仓库回库和闭环结单；小程序继续复用现有扫码报工流程，只把原“原材料分批次号”选择项作为工单短码 `JHXXXX` 使用。返工工单不重新领原材料，返工成品/废料的来源追溯从原发货明细、退货明细、原成品批次和原材料库存批次链路继承。

**Tech Stack:** FastAPI, SQLAlchemy ORM, Pydantic schemas, Vue 3 Composition API, 微信小程序原生页面, pytest/unittest, Node.js static checks.

---

## Scope Check

本计划覆盖一个跨系统闭环，但每一部分都围绕同一个业务对象：`退货返工工单`。

本计划包含：

- ERP 退货库返工出库自动生成可被小程序识别的返工工单。
- 小程序工单短码 `JHXXXX` 下拉包含未结单返工工单。
- ERP 同步小程序报工时能稳定绑定返工工单。
- 成品库新增 `返工入库`，按返工工单最后工序良品数回库。
- 废料库新增 `返工废料入库`，按返工工单报废数入废料库。
- 返工工单只有在 `返工入库数量 + 返工废料入库数量` 覆盖返工出库数量后才能结单。

本计划不包含：

- 不新增小程序独立返工页面。
- 不让返工工单重新领原材料。
- 不把退货返工当普通原材料生产工单处理。
- 不引入新的数据库表；优先复用 `pp_work_order`、`rt_return_disposition`、`wh_stock_lot`、`wh_inventory_txn`、`pp_completion_receipt`。

当前工作目录 `/Users/souplearn/Gitlab/py/ForgeFlow-ERP` 不是 Git 仓库；本计划中的 commit 步骤在当前挂载目录执行时改为验证检查。如果在真实 Git worktree 中执行，应按任务提交。

---

## File Structure

### ERP Backend

- Modify `backend/app/services/operations.py`
  - 新增共享工单号生成 helper。
  - 修改 `create_rework_work_order()`，让返工工单号符合 `20260001-产品名` 规则。
  - 扩展 `get_return_dispositions_query()`，返回返工工单、已报工、已回库、已入废料和剩余待处理数量。

- Modify `backend/app/api/routes/production.py`
  - 普通生产工单和返工工单共用年度递增工单号规则。
  - 小程序同步报工继续支持 `JHXXXX` 绑定返工工单。
  - 成品入库支持返工工单：返工工单不要求原材料领用批次，来源追溯从退货/发货链路继承。
  - 成品返工入库使用清晰库存流水类型 `REWORK_FINISHED_IN`，中文展示为 `返工入库`。

- Modify `backend/app/api/routes/inventory.py`
  - 新增废料库入库业务类型 `REWORK_SCRAP_IN`。
  - 按返工工单报废数控制可入废料数量。
  - 写入 `返工废料入库` 库存批次和流水。
  - 支持返工工单在成品回库和废料回库完成后结单。

- Modify `backend/app/schemas/operations.py`
  - 增加字典值、响应字段和入库 payload 兼容字段。
  - `ReturnDispositionRead` 增加返工闭环展示字段。

- Modify `backend/tests/test_return_warehouse_flow.py`
  - 增加退货返工工单、小程序短码、返工成品入库、返工废料入库、结单校验的后端测试。

### ERP Frontend

- Modify `frontend/src/views/InventoryLedgerView.vue`
  - 成品库入库区新增 `返工入库`。
  - 废料库入库区新增 `返工废料入库`。
  - 两个入口都必须先选择返工工单。
  - 展示可返工入库数量、可返工废料入库数量、已报工良品/废品、已回库数量、已入废料数量。

- Modify `frontend/src/utils/dictionaries.js`
  - 增加 `REWORK_FINISHED_IN: "返工入库"`。
  - 增加 `REWORK_SCRAP_IN: "返工废料入库"`。

- Create `frontend/scripts/test-return-rework-closed-loop.mjs`
  - 静态检查 ERP 前端是否出现新操作、中文字典、API payload 字段。

### Miniapp Backend

- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reports.py`
  - 保持现有 `/api/reports/draft` 流程。
  - 确认 `_raw_material_batch_options()` 返回普通工单和返工工单。
  - 排除已结单、取消、关闭的返工工单。
  - 可选增加接口响应中的显示名称，但本计划默认小程序仍显示 `JHXXXX`。

- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_work_order_batch_options.py`
  - 增加返工工单短码下拉测试。

### Miniapp Frontend

- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.wxml`
  - 将“原材料分批次号”相关展示文案改为 `工单号` 或 `生产/返工工单号`。

- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.js`
  - 保持字段名 `rawMaterialBatchNo` 不变，避免改动小程序后端和 ERP 同步字段。
  - 校验提示从“请选择原材料分批次号”改为“请选择工单号”。

---

## Task 1: ERP Backend Failing Tests

**Files:**
- Modify: `backend/tests/test_return_warehouse_flow.py`

- [ ] **Step 1: Add tests for rework work-order creation**

Add this test method inside `ReturnWarehouseFlowTest`:

```python
    def test_return_rework_outbound_creates_miniapp_visible_rework_work_order(self) -> None:
        from decimal import Decimal

        from app.api.routes.returns import create_return_order, create_return_rework_outbound
        from app.models.operations import ReturnDisposition, WorkOrder
        from app.schemas.operations import ReturnDispositionCreate, ReturnItemCreate, ReturnOrderCreate

        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=10,
                        return_weight_kg=Decimal("25"),
                    )
                ],
            ),
            context=Context(),
            db=self.db,
        )

        result = create_return_rework_outbound(
            ReturnDispositionCreate(return_item_id=1, disposition_type="REWORK", extra_cost_amount=20, remark="返工出库"),
            context=Context(),
            db=self.db,
        )

        disposition = self.db.get(ReturnDisposition, result.return_disposition_id)
        work_order = self.db.get(WorkOrder, disposition.rework_work_order_id)

        self.assertIsNotNone(work_order)
        self.assertEqual(work_order.work_order_type, "REWORK")
        self.assertEqual(work_order.product_item_id, seeded["product_item_id"])
        self.assertEqual(work_order.planned_qty, Decimal("10.000000"))
        self.assertEqual(work_order.released_qty, Decimal("10.000000"))
        self.assertRegex(work_order.work_order_no, r"^20\\d{2}\\d{4}-冲压支架$")
        self.assertEqual(work_order.status, "RELEASED")
```

- [ ] **Step 2: Add test for finished rework inbound from miniapp reports**

Add this test method inside `ReturnWarehouseFlowTest`:

```python
    def test_finished_rework_inbound_uses_rework_work_order_good_qty(self) -> None:
        from decimal import Decimal

        from fastapi import HTTPException

        from app.api.routes.production import create_completion_receipt, create_operation_report
        from app.api.routes.returns import create_return_order, create_return_rework_outbound
        from app.models.operations import InventoryTxn, StockLot, WorkOrder, WorkOrderOperation
        from app.schemas.operations import (
            CompletionReceiptCreate,
            CompletionReceiptItemCreate,
            OperationReportCreate,
            ReturnDispositionCreate,
            ReturnItemCreate,
            ReturnOrderCreate,
        )

        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=10, return_weight_kg=Decimal("25"))],
            ),
            context=Context(),
            db=self.db,
        )
        disposition = create_return_rework_outbound(
            ReturnDispositionCreate(return_item_id=1, disposition_type="REWORK", extra_cost_amount=20, remark="返工出库"),
            context=Context(),
            db=self.db,
        )
        work_order = self.db.get(WorkOrder, disposition.rework_work_order_id)
        final_operation = self.db.scalars(
            select(WorkOrderOperation)
            .where(WorkOrderOperation.work_order_id == work_order.id)
            .order_by(WorkOrderOperation.seq_no.desc())
        ).first()

        create_operation_report(
            OperationReportCreate(
                work_order_id=work_order.id,
                work_order_operation_id=final_operation.id,
                employee_id=1,
                item_id=seeded["product_item_id"],
                report_qty=10,
                good_qty=8,
                scrap_qty=2,
            ),
            context=Context(),
            db=self.db,
        )

        receipt = create_completion_receipt(
            CompletionReceiptCreate(
                work_order_id=work_order.id,
                warehouse_id=seeded["finished_warehouse_id"],
                settle_work_order=False,
                remark="返工良品回库",
                items=[
                    CompletionReceiptItemCreate(
                        product_item_id=seeded["product_item_id"],
                        receipt_qty=8,
                        receipt_weight_kg=0,
                    )
                ],
            ),
            context=Context(),
            db=self.db,
        )

        lot = self.db.scalar(select(StockLot).where(StockLot.source_doc_id == receipt.completion_receipt_id).order_by(StockLot.id.desc()))
        txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == lot.id))

        self.assertEqual(receipt.total_receipt_qty, 8)
        self.assertEqual(lot.inbound_qty, Decimal("8.000000"))
        self.assertEqual(lot.source_doc_type, "REWORK_RECEIPT")
        self.assertIn("退货返工", lot.source_material_summary)
        self.assertEqual(txn.txn_type, "REWORK_FINISHED_IN")
        self.assertEqual(txn.amount_basis, "QTY")

        with self.assertRaises(HTTPException) as ctx:
            create_completion_receipt(
                CompletionReceiptCreate(
                    work_order_id=work_order.id,
                    warehouse_id=seeded["finished_warehouse_id"],
                    items=[CompletionReceiptItemCreate(product_item_id=seeded["product_item_id"], receipt_qty=1)],
                ),
                context=Context(),
                db=self.db,
            )
        self.assertEqual(ctx.exception.status_code, 400)
        self.assertIn("返工工单当前没有可返工入库数量", ctx.exception.detail)
```

- [ ] **Step 3: Add test for rework scrap inbound and settlement**

Add this test method inside `ReturnWarehouseFlowTest`:

```python
    def test_rework_scrap_inbound_plus_finished_inbound_can_settle_rework_order(self) -> None:
        from decimal import Decimal

        from app.api.routes.inventory import create_warehouse_inbound
        from app.api.routes.production import create_completion_receipt, create_operation_report
        from app.api.routes.returns import create_return_order, create_return_rework_outbound
        from app.models.operations import InventoryTxn, WorkOrder, WorkOrderOperation
        from app.schemas.operations import (
            CompletionReceiptCreate,
            CompletionReceiptItemCreate,
            OperationReportCreate,
            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=10, return_weight_kg=Decimal("25"))],
            ),
            context=Context(),
            db=self.db,
        )
        disposition = create_return_rework_outbound(
            ReturnDispositionCreate(return_item_id=1, disposition_type="REWORK", extra_cost_amount=20, remark="返工出库"),
            context=Context(),
            db=self.db,
        )
        work_order = self.db.get(WorkOrder, disposition.rework_work_order_id)
        final_operation = self.db.scalars(
            select(WorkOrderOperation)
            .where(WorkOrderOperation.work_order_id == work_order.id)
            .order_by(WorkOrderOperation.seq_no.desc())
        ).first()
        create_operation_report(
            OperationReportCreate(
                work_order_id=work_order.id,
                work_order_operation_id=final_operation.id,
                employee_id=1,
                item_id=seeded["product_item_id"],
                report_qty=10,
                good_qty=8,
                scrap_qty=2,
            ),
            context=Context(),
            db=self.db,
        )
        create_completion_receipt(
            CompletionReceiptCreate(
                work_order_id=work_order.id,
                warehouse_id=seeded["finished_warehouse_id"],
                items=[CompletionReceiptItemCreate(product_item_id=seeded["product_item_id"], receipt_qty=8)],
            ),
            context=Context(),
            db=self.db,
        )
        create_warehouse_inbound(
            WarehouseInboundCreate(
                biz_type="REWORK_SCRAP_IN",
                item_id=seeded["product_item_id"],
                warehouse_id=seeded["scrap_warehouse_id"],
                source_doc_type="WORK_ORDER",
                source_doc_id=work_order.id,
                inbound_qty=2,
                inbound_weight_kg=5,
                settle_work_order=True,
                settle_remark="返工良品和返工废料均已处理",
            ),
            context=Context(),
            db=self.db,
        )

        self.db.refresh(work_order)
        scrap_txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "REWORK_SCRAP_IN"))

        self.assertEqual(scrap_txn.qty_change, Decimal("2.000000"))
        self.assertEqual(scrap_txn.weight_change_kg, Decimal("5.000000"))
        self.assertEqual(work_order.status, "SETTLED")
        self.assertIn("返工工单已结单", work_order.remark)
```

- [ ] **Step 4: Run tests and confirm they fail**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
. .venv/bin/activate
python -m pytest tests/test_return_warehouse_flow.py -k "rework" -v
```

Expected before implementation:

```text
At least one test fails because rework work-order numbering, REWORK_FINISHED_IN, or REWORK_SCRAP_IN is not implemented yet.
```

---

## Task 2: Shared Work Order Numbering

**Files:**
- Modify: `backend/app/services/operations.py`
- Modify: `backend/app/api/routes/production.py`
- Test: `backend/tests/test_return_warehouse_flow.py`

- [ ] **Step 1: Add shared yearly work-order number helper**

Add near `create_rework_work_order()` in `backend/app/services/operations.py`:

```python
def build_yearly_product_work_order_no(db: Session, product: Item) -> str:
    year_part = date.today().strftime("%Y")
    existing_nos = db.scalars(
        select(WorkOrder.work_order_no).where(WorkOrder.work_order_no.like(f"{year_part}____-%"))
    ).all()
    sequences = [
        int(work_order_no[4:8])
        for work_order_no in existing_nos
        if len(work_order_no) > 8
        and work_order_no[:4] == year_part
        and work_order_no[4:8].isdigit()
        and work_order_no[8] == "-"
    ]
    next_sequence = (max(sequences) if sequences else 0) + 1
    return f"{year_part}{next_sequence:04d}-{product.item_name}"
```

- [ ] **Step 2: Use shared helper for rework work orders**

Change `create_rework_work_order()`:

```python
product = db.get(Item, product_item_id)
if not product:
    raise HTTPException(status_code=404, detail="返工工单产品不存在")
work_order = WorkOrder(
    work_order_no=build_yearly_product_work_order_no(db, product),
    work_order_type="REWORK",
    ...
)
```

- [ ] **Step 3: Use shared helper for normal work orders**

In `backend/app/api/routes/production.py`, import:

```python
from app.services.operations import build_yearly_product_work_order_no
```

Change `_build_work_order_no()`:

```python
def _build_work_order_no(db: Session, product: Item) -> str:
    return build_yearly_product_work_order_no(db, product)
```

- [ ] **Step 4: Run targeted test**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
. .venv/bin/activate
python -m pytest tests/test_return_warehouse_flow.py::ReturnWarehouseFlowTest::test_return_rework_outbound_creates_miniapp_visible_rework_work_order -v
```

Expected:

```text
PASSED
```

---

## Task 3: Return Disposition Closed-Loop Query

**Files:**
- Modify: `backend/app/services/operations.py`
- Modify: `backend/app/schemas/operations.py`
- Test: `backend/tests/test_return_warehouse_flow.py`

- [ ] **Step 1: Extend `ReturnDispositionRead`**

In `backend/app/schemas/operations.py`, add fields to `ReturnDispositionRead`:

```python
product_item_id: int | None = None
product_code: str | None = None
product_name: str | None = None
return_qty: float = 0
return_weight_kg: float = 0
rework_report_good_qty: float = 0
rework_report_scrap_qty: float = 0
rework_finished_inbound_qty: float = 0
rework_scrap_inbound_qty: float = 0
remaining_rework_finished_inbound_qty: float = 0
remaining_rework_scrap_inbound_qty: float = 0
rework_close_ready: bool = False
rework_close_status: str | None = None
```

- [ ] **Step 2: Add aggregation subqueries in `get_return_dispositions_query()`**

In `backend/app/services/operations.py`, add subqueries inside `get_return_dispositions_query()`:

```python
report_subquery = (
    select(
        OperationReport.work_order_id.label("work_order_id"),
        func.coalesce(func.sum(OperationReport.good_qty), 0).label("rework_report_good_qty"),
        func.coalesce(func.sum(OperationReport.scrap_qty), 0).label("rework_report_scrap_qty"),
    )
    .group_by(OperationReport.work_order_id)
    .subquery()
)

finished_inbound_subquery = (
    select(
        InventoryTxn.source_doc_id.label("work_order_id"),
        func.coalesce(func.sum(InventoryTxn.qty_change), 0).label("rework_finished_inbound_qty"),
    )
    .where(InventoryTxn.txn_type == "REWORK_FINISHED_IN")
    .group_by(InventoryTxn.source_doc_id)
    .subquery()
)

scrap_inbound_subquery = (
    select(
        InventoryTxn.source_doc_id.label("work_order_id"),
        func.coalesce(func.sum(InventoryTxn.qty_change), 0).label("rework_scrap_inbound_qty"),
    )
    .where(InventoryTxn.txn_type == "REWORK_SCRAP_IN")
    .group_by(InventoryTxn.source_doc_id)
    .subquery()
)
```

Join those subqueries on `ReturnDisposition.rework_work_order_id`.

- [ ] **Step 3: Return derived Chinese status**

Add selected labels:

```python
(func.coalesce(report_subquery.c.rework_report_good_qty, 0)).label("rework_report_good_qty"),
(func.coalesce(report_subquery.c.rework_report_scrap_qty, 0)).label("rework_report_scrap_qty"),
(func.coalesce(finished_inbound_subquery.c.rework_finished_inbound_qty, 0)).label("rework_finished_inbound_qty"),
(func.coalesce(scrap_inbound_subquery.c.rework_scrap_inbound_qty, 0)).label("rework_scrap_inbound_qty"),
case(
    (
        func.coalesce(finished_inbound_subquery.c.rework_finished_inbound_qty, 0)
        + func.coalesce(scrap_inbound_subquery.c.rework_scrap_inbound_qty, 0)
        >= ReturnItem.return_qty,
        "可结单",
    ),
    (
        func.coalesce(finished_inbound_subquery.c.rework_finished_inbound_qty, 0)
        + func.coalesce(scrap_inbound_subquery.c.rework_scrap_inbound_qty, 0)
        > 0,
        "部分处理",
    ),
    else_="待处理",
).label("rework_close_status"),
```

- [ ] **Step 4: Run return disposition query tests**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
. .venv/bin/activate
python -m pytest tests/test_return_warehouse_flow.py -k "rework" -v
```

Expected after Task 3:

```text
The work-order creation test passes; inbound-related tests still fail until Tasks 4 and 5.
```

---

## Task 4: Finished Rework Inbound

**Files:**
- Modify: `backend/app/api/routes/production.py`
- Modify: `backend/app/services/operations.py`
- Modify: `backend/app/schemas/operations.py`
- Test: `backend/tests/test_return_warehouse_flow.py`

- [ ] **Step 1: Add helper to detect rework work orders**

In `backend/app/api/routes/production.py`, add:

```python
def _is_rework_work_order(work_order: WorkOrder) -> bool:
    return str(work_order.work_order_type or "").upper() == "REWORK"
```

- [ ] **Step 2: Add helper to build source trace from return disposition**

Add:

```python
def _build_rework_source_material_summary(db: Session, work_order_id: int) -> dict[str, object | None]:
    row = db.execute(
        select(
            ReturnDisposition.id.label("return_disposition_id"),
            ReturnItem.source_lot_id.label("source_lot_id"),
            StockLot.source_material_lot_id.label("source_material_lot_id"),
            StockLot.source_material_sub_batch_no.label("source_material_sub_batch_no"),
            StockLot.source_material_summary.label("source_material_summary"),
            StockLot.unit_cost.label("unit_cost"),
        )
        .join(ReturnItem, ReturnItem.id == ReturnDisposition.return_item_id)
        .outerjoin(StockLot, StockLot.id == ReturnItem.source_lot_id)
        .where(ReturnDisposition.rework_work_order_id == work_order_id)
        .limit(1)
    ).mappings().first()
    if not row:
        raise HTTPException(status_code=400, detail="该返工工单缺少退货返工来源，不能返工入库")
    source_material_sub_batch_no = row["source_material_sub_batch_no"] or "退货返工来源批次未指定"
    source_material_summary = row["source_material_summary"] or "退货返工来源"
    return {
        "source_doc_type": "RETURN_DISPOSITION",
        "source_doc_id": row["return_disposition_id"],
        "source_material_lot_id": row["source_material_lot_id"],
        "source_material_sub_batch_no": source_material_sub_batch_no,
        "source_material_summary": f"退货返工；{source_material_summary}",
        "source_material_unit_cost": to_decimal(row["unit_cost"], "0.0001"),
    }
```

- [ ] **Step 3: Branch `create_completion_receipt()` for rework**

In `create_completion_receipt()`, replace:

```python
source_material = _build_work_order_source_material_summary(db, payload.work_order_id)
if not source_material["source_material_sub_batch_no"]:
    raise HTTPException(status_code=400, detail="该工单没有原材料库存批次领用记录，不能进行成品入库")
```

with:

```python
if _is_rework_work_order(work_order):
    source_material = _build_rework_source_material_summary(db, payload.work_order_id)
else:
    source_material = _build_work_order_source_material_summary(db, payload.work_order_id)
    if not source_material["source_material_sub_batch_no"]:
        raise HTTPException(status_code=400, detail="该工单没有原材料库存批次领用记录，不能进行成品入库")
```

- [ ] **Step 4: Use rework-specific source doc and transaction type**

Inside the completion receipt item loop, set:

```python
is_rework = _is_rework_work_order(work_order)
stock_source_doc_type = "REWORK_RECEIPT" if is_rework else "FG_RECEIPT"
txn_type = "REWORK_FINISHED_IN" if is_rework else "FG_IN"
txn_remark = "返工入库" if is_rework else "完工入库"
```

Use those values when creating `StockLot` and `InventoryTxn`:

```python
source_doc_type=stock_source_doc_type,
...
txn_type=txn_type,
...
remark=txn_remark,
source_doc_type="WORK_ORDER" if is_rework else "FG_RECEIPT",
source_doc_id=work_order.id if is_rework else completion.id,
```

- [ ] **Step 5: Add rework max inbound guard**

Before creating `CompletionReceipt`, after `max_receivable_qty`:

```python
if _is_rework_work_order(work_order) and total_receipt_qty > max_receivable_qty:
    raise HTTPException(status_code=400, detail=f"返工入库数量不能超过当前可返工入库数量 {max_receivable_qty}")
```

The existing `validate_inbound_with_tolerance()` can stay for normal production. For rework use exact upper limit:

```python
if _is_rework_work_order(work_order):
    if total_receipt_qty > max_receivable_qty:
        raise HTTPException(status_code=400, detail=f"返工入库数量不能超过当前可返工入库数量 {max_receivable_qty}")
else:
    validate_inbound_with_tolerance("成品入库数量", total_receipt_qty, max_receivable_qty)
```

- [ ] **Step 6: Run finished rework inbound test**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
. .venv/bin/activate
python -m pytest tests/test_return_warehouse_flow.py::ReturnWarehouseFlowTest::test_finished_rework_inbound_uses_rework_work_order_good_qty -v
```

Expected:

```text
PASSED
```

---

## Task 5: Rework Scrap Inbound

**Files:**
- Modify: `backend/app/api/routes/inventory.py`
- Modify: `backend/app/schemas/operations.py`
- Modify: `frontend/src/utils/dictionaries.js`
- Test: `backend/tests/test_return_warehouse_flow.py`

- [ ] **Step 1: Add inbound biz type**

In `backend/app/schemas/operations.py`, add `REWORK_SCRAP_IN` to `WAREHOUSE_INBOUND_BIZ_TYPES`:

```python
"REWORK_SCRAP_IN",    # 返工废料入库（废料库）
```

- [ ] **Step 2: Add inventory route mapping**

In `backend/app/api/routes/inventory.py`, update `_INBOUND_BIZ_MAP`:

```python
"REWORK_SCRAP_IN": ("REWORK_SCRAP", "REWORK_SCRAP_IN", "REWORK_SCRAP_IN", None),
```

Update `_ALLOWED_INBOUND_BY_WAREHOUSE_TYPE`:

```python
"SCRAP": {"PRODUCTION_SCRAP_IN", "OPENING", "OUTSOURCING_SCRAP_IN", "RETURN_SCRAP_IN", "REWORK_SCRAP_IN"},
```

Update `_INVENTORY_BIZ_TYPE_LABELS`:

```python
"REWORK_SCRAP_IN": "返工废料入库",
```

- [ ] **Step 3: Add helper to validate rework scrap inbound**

In `backend/app/api/routes/inventory.py`, add:

```python
def _get_rework_work_order_for_scrap_inbound(db: Session, payload: WarehouseInboundCreate) -> WorkOrder:
    if str(payload.source_doc_type or "").upper() != "WORK_ORDER" or not payload.source_doc_id:
        raise HTTPException(status_code=400, detail="返工废料入库请选择返工工单")
    work_order = db.get(WorkOrder, int(payload.source_doc_id))
    if not work_order:
        raise HTTPException(status_code=404, detail="返工工单不存在")
    if str(work_order.work_order_type or "").upper() != "REWORK":
        raise HTTPException(status_code=400, detail="返工废料入库只能选择返工工单")
    if str(work_order.status or "").upper() == "SETTLED":
        raise HTTPException(status_code=400, detail="该返工工单已结单，不能继续入废料库")
    return work_order
```

- [ ] **Step 4: Add max rework scrap inbound calculation**

Add:

```python
def _rework_scrap_inbound_qty(db: Session, work_order_id: int) -> Decimal:
    return to_decimal(
        db.scalar(
            select(func.coalesce(func.sum(InventoryTxn.qty_change), 0)).where(
                InventoryTxn.txn_type == "REWORK_SCRAP_IN",
                InventoryTxn.source_doc_type == "WORK_ORDER",
                InventoryTxn.source_doc_id == work_order_id,
            )
        ),
        "0.000001",
    )


def _rework_report_scrap_qty(db: Session, work_order_id: int) -> Decimal:
    return to_decimal(
        db.scalar(
            select(func.coalesce(func.sum(OperationReport.scrap_qty), 0)).where(OperationReport.work_order_id == work_order_id)
        ),
        "0.000001",
    )
```

- [ ] **Step 5: Validate and route `REWORK_SCRAP_IN`**

Inside `create_warehouse_inbound()` after `biz_type` normalization:

```python
rework_scrap_work_order = None
if biz_type == "REWORK_SCRAP_IN":
    rework_scrap_work_order = _get_rework_work_order_for_scrap_inbound(db, payload)
    reported_scrap_qty = _rework_report_scrap_qty(db, rework_scrap_work_order.id)
    already_scrap_inbound_qty = _rework_scrap_inbound_qty(db, rework_scrap_work_order.id)
    max_rework_scrap_qty = reported_scrap_qty - already_scrap_inbound_qty
    if inbound_qty <= 0:
        raise HTTPException(status_code=400, detail="返工废料入库数量必须大于0")
    if inbound_qty > max_rework_scrap_qty:
        raise HTTPException(status_code=400, detail=f"返工废料入库数量不能超过当前可入库废品数量 {max_rework_scrap_qty}")
    if inbound_weight <= 0:
        product = db.get(Item, payload.item_id)
        inbound_weight = inbound_qty * to_decimal(product.unit_weight_kg if product else 0, "0.000001")
```

Before `StockLot` creation, set:

```python
if biz_type == "REWORK_SCRAP_IN":
    operation_source_doc_type = "WORK_ORDER"
    operation_source_doc_id = rework_scrap_work_order.id
    source_material_summary = payload.source_material_summary or f"返工废料入库；工单：{rework_scrap_work_order.work_order_no}"
```

- [ ] **Step 6: Set `amount_basis` to quantity for rework scrap**

In `create_inventory_txn()` call:

```python
amount_basis="QTY" if (is_aux_quantity_inbound or is_finished_outsourcing_quantity_inbound or biz_type == "REWORK_SCRAP_IN") else "WEIGHT",
```

- [ ] **Step 7: Run rework scrap inbound test**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
. .venv/bin/activate
python -m pytest tests/test_return_warehouse_flow.py::ReturnWarehouseFlowTest::test_rework_scrap_inbound_plus_finished_inbound_can_settle_rework_order -v
```

Expected:

```text
PASSED
```

---

## Task 6: Rework Work Order Settlement Rule

**Files:**
- Modify: `backend/app/api/routes/inventory.py`
- Modify: `backend/app/api/routes/production.py`
- Test: `backend/tests/test_return_warehouse_flow.py`

- [ ] **Step 1: Add helper to summarize rework closure**

Add this helper to `backend/app/api/routes/inventory.py` and duplicate or import it in `backend/app/api/routes/production.py` if needed:

```python
def _rework_processed_qty(db: Session, work_order_id: int) -> Decimal:
    finished_qty = to_decimal(
        db.scalar(
            select(func.coalesce(func.sum(InventoryTxn.qty_change), 0)).where(
                InventoryTxn.txn_type == "REWORK_FINISHED_IN",
                InventoryTxn.source_doc_type == "WORK_ORDER",
                InventoryTxn.source_doc_id == work_order_id,
            )
        ),
        "0.000001",
    )
    scrap_qty = to_decimal(
        db.scalar(
            select(func.coalesce(func.sum(InventoryTxn.qty_change), 0)).where(
                InventoryTxn.txn_type == "REWORK_SCRAP_IN",
                InventoryTxn.source_doc_type == "WORK_ORDER",
                InventoryTxn.source_doc_id == work_order_id,
            )
        ),
        "0.000001",
    )
    return finished_qty + scrap_qty
```

- [ ] **Step 2: Add strict closure helper**

```python
def _settle_rework_work_order_if_ready(db: Session, work_order: WorkOrder, settle_remark: str | None) -> None:
    planned_qty = to_decimal(work_order.planned_qty)
    processed_qty = _rework_processed_qty(db, work_order.id)
    if processed_qty < planned_qty:
        raise HTTPException(
            status_code=400,
            detail=f"返工工单尚未闭环：返工出库 {planned_qty} 件，已回库/入废料 {processed_qty} 件",
        )
    work_order.status = "SETTLED"
    work_order.actual_end_time = work_order.actual_end_time or datetime.now()
    parts = [part.strip() for part in str(work_order.remark or "").replace(";", "；").split("；") if part.strip()]
    if "返工工单已结单" not in parts:
        parts.append("返工工单已结单")
    remark = str(settle_remark or "").strip()
    if remark:
        parts.append(f"返工结单说明：{remark}")
    work_order.remark = "；".join(dict.fromkeys(parts))[-255:]
    db.add(work_order)
```

- [ ] **Step 3: Apply settlement in finished rework inbound**

In `create_completion_receipt()`, replace direct `SETTLED` assignment for rework:

```python
if payload.settle_work_order:
    if _is_rework_work_order(work_order):
        _settle_rework_work_order_if_ready(db, work_order, payload.settle_remark)
    else:
        work_order.status = "SETTLED"
        work_order.actual_end_time = work_order.actual_end_time or datetime.now()
        work_order.updated_at = datetime.now()
        _append_finished_work_order_settle_remark(work_order, payload.settle_remark)
        db.add(work_order)
```

- [ ] **Step 4: Apply settlement in rework scrap inbound**

After creating `REWORK_SCRAP_IN` inventory transaction:

```python
if biz_type == "REWORK_SCRAP_IN" and payload.settle_work_order and rework_scrap_work_order is not None:
    _settle_rework_work_order_if_ready(db, rework_scrap_work_order, payload.settle_remark)
```

- [ ] **Step 5: Run all return rework tests**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
. .venv/bin/activate
python -m pytest tests/test_return_warehouse_flow.py -k "rework" -v
```

Expected:

```text
All rework tests pass.
```

---

## Task 7: Miniapp Backend Work Order Options

**Files:**
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reports.py`
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_work_order_batch_options.py`

- [ ] **Step 1: Add miniapp backend test for rework work orders**

In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_work_order_batch_options.py`, first extend the in-memory test table so the test can represent normal and rework work orders:

```python
                create table pp_work_order (
                    id integer primary key,
                    work_order_no text,
                    work_order_type text,
                    product_item_id integer,
                    status text
                )
```

Then add:

```python
def test_raw_material_batch_options_returns_rework_work_order_short_code(monkeypatch):
    class FrozenDateTime(datetime):
        @classmethod
        def now(cls, tz=None):  # noqa: ANN001
            _ = tz
            return cls(2026, 6, 2, 10, 0, 0)

    monkeypatch.setattr(reports, "datetime", FrozenDateTime)
    db = _session()
    try:
        db.execute(text("insert into md_item (id, item_code, item_name) values (1, 'FG-001', '钢制碗')"))
        db.execute(
            text(
                """
                insert into pp_work_order (id, work_order_no, work_order_type, product_item_id, status)
                values (1, '20260001-钢制碗', 'REWORK', 1, 'RELEASED')
                """
            )
        )
        db.execute(
            text("insert into pp_work_order_operation (id, work_order_id, operation_name) values (1, 1, '返工开平')")
        )
        db.commit()

        options = reports._raw_material_batch_options(
            db,
            project_no="FG-001",
            product_name="钢制碗",
            process_name="返工开平",
        )

        assert options == ["JH0001"]
    finally:
        db.close()
```

- [ ] **Step 2: Keep query inclusive of normal and rework work orders**

Confirm `_raw_material_batch_options()` does not filter out `work_order_type='REWORK'`. The SQL should continue to filter by work-order number, status, product, and process only:

```sql
and upper(coalesce(wo.status, '')) not in ('SETTLED', 'CANCELED', 'CANCELLED')
and (:process_name = '' or op.operation_name = :process_name)
```

- [ ] **Step 3: Add explicit order by available JH code**

Keep:

```sql
order by wo.work_order_no asc
```

This gives `JH0001, JH0002, JH0003...`.

- [ ] **Step 4: Run miniapp backend tests**

Run:

```bash
cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
pytest tests/test_work_order_batch_options.py -v
```

Expected:

```text
All tests pass, including the new rework short-code test.
```

---

## Task 8: Miniapp Frontend Label Rename

**Files:**
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.wxml`
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.js`

- [ ] **Step 1: Rename visible label in WXML**

Search:

```bash
rg -n "原材料分批次号|rawMaterialBatch" /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm
```

Replace visible text:

```text
原材料分批次号
```

with:

```text
工单号
```

Keep bound fields unchanged:

```text
rawMaterialBatchNo
rawMaterialBatchOptions
rawMaterialBatchIndex
```

- [ ] **Step 2: Rename validation toast**

In `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.js`, change:

```javascript
title: `${blockDisplay(block)}请选择原材料分批次号`,
```

to:

```javascript
title: `${blockDisplay(block)}请选择工单号`,
```

- [ ] **Step 3: Static check**

Run:

```bash
rg -n "原材料分批次号" /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm
```

Expected:

```text
No matches in reportForm visible UI files.
```

---

## Task 9: ERP Frontend Inventory Operations

**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- Modify: `frontend/src/utils/dictionaries.js`
- Create: `frontend/scripts/test-return-rework-closed-loop.mjs`

- [ ] **Step 1: Add dictionary labels**

In `frontend/src/utils/dictionaries.js`, update `INVENTORY_TXN_TYPE_LABELS`:

```javascript
REWORK_FINISHED_IN: "返工入库",
REWORK_SCRAP_IN: "返工废料入库",
```

Update `SOURCE_DOC_TYPE_LABELS`:

```javascript
REWORK_RECEIPT: "返工入库",
RETURN_DISPOSITION: "退货返工处置",
```

- [ ] **Step 2: Add inventory operation buttons**

In `frontend/src/views/InventoryLedgerView.vue`, update operation matrix:

```javascript
finished: {
  inbound: [
    makeOperation("finished-production-in", "in", "PRODUCTION_FINISHED", "生产入库", "生产完工成品入库，并生成成品批次。", "completion"),
    makeOperation("finished-rework-in", "in", "REWORK_FINISHED_IN", "返工入库", "退货返工完成后的成品回库。", "reworkCompletion"),
    makeOperation("finished-outsourcing-in", "in", "OUTSOURCING_IN", "委外入库", "委外加工完成后的成品入库。"),
    makeOperation("finished-opening-in", "in", "OPENING", "期初入库", "仓库初始化时导入的初始成品。")
  ],
  outbound: [
    makeOperation("finished-sales-out", "out", "SALES_OUT", "销售出库", "按销售订单进行成品发货出库。", "delivery")
  ]
},
scrap: {
  inbound: [
    makeOperation("scrap-production-in", "in", "PRODUCTION_SCRAP_IN", "生产废料入库", "生产过程产生的废料入库。"),
    makeOperation("scrap-rework-in", "in", "REWORK_SCRAP_IN", "返工废料入库", "退货返工失败产生的废品入废料库。", "reworkScrapInbound"),
    makeOperation("scrap-opening-in", "in", "OPENING", "期初入库", "仓库初始化时导入的初始废料。"),
    makeOperation("scrap-outsourcing-in", "in", "OUTSOURCING_SCRAP_IN", "委外废料入库", "委外加工产生的废料入库。"),
    makeOperation("scrap-return-in", "in", "RETURN_SCRAP_IN", "退货废料入库", "客户退回后判定报废的产品直接进入废料库。", "returnScrapInbound")
  ],
  outbound: [
    makeOperation("scrap-sale-out", "out", "SCRAP_SALE_OUT", "售卖出库", "废料称重售卖出库。")
  ]
}
```

- [ ] **Step 3: Add computed lists for rework work orders**

Use existing `workOrders` data source if present. Add:

```javascript
const reworkWorkOrders = computed(() =>
  workOrders.value.filter((workOrder) => String(workOrder.work_order_type || "").toUpperCase() === "REWORK")
);

const reworkFinishedInboundOptions = computed(() =>
  reworkWorkOrders.value.filter((workOrder) => Number(workOrder.max_finished_inbound_qty || workOrder.limits?.max_finished_inbound_qty || 0) > 0)
);

const reworkScrapInboundOptions = computed(() =>
  reworkWorkOrders.value.filter((workOrder) => Number(workOrder.max_rework_scrap_inbound_qty || workOrder.limits?.max_rework_scrap_inbound_qty || 0) > 0)
);
```

- [ ] **Step 4: Route `reworkCompletion` drawer to existing completion form**

When opening `finished-rework-in`, reuse the completion drawer but change title and filter:

```javascript
if (operation.drawerType === "reworkCompletion") {
  activeOperation.value = operation;
  resetCompletionForm();
  completionDrawerOpen.value = true;
  return;
}
```

In the completion work-order select, use:

```javascript
const completionWorkOrderOptions = computed(() =>
  activeOperation.value?.drawerType === "reworkCompletion"
    ? reworkFinishedInboundOptions.value
    : productionCompletionWorkOrderOptions.value
);
```

- [ ] **Step 5: Submit rework completion to existing completion endpoint**

Keep using:

```javascript
postResource("/production/completion-receipts", payload)
```

Payload remains:

```javascript
{
  work_order_id: Number(completionForm.work_order_id),
  warehouse_id: Number(completionForm.warehouse_id),
  settle_work_order: Boolean(completionForm.settle_work_order),
  settle_remark: completionForm.settle_remark || null,
  items: [
    {
      product_item_id: Number(completionForm.product_item_id),
      receipt_qty: Number(completionForm.receipt_qty || 0),
      receipt_weight_kg: 0
    }
  ]
}
```

Backend determines `REWORK_FINISHED_IN` from `work_order_type`.

- [ ] **Step 6: Submit rework scrap inbound**

For `reworkScrapInbound`, submit:

```javascript
await postResource("/inventory/inbound", {
  biz_type: "REWORK_SCRAP_IN",
  item_id: Number(genericForm.item_id),
  warehouse_id: Number(genericForm.warehouse_id),
  source_doc_type: "WORK_ORDER",
  source_doc_id: Number(genericForm.work_order_id),
  inbound_qty: Number(genericForm.inbound_qty || 0),
  inbound_weight_kg: Number(genericForm.inbound_weight_kg || 0),
  settle_work_order: Boolean(genericForm.settle_work_order),
  settle_remark: genericForm.settle_remark || null,
  remark: genericForm.remark || null
});
```

- [ ] **Step 7: Add static guard script**

Create `frontend/scripts/test-return-rework-closed-loop.mjs`:

```javascript
import { readFileSync } from "node:fs";

const inventory = readFileSync(new URL("../src/views/InventoryLedgerView.vue", import.meta.url), "utf8");
const dictionaries = readFileSync(new URL("../src/utils/dictionaries.js", import.meta.url), "utf8");

const requiredInventorySnippets = [
  "REWORK_FINISHED_IN",
  "返工入库",
  "REWORK_SCRAP_IN",
  "返工废料入库",
  "reworkCompletion",
  "reworkScrapInbound"
];

const requiredDictionarySnippets = [
  "REWORK_FINISHED_IN: \"返工入库\"",
  "REWORK_SCRAP_IN: \"返工废料入库\""
];

for (const snippet of requiredInventorySnippets) {
  if (!inventory.includes(snippet)) {
    throw new Error(`InventoryLedgerView.vue missing ${snippet}`);
  }
}

for (const snippet of requiredDictionarySnippets) {
  if (!dictionaries.includes(snippet)) {
    throw new Error(`dictionaries.js missing ${snippet}`);
  }
}

console.log("return rework closed-loop UI guard passed");
```

- [ ] **Step 8: Run frontend checks**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-return-rework-closed-loop.mjs
npm run build
```

Expected:

```text
return rework closed-loop UI guard passed
vite build exits 0
```

---

## Task 10: ERP Sync Miniapp Report Verification

**Files:**
- Modify: `backend/tests/test_return_warehouse_flow.py`
- Verify: `backend/app/api/routes/production.py`

- [ ] **Step 1: Add test for miniapp `JHXXXX` resolving rework order**

Add:

```python
    def test_miniapp_jh_short_code_binds_to_rework_work_order(self) -> None:
        from decimal import Decimal

        from app.api.routes.production import _new_miniapp_resolve_cache, _sync_miniapp_item_to_erp
        from app.api.routes.returns import create_return_order, create_return_rework_outbound
        from app.models.operations import OperationReport, WorkOrder
        from app.schemas.operations import ReturnDispositionCreate, ReturnItemCreate, ReturnOrderCreate

        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=10, return_weight_kg=Decimal("25"))],
            ),
            context=Context(),
            db=self.db,
        )
        disposition = create_return_rework_outbound(
            ReturnDispositionCreate(return_item_id=1, disposition_type="REWORK", remark="返工出库"),
            context=Context(),
            db=self.db,
        )
        work_order = self.db.get(WorkOrder, disposition.rework_work_order_id)
        short_code = f"JH{int(work_order.work_order_no[4:8]):04d}"
        row = {
            "source_report_id": 9001,
            "source_item_id": 900101,
            "project_no": "产品需规00001",
            "product_name": "冲压支架",
            "process_name": "开平",
            "raw_material_batch_no": short_code,
            "employee_phone": "13800000001",
            "employee_name": "返工员工",
            "attendance_point_name": "",
            "good_qty": 8,
            "defect_qty": 0,
            "miniapp_scrap_qty": 2,
            "standard_beat": 10,
            "started_at": datetime.now(UTC),
            "end_time": datetime.now(UTC),
            "status": "approved",
        }

        report = _sync_miniapp_item_to_erp(
            self.db,
            int(row["source_item_id"]),
            required=True,
            row=row,
            cache=_new_miniapp_resolve_cache(),
        )

        saved = self.db.get(OperationReport, report.id)
        self.assertEqual(saved.work_order_id, work_order.id)
        self.assertEqual(saved.good_qty, Decimal("8.000000"))
        self.assertEqual(saved.scrap_qty, Decimal("2.000000"))
```

- [ ] **Step 2: Run sync test**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
. .venv/bin/activate
python -m pytest tests/test_return_warehouse_flow.py -k "miniapp_jh_short_code" -v
```

Expected:

```text
PASSED
```

---

## Task 11: End-to-End Backend Verification

**Files:**
- Verify backend suite.

- [ ] **Step 1: Run ERP return and production tests**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
. .venv/bin/activate
python -m pytest tests/test_return_warehouse_flow.py tests/test_production_work_order_flow.py -v
```

Expected:

```text
All selected tests pass.
```

- [ ] **Step 2: Run miniapp backend tests**

Run:

```bash
cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
pytest tests/test_work_order_batch_options.py tests/test_metrics.py -v
```

Expected:

```text
All selected tests pass.
```

- [ ] **Step 3: Run frontend build**

Run:

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

Expected:

```text
vite build exits 0.
```

---

## Task 12: Manual Acceptance Checklist

**Files:**
- No code changes.

- [ ] **Step 1: ERP creates rework order**

Manual flow:

```text
成品库销售出库
-> 退货库退货入库
-> 退货库返工出库
```

Expected:

```text
工单台账出现一张 work_order_type=REWORK 的返工工单。
工单号格式为 20260001-产品名。
退货返工处置记录能看到返工工单号。
```

- [ ] **Step 2: Miniapp sees rework order**

Manual flow:

```text
小程序扫码同一产品工序二维码
-> 报工页面
-> 工单号下拉
```

Expected:

```text
下拉中出现 JH0001。
已结单的返工工单不出现。
```

- [ ] **Step 3: Miniapp report syncs to ERP**

Manual flow:

```text
小程序选择 JH0001
-> 填写良品 8，废品 2
-> 提交并审核
-> ERP 工序报工同步
```

Expected:

```text
ERP 工单台账中返工工单累计良品 8、废品 2。
普通生产工单数据不被串入该返工工单。
```

- [ ] **Step 4: Finished rework inbound**

Manual flow:

```text
嘉恒仓库 -> 成品库 -> 返工入库
-> 选择返工工单 JH0001 对应工单
-> 入库 8 件
```

Expected:

```text
成品库存增加 8 件。
库存流水显示“返工入库”。
成品批次追溯到原销售/原发货/原材料库存批次。
```

- [ ] **Step 5: Rework scrap inbound and close**

Manual flow:

```text
嘉恒仓库 -> 废料库 -> 返工废料入库
-> 选择同一返工工单
-> 入库 2 件对应废料重量
-> 勾选结单
```

Expected:

```text
废料库存增加。
库存流水显示“返工废料入库”。
返工工单状态变为已结单。
小程序 JH0001 不再显示。
```

---

## Self-Review Checklist

- [ ] 退货可返工品有入口：`退货库 · 退货入库`。
- [ ] 退货返工出库会生成 `work_order_type=REWORK` 工单。
- [ ] 返工工单号符合 `20260001-产品名`，小程序可转 `JH0001`。
- [ ] 小程序不新增返工页面，只改文案和测试。
- [ ] ERP 同步小程序报工能用 `JHXXXX` 匹配返工工单。
- [ ] 成品库返工入库按返工工单最后工序良品数控制。
- [ ] 废料库返工废料入库按返工工单废品数控制。
- [ ] 返工工单结单要求良品回库 + 废品入废料库覆盖返工出库数量。
- [ ] 返工入库不要求原材料领用记录。
- [ ] 所有新增展示值为中文，不展示 `REWORK_FINISHED_IN` 或 `REWORK_SCRAP_IN` 给用户。

---

## Recommended Execution Mode

推荐使用 **Inline Execution**。

原因：

```text
这次改动跨 ERP 后端、ERP 前端、小程序后端、小程序前端，多个任务共享同一条返工闭环上下文。Inline Execution 更适合连续验证，不容易让子任务之间对业务口径产生偏差。
```

执行时按顺序推进：

```text
1. 后端测试先行。
2. 工单号和返工工单生成。
3. 小程序工单短码验证。
4. 成品返工入库。
5. 返工废料入库。
6. ERP 前端入口。
7. 小程序文案。
8. 全链路手工验收。
```
