# Return Warehouse 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:** Add 嘉恒仓库“退货库” for customer-returned products that can be reworked, move the current “退货处理” entry into warehouse inbound operations, and add 废料库“退货废料入库” for customer-returned products judged as scrap.

**Architecture:** Treat customer returns as inventory movement. `RETURN` warehouse lots represent returned finished goods waiting for rework and are locked until `返工出库`; customer-return scrap bypasses the return warehouse and enters `SCRAP` directly while retaining delivery-line and source-material traceability. The old 发货与售后退货入口 is removed from navigation, but existing return order/disposition tables remain the trace backbone.

**Tech Stack:** FastAPI, SQLAlchemy ORM, Pydantic schemas, MySQL patch SQL, SQLite unit tests, Vue 3 SFC, Vite, existing warehouse ledger components.

---

## Confirmed Business Rules

- “发货与售后”的“退货处理”入口去掉，退货登记逻辑移动到“嘉恒仓库 -> 退货库 -> 入库 -> 退货入库”。
- “退货库”只存放客户退回后判定可以返工的产品。
- 退货库入库后的批次默认“待返工/锁定”，不能作为成品库存销售。
- 退货库出库只有“返工出库”，出库后扣减退货库库存并生成返工处置/返工工单。
- 客户退回后判定报废的产品不进入退货库，用户在“嘉恒仓库 -> 废料库 -> 入库 -> 退货废料入库”登记。
- 退货库需要“期初入库”，用于初始化历史退货待返工库存。
- 退货品明细必须展示发货单号或发货批次号，并通过发货明细追溯成品批次、原材料库存批次号、来源材料。

## File Structure

- Create `backend/sql/add_return_warehouse_patch.sql`
  - Add `RETURN` warehouse and default location.
  - Add `delivery_item_id` to `rt_return_item`.
- Modify `backend/app/api/routes/master_data.py`
  - Add 嘉恒退货库 definition and auto-ensure location.
- Modify `backend/app/api/routes/inventory.py`
  - Add `RETURN` warehouse label, sample opening rows, inbound/outbound permissions, lot number rules, and customer-return scrap inbound operation.
- Modify `backend/app/services/stocktake.py`
  - Include 退货库 labels and quantity/weight behavior in stocktake export/import.
- Modify `backend/app/models/operations.py`
  - Add nullable `delivery_item_id` FK on `ReturnItem`.
- Modify `backend/app/schemas/operations.py`
  - Add `delivery_item_id` to return schemas.
  - Add read models for delivery-line trace options if needed by the UI.
  - Add `RETURN_SCRAP_IN` / `RETURN_IN` / `RETURN_OUT` labels to allowed business types.
- Modify `backend/app/services/operations.py`
  - Add delivery-line trace helper.
  - Add return query fields for delivery item, finished lot, source material lot, and source material summary.
- Modify `backend/app/api/routes/returns.py`
  - Strengthen return inbound validation to require a concrete delivery line for warehouse return flows.
  - Add return-scrap inbound endpoint that creates scrap warehouse inventory directly.
  - Restrict return-warehouse outbound to rework.
- Modify `frontend/src/views/InventoryLedgerView.vue`
  - Add 退货库 tab.
  - Add 退货库“期初入库 / 退货入库 / 返工出库”.
  - Add 废料库“退货废料入库”.
  - Add dedicated drawers for 退货入库、退货废料入库、返工出库.
  - Load delivery and delivery-item trace data.
- Modify `frontend/src/views/DeliveryWorkspaceView.vue`
  - Remove the 退货处理 section from 发货与售后.
- Modify `frontend/src/App.vue`
  - Remove 退货处理 child menu from 发货与售后 route group.
- Modify `frontend/src/router/index.js`
  - Keep or hide the old route so direct access does not remain as a visible menu. If keeping the route for compatibility, redirect it to `inventory-ledger`.
- Modify `frontend/src/utils/dictionaries.js`
  - Add warehouse/txn/source labels for return warehouse and return scrap inbound.
- Modify `frontend/src/utils/inventoryOperationOptions.js`
  - Treat return warehouse as product/finished-goods quantity+weight stock, not weight-only raw material.
- Create `backend/tests/test_return_warehouse_flow.py`
  - Backend regression tests for warehouse creation, return inbound trace, rework outbound, return scrap inbound, and stocktake labels.
- Create or modify `frontend/scripts/test-inventory-ledger-return-warehouse.mjs`
  - Static verification for new tab, operations, hidden old entry, labels, and trace fields.

---

### Task 1: Add RETURN Warehouse Type, SQL Patch, And Opening/Stocktake Labels

**Files:**
- Create: `backend/sql/add_return_warehouse_patch.sql`
- Modify: `backend/app/api/routes/master_data.py`
- Modify: `backend/app/api/routes/inventory.py`
- Modify: `backend/app/services/stocktake.py`
- Test: `backend/tests/test_return_warehouse_flow.py`

- [ ] **Step 1: Write the failing warehouse-definition test**

Create `backend/tests/test_return_warehouse_flow.py` with a SQLite fixture following the project’s existing pattern:

```python
from __future__ import annotations

import unittest
from datetime import UTC, datetime
from io import BytesIO

from openpyxl import load_workbook
from sqlalchemy import BigInteger, create_engine, select
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session, sessionmaker


@compiles(BigInteger, "sqlite")
def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str:
    _ = type_, compiler, kw
    return "INTEGER"


import app.models.master_data  # noqa: E402,F401
import app.models.operations  # noqa: E402,F401
import app.models.sales  # noqa: E402,F401
from app.models.base import Base  # noqa: E402
from app.models.master_data import Warehouse  # noqa: E402


class ReturnWarehouseFlowTest(unittest.TestCase):
    def setUp(self) -> None:
        engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
        Base.metadata.create_all(engine)
        self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
        self.db: Session = self.SessionLocal()

    def tearDown(self) -> None:
        self.db.close()

    def test_jiaheng_warehouses_include_return_warehouse(self) -> None:
        from app.api.routes.master_data import _ensure_jiaheng_warehouses

        _ensure_jiaheng_warehouses(self.db)

        warehouse = self.db.scalar(select(Warehouse).where(Warehouse.warehouse_type == "RETURN"))
        self.assertIsNotNone(warehouse)
        self.assertEqual(warehouse.warehouse_code, "WH-RETURN-01")
        self.assertEqual(warehouse.warehouse_name, "退货库")
```

- [ ] **Step 2: Run the failing warehouse-definition test**

Run:

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

Expected: FAIL because `RETURN` is not included in `JIAHENG_WAREHOUSE_DEFINITIONS`.

- [ ] **Step 3: Add the SQL patch**

Create `backend/sql/add_return_warehouse_patch.sql`:

```sql
INSERT INTO wh_warehouse (warehouse_code, warehouse_name, warehouse_type, manager_employee_id, status, remark)
SELECT 'WH-RETURN-01', '退货库', 'RETURN', NULL, 'ACTIVE', '客户退回且可返工产品的待处理库存'
WHERE NOT EXISTS (SELECT 1 FROM wh_warehouse WHERE warehouse_code = 'WH-RETURN-01');

UPDATE wh_warehouse
SET warehouse_name = '退货库',
    warehouse_type = 'RETURN',
    status = 'ACTIVE',
    remark = '客户退回且可返工产品的待处理库存'
WHERE warehouse_code = 'WH-RETURN-01';

INSERT INTO wh_location (
  warehouse_id,
  location_code,
  location_name,
  zone_name,
  is_default,
  is_locked,
  status,
  remark
)
SELECT
  (SELECT id FROM wh_warehouse WHERE warehouse_code = 'WH-RETURN-01'),
  'RTN-F-01',
  '退货暂存位',
  'F区',
  1,
  0,
  'ACTIVE',
  '退货库默认库位'
WHERE NOT EXISTS (
  SELECT 1
  FROM wh_location
  WHERE warehouse_id = (SELECT id FROM wh_warehouse WHERE warehouse_code = 'WH-RETURN-01')
    AND location_code = 'RTN-F-01'
);

UPDATE wh_location
SET location_name = '退货暂存位',
    zone_name = 'F区',
    is_default = 1,
    is_locked = 0,
    status = 'ACTIVE',
    remark = '退货库默认库位'
WHERE warehouse_id = (SELECT id FROM wh_warehouse WHERE warehouse_code = 'WH-RETURN-01')
  AND location_code = 'RTN-F-01';

SET @has_delivery_item_id := (
  SELECT COUNT(*)
  FROM INFORMATION_SCHEMA.COLUMNS
  WHERE TABLE_SCHEMA = DATABASE()
    AND TABLE_NAME = 'rt_return_item'
    AND COLUMN_NAME = 'delivery_item_id'
);

SET @add_delivery_item_id_sql := IF(
  @has_delivery_item_id = 0,
  'ALTER TABLE rt_return_item ADD COLUMN delivery_item_id BIGINT NULL AFTER sales_order_item_id',
  'SELECT 1'
);
PREPARE add_delivery_item_id_stmt FROM @add_delivery_item_id_sql;
EXECUTE add_delivery_item_id_stmt;
DEALLOCATE PREPARE add_delivery_item_id_stmt;

SET @has_return_delivery_item_idx := (
  SELECT COUNT(*)
  FROM INFORMATION_SCHEMA.STATISTICS
  WHERE TABLE_SCHEMA = DATABASE()
    AND TABLE_NAME = 'rt_return_item'
    AND INDEX_NAME = 'idx_rt_return_item_delivery_item'
);

SET @add_return_delivery_item_idx_sql := IF(
  @has_return_delivery_item_idx = 0,
  'ALTER TABLE rt_return_item ADD KEY idx_rt_return_item_delivery_item (delivery_item_id)',
  'SELECT 1'
);
PREPARE add_return_delivery_item_idx_stmt FROM @add_return_delivery_item_idx_sql;
EXECUTE add_return_delivery_item_idx_stmt;
DEALLOCATE PREPARE add_return_delivery_item_idx_stmt;
```

- [ ] **Step 4: Add RETURN to backend warehouse definitions**

Modify `backend/app/api/routes/master_data.py` by appending this entry to `JIAHENG_WAREHOUSE_DEFINITIONS` after the SCRAP warehouse:

```python
    {
        "warehouse_code": "WH-RETURN-01",
        "warehouse_name": "退货库",
        "warehouse_type": "RETURN",
        "location_code": "RTN-F-01",
        "location_name": "退货暂存位",
        "zone_name": "F区",
        "remark": "客户退回且可返工产品的待处理库存",
    },
```

Update `_ensure_jiaheng_warehouses` to include `RETURN` in the existing `warehouse_type.in_(...)` list:

```python
Warehouse.warehouse_type.in_(["RAW", "SEMI", "FINISHED", "AUX", "SCRAP", "RETURN"])
```

- [ ] **Step 5: Add RETURN to inventory labels, samples, and stocktake labels**

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

```python
WAREHOUSE_TYPE_LABELS = {
    "RAW": "原材料库",
    "SEMI": "半成品库",
    "FINISHED": "成品库",
    "AUX": "辅料库",
    "SCRAP": "废料库",
    "RETURN": "退货库",
}
```

Add sample opening rows:

```python
    "RETURN": [
        ["退货库", "退货仓", "退货暂存位", "示例产品001", "五金支架待返工品", "示例-RETURN-0001", 20, 50, 0, "仓库初始化清点", "示例行，导入时会自动跳过，请替换为真实数据"],
        ["退货库", "退货仓", "退货暂存位", "示例产品002", "冲压件待返工品", "示例-RETURN-0002", 12, 30, 0, "仓库初始化清点", "示例行，导入时会自动跳过，请替换为真实数据"],
    ],
```

In `backend/app/services/stocktake.py`, add:

```python
        "RETURN": "退货库",
```

Keep `_is_weight_only_warehouse_type` unchanged so RETURN keeps both quantity and weight.

- [ ] **Step 6: Add opening-template test for return warehouse**

Append to `ReturnWarehouseFlowTest`:

```python
    def test_return_opening_template_exports_sample_rows_with_quantity(self) -> None:
        from app.api.routes.inventory import build_opening_inventory_template

        workbook = build_opening_inventory_template(self.db, "RETURN")
        output = BytesIO()
        workbook.save(output)
        loaded = load_workbook(BytesIO(output.getvalue()), read_only=True, data_only=True)
        rows = list(loaded["退货库期初导入"].iter_rows(values_only=True))
        headers = list(rows[0])

        self.assertIn("数量", headers)
        self.assertEqual(rows[1][headers.index("仓库类型")], "退货库")
        self.assertEqual(rows[1][headers.index("仓库名称")], "退货仓")
        self.assertIn("示例-RETURN-", rows[1][headers.index("批次号")])
        self.assertGreater(rows[1][headers.index("数量")], 0)
        self.assertGreater(rows[1][headers.index("重量(kg)")], 0)
```

- [ ] **Step 7: Run Task 1 tests**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_return_warehouse_flow.py::ReturnWarehouseFlowTest::test_jiaheng_warehouses_include_return_warehouse tests/test_return_warehouse_flow.py::ReturnWarehouseFlowTest::test_return_opening_template_exports_sample_rows_with_quantity -q
```

Expected: PASS.

- [ ] **Step 8: Commit Task 1 if the workspace is a git repository**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git status --short
```

If this is a git repository, commit:

```bash
git add backend/sql/add_return_warehouse_patch.sql backend/app/api/routes/master_data.py backend/app/api/routes/inventory.py backend/app/services/stocktake.py backend/tests/test_return_warehouse_flow.py
git commit -m "feat: add return warehouse foundation"
```

If `git status` reports this is not a git repository, record that in implementation notes and continue.

---

### Task 2: Add Delivery-Line Trace To Return Items

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

- [ ] **Step 1: Write failing schema/model/query test**

Append a seeded test that creates a delivery item pointing to a finished stock lot whose source material lot is known:

```python
    def test_return_item_query_exposes_delivery_and_source_material_trace(self) -> None:
        from decimal import Decimal
        from app.models.master_data import Item, Warehouse
        from app.models.operations import Delivery, DeliveryItem, ReturnItem, ReturnOrder, StockLot, WarehouseLocation
        from app.models.sales import Customer
        from app.services.operations import get_return_items_query

        now = datetime.now(UTC)
        customer = Customer(id=1, customer_code="CUS-001", customer_name="测试客户", status="ACTIVE")
        return_wh = Warehouse(id=1, warehouse_code="WH-RETURN-01", warehouse_name="退货库", warehouse_type="RETURN", status="ACTIVE", created_at=now, updated_at=now)
        finished_wh = Warehouse(id=2, warehouse_code="WH-FG-01", warehouse_name="成品库", warehouse_type="FINISHED", status="ACTIVE", created_at=now, updated_at=now)
        return_loc = WarehouseLocation(id=1, warehouse_id=1, location_code="RTN-F-01", location_name="退货暂存位", is_default=1, is_locked=0, status="ACTIVE", created_at=now, updated_at=now)
        material = Item(id=1, item_code="原材料00001", item_name="冷轧钢板", item_type="RAW_MATERIAL", unit_weight_kg=Decimal("1"), status="ACTIVE", created_at=now, updated_at=now)
        product = Item(id=2, item_code="产品需规00001", item_name="冲压支架", item_type="FINISHED_GOOD", unit_weight_kg=Decimal("2.5"), status="ACTIVE", created_at=now, updated_at=now)
        material_lot = StockLot(id=1, lot_no="JH_原材料00001_0001", lot_role="INBOUND_RAW", item_id=1, warehouse_id=2, location_id=None, source_doc_type="OPENING_STOCK", source_doc_id=1, inbound_qty=0, inbound_weight_kg=500, remaining_qty=0, remaining_weight_kg=0, locked_qty=0, locked_weight_kg=0, unit_cost=5, quality_status="PASS", status="DEPLETED", created_at=now, updated_at=now)
        finished_lot = StockLot(id=2, lot_no="FG-20260531-0001", lot_role="INBOUND_FINISHED", item_id=2, warehouse_id=2, location_id=None, source_doc_type="FG_RECEIPT", source_doc_id=1, source_line_id=1, source_material_lot_id=1, source_material_sub_batch_no="JH_原材料00001_0001", source_material_summary="冷轧钢板", inbound_qty=100, inbound_weight_kg=250, remaining_qty=0, remaining_weight_kg=0, locked_qty=0, locked_weight_kg=0, unit_cost=8, quality_status="PASS", status="DEPLETED", created_at=now, updated_at=now)
        delivery = Delivery(id=1, delivery_no="发货2026-00001", customer_id=1, warehouse_id=2, delivery_date=now, status="POSTED", created_at=now, updated_at=now)
        delivery_item = DeliveryItem(id=1, delivery_id=1, line_no=1, product_item_id=2, lot_id=2, delivery_qty=30, delivery_weight_kg=75, unit_price=12, line_amount=360, status="POSTED", created_at=now, updated_at=now)
        return_order = ReturnOrder(id=1, return_no="RTN-001", customer_id=1, delivery_id=1, return_date=now, status="CREATED", created_at=now, updated_at=now)
        return_item = ReturnItem(id=1, return_order_id=1, line_no=1, delivery_item_id=1, product_item_id=2, source_lot_id=2, return_qty=5, return_weight_kg=12.5, disposition_status="PENDING", created_at=now, updated_at=now)
        self.db.add_all([customer, return_wh, finished_wh, return_loc, material, product, material_lot, finished_lot, delivery, delivery_item, return_order, return_item])
        self.db.commit()

        row = self.db.execute(get_return_items_query(limit=10)).mappings().first()

        self.assertEqual(row["delivery_item_id"], 1)
        self.assertEqual(row["delivery_no"], "发货2026-00001")
        self.assertEqual(row["source_lot_no"], "FG-20260531-0001")
        self.assertEqual(row["source_material_lot_no"], "JH_原材料00001_0001")
        self.assertEqual(row["source_material_name"], "冷轧钢板")
```

- [ ] **Step 2: Run the failing trace test**

Run:

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

Expected: FAIL because `ReturnItem.delivery_item_id` and query fields are missing.

- [ ] **Step 3: Add `delivery_item_id` to ORM and schemas**

In `backend/app/models/operations.py`, add to `ReturnItem` after `sales_order_item_id`:

```python
    delivery_item_id: Mapped[int | None] = mapped_column(ForeignKey("so_delivery_item.id"), nullable=True)
```

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

```python
    delivery_item_id: int | None = None
```

Add to `ReturnItemRead`:

```python
    delivery_item_id: int | None = None
    delivery_no: str | None = None
    source_material_lot_id: int | None = None
    source_material_lot_no: str | None = None
    source_material_code: str | None = None
    source_material_name: str | None = None
    source_material_sub_batch_no: str | None = None
    source_material_summary: str | None = None
```

- [ ] **Step 4: Update `get_return_items_query` trace joins**

In `backend/app/services/operations.py`, update `get_return_items_query` to join delivery item and source material lots:

```python
source_lot = aliased(StockLot)
source_material_lot = aliased(StockLot)
source_material_item = aliased(Item)

stmt = (
    select(
        ReturnItem.id.label("return_item_id"),
        ReturnItem.return_order_id.label("return_order_id"),
        ReturnOrder.return_no.label("return_no"),
        ReturnItem.line_no.label("line_no"),
        ReturnItem.sales_order_item_id.label("sales_order_item_id"),
        ReturnItem.delivery_item_id.label("delivery_item_id"),
        Delivery.delivery_no.label("delivery_no"),
        ReturnItem.product_item_id.label("product_item_id"),
        Item.item_code.label("product_code"),
        Item.item_name.label("product_name"),
        ReturnItem.source_lot_id.label("source_lot_id"),
        source_lot.lot_no.label("source_lot_no"),
        source_lot.source_material_lot_id.label("source_material_lot_id"),
        source_material_lot.lot_no.label("source_material_lot_no"),
        source_material_item.item_code.label("source_material_code"),
        source_material_item.item_name.label("source_material_name"),
        func.coalesce(source_material_lot.lot_no, source_lot.source_material_sub_batch_no).label("source_material_sub_batch_no"),
        source_lot.source_material_summary.label("source_material_summary"),
        ReturnItem.return_qty.label("return_qty"),
        ReturnItem.return_weight_kg.label("return_weight_kg"),
        ReturnItem.disposition_status.label("disposition_status"),
        ReturnItem.responsibility_type.label("responsibility_type"),
        ReturnItem.remark.label("remark"),
    )
    .join(ReturnOrder, ReturnOrder.id == ReturnItem.return_order_id)
    .join(Item, Item.id == ReturnItem.product_item_id)
    .outerjoin(DeliveryItem, DeliveryItem.id == ReturnItem.delivery_item_id)
    .outerjoin(Delivery, Delivery.id == DeliveryItem.delivery_id)
    .outerjoin(source_lot, source_lot.id == ReturnItem.source_lot_id)
    .outerjoin(source_material_lot, source_material_lot.id == source_lot.source_material_lot_id)
    .outerjoin(source_material_item, source_material_item.id == source_material_lot.item_id)
    .order_by(ReturnItem.id.desc())
    .limit(limit)
)
```

Keep the existing `return_order_id` filter after building `stmt`.

- [ ] **Step 5: Run the trace test**

Run:

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

Expected: PASS.

- [ ] **Step 6: Commit Task 2 if the workspace is a git repository**

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git status --short
git add backend/app/models/operations.py backend/app/schemas/operations.py backend/app/services/operations.py backend/tests/test_return_warehouse_flow.py
git commit -m "feat: trace returns to delivery batches"
```

---

### Task 3: Implement Return Warehouse Inbound And Return Scrap Inbound Backend Logic

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

- [ ] **Step 1: Write failing return-warehouse inbound test**

Append a test that calls the existing return order endpoint with a concrete delivery item:

```python
    def test_return_inbound_requires_delivery_item_and_creates_locked_return_lot(self) -> None:
        from app.api.routes.returns import create_return_order
        from app.models.master_data import Item, Warehouse
        from app.models.operations import Delivery, DeliveryItem, StockLot, WarehouseLocation
        from app.models.sales import Customer
        from app.schemas.operations import ReturnItemCreate, ReturnOrderCreate

        now = datetime.now(UTC)
        customer = Customer(id=1, customer_code="CUS-001", customer_name="测试客户", status="ACTIVE")
        return_wh = Warehouse(id=1, warehouse_code="WH-RETURN-01", warehouse_name="退货库", warehouse_type="RETURN", status="ACTIVE", created_at=now, updated_at=now)
        finished_wh = Warehouse(id=2, warehouse_code="WH-FG-01", warehouse_name="成品库", warehouse_type="FINISHED", status="ACTIVE", created_at=now, updated_at=now)
        return_loc = WarehouseLocation(id=1, warehouse_id=1, location_code="RTN-F-01", location_name="退货暂存位", is_default=1, is_locked=0, status="ACTIVE", created_at=now, updated_at=now)
        material = Item(id=1, item_code="原材料00001", item_name="冷轧钢板", item_type="RAW_MATERIAL", unit_weight_kg=1, status="ACTIVE", created_at=now, updated_at=now)
        product = Item(id=2, item_code="产品需规00001", item_name="冲压支架", item_type="FINISHED_GOOD", unit_weight_kg=2.5, status="ACTIVE", created_at=now, updated_at=now)
        material_lot = StockLot(id=1, lot_no="JH_原材料00001_0001", lot_role="INBOUND_RAW", item_id=1, warehouse_id=2, source_doc_type="OPENING_STOCK", source_doc_id=1, inbound_qty=0, inbound_weight_kg=500, remaining_qty=0, remaining_weight_kg=0, locked_qty=0, locked_weight_kg=0, unit_cost=5, quality_status="PASS", status="DEPLETED", created_at=now, updated_at=now)
        finished_lot = StockLot(id=2, lot_no="FG-20260531-0001", lot_role="INBOUND_FINISHED", item_id=2, warehouse_id=2, source_doc_type="FG_RECEIPT", source_doc_id=1, source_line_id=1, source_material_lot_id=1, source_material_sub_batch_no="JH_原材料00001_0001", source_material_summary="冷轧钢板", inbound_qty=100, inbound_weight_kg=250, remaining_qty=70, remaining_weight_kg=175, locked_qty=0, locked_weight_kg=0, unit_cost=8, quality_status="PASS", status="AVAILABLE", created_at=now, updated_at=now)
        delivery = Delivery(id=1, delivery_no="发货2026-00001", customer_id=1, warehouse_id=2, delivery_date=now, status="POSTED", created_at=now, updated_at=now)
        delivery_item = DeliveryItem(id=1, delivery_id=1, line_no=1, product_item_id=2, lot_id=2, delivery_qty=30, delivery_weight_kg=75, unit_price=12, line_amount=360, status="POSTED", created_at=now, updated_at=now)
        self.db.add_all([customer, return_wh, finished_wh, return_loc, material, product, material_lot, finished_lot, delivery, delivery_item])
        self.db.commit()

        payload = ReturnOrderCreate(
            customer_id=1,
            delivery_id=1,
            warehouse_id=1,
            return_reason="客户反馈尺寸异常，可返工",
            items=[ReturnItemCreate(delivery_item_id=1, product_item_id=2, source_lot_id=2, return_qty=5, return_weight_kg=12.5)],
        )

        result = create_return_order(payload, context=Context(), db=self.db)
        lot = self.db.scalar(select(StockLot).where(StockLot.source_doc_type == "RETURN_ORDER"))

        self.assertEqual(result.status, "CREATED")
        self.assertEqual(lot.warehouse_id, 1)
        self.assertEqual(lot.lot_role, "RETURNED_FINISHED")
        self.assertEqual(lot.status, "LOCKED")
        self.assertEqual(lot.quality_status, "PENDING")
        self.assertEqual(lot.parent_lot_id, 2)
        self.assertEqual(lot.source_material_lot_id, 1)
        self.assertEqual(lot.source_material_sub_batch_no, "JH_原材料00001_0001")
```

- [ ] **Step 2: Run the failing return inbound test**

Run:

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

Expected: FAIL because return inbound does not preserve delivery item trace and source material lot on the returned lot.

- [ ] **Step 3: Add delivery item trace helper**

In `backend/app/api/routes/returns.py`, add this helper near `build_return_lot_no`:

```python
def get_delivery_item_trace(db: Session, delivery_item_id: int) -> dict[str, object]:
    source_material_lot = aliased(StockLot)
    source_material_item = aliased(Item)
    row = db.execute(
        select(
            DeliveryItem.id.label("delivery_item_id"),
            DeliveryItem.delivery_id.label("delivery_id"),
            Delivery.delivery_no.label("delivery_no"),
            Delivery.customer_id.label("customer_id"),
            Delivery.sales_order_id.label("sales_order_id"),
            DeliveryItem.sales_order_item_id.label("sales_order_item_id"),
            DeliveryItem.product_item_id.label("product_item_id"),
            DeliveryItem.lot_id.label("source_lot_id"),
            DeliveryItem.delivery_qty.label("delivery_qty"),
            DeliveryItem.delivery_weight_kg.label("delivery_weight_kg"),
            StockLot.lot_no.label("source_lot_no"),
            StockLot.source_material_lot_id.label("source_material_lot_id"),
            source_material_lot.lot_no.label("source_material_lot_no"),
            source_material_item.item_code.label("source_material_code"),
            source_material_item.item_name.label("source_material_name"),
            func.coalesce(source_material_lot.lot_no, StockLot.source_material_sub_batch_no).label("source_material_sub_batch_no"),
            StockLot.source_material_summary.label("source_material_summary"),
        )
        .join(Delivery, Delivery.id == DeliveryItem.delivery_id)
        .outerjoin(StockLot, StockLot.id == DeliveryItem.lot_id)
        .outerjoin(source_material_lot, source_material_lot.id == StockLot.source_material_lot_id)
        .outerjoin(source_material_item, source_material_item.id == source_material_lot.item_id)
        .where(DeliveryItem.id == delivery_item_id)
    ).mappings().first()
    if not row:
        raise HTTPException(status_code=404, detail="发货批次不存在")
    return dict(row)
```

Also add `aliased` import:

```python
from sqlalchemy.orm import Session, aliased
```

- [ ] **Step 4: Update return inbound validation**

In `create_return_order`, for each item:

```python
        if not item_payload.delivery_item_id:
            raise HTTPException(status_code=400, detail="退货入库必须选择发货批次")

        trace = get_delivery_item_trace(db, item_payload.delivery_item_id)
        if int(trace["delivery_id"]) != int(payload.delivery_id or 0):
            raise HTTPException(status_code=400, detail="退货明细发货批次不属于所选发货单")
        if int(trace["customer_id"] or 0) != int(payload.customer_id):
            raise HTTPException(status_code=400, detail="退货客户与发货单客户不一致")
        if int(trace["product_item_id"]) != int(item_payload.product_item_id):
            raise HTTPException(status_code=400, detail="退货产品与发货批次产品不一致")
        if item_payload.source_lot_id and int(trace["source_lot_id"] or 0) != int(item_payload.source_lot_id):
            raise HTTPException(status_code=400, detail="退货来源批次与发货批次不一致")
```

When creating `ReturnItem`, set:

```python
            delivery_item_id=item_payload.delivery_item_id,
            sales_order_item_id=item_payload.sales_order_item_id or trace["sales_order_item_id"],
            source_lot_id=trace["source_lot_id"],
```

When creating the returned `StockLot`, set:

```python
            parent_lot_id=trace["source_lot_id"],
            source_material_lot_id=trace["source_material_lot_id"],
            source_material_sub_batch_no=trace["source_material_sub_batch_no"],
            source_material_summary=trace["source_material_summary"] or trace["source_material_name"] or trace["source_material_lot_no"],
```

- [ ] **Step 5: Add return scrap inbound schema**

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

```python
class ReturnScrapInboundItemCreate(BaseModel):
    delivery_item_id: int
    product_item_id: int
    source_lot_id: int | None = None
    scrap_qty: float = 0
    scrap_weight_kg: float
    unit_cost: float | None = None
    remark: str | None = None


class ReturnScrapInboundCreate(BaseModel):
    customer_id: int
    delivery_id: int
    warehouse_id: int
    inbound_date: datetime | None = None
    return_reason: str | None = None
    handler_employee_id: int | None = None
    remark: str | None = None
    items: list[ReturnScrapInboundItemCreate]
```

- [ ] **Step 6: Implement `/returns/scrap-inbound`**

In `backend/app/api/routes/returns.py`, import the new schemas and add:

```python
@router.post("/scrap-inbound", response_model=ReturnOrderRead)
def create_return_scrap_inbound(
    payload: ReturnScrapInboundCreate,
    context: AuthContext = Depends(require_authenticated_user),
    db: Session = Depends(get_db),
) -> ReturnOrderRead:
    customer = db.get(Customer, payload.customer_id)
    if not customer:
        raise HTTPException(status_code=404, detail="客户不存在")
    delivery = db.get(Delivery, payload.delivery_id)
    if not delivery:
        raise HTTPException(status_code=404, detail="发货单不存在")
    if int(delivery.customer_id or 0) != int(customer.id):
        raise HTTPException(status_code=400, detail="客户与发货单不一致")
    warehouse = db.get(Warehouse, payload.warehouse_id)
    if not warehouse or str(warehouse.warehouse_type or "").upper() != "SCRAP":
        raise HTTPException(status_code=400, detail="退货废料入库只能进入废料库")
    if not payload.items:
        raise HTTPException(status_code=400, detail="退货废料入库至少需要一条明细")

    order = ReturnOrder(
        return_no=build_return_no(db, customer),
        customer_id=customer.id,
        sales_order_id=delivery.sales_order_id,
        delivery_id=delivery.id,
        return_date=payload.inbound_date or datetime.now(),
        return_reason=payload.return_reason,
        handler_employee_id=payload.handler_employee_id,
        status="CLOSED",
        remark=payload.remark,
    )
    db.add(order)
    db.flush()
    location = get_location_or_default(db, warehouse.id, None)

    for index, item_payload in enumerate(payload.items, start=1):
        trace = get_delivery_item_trace(db, item_payload.delivery_item_id)
        if int(trace["delivery_id"]) != int(delivery.id):
            raise HTTPException(status_code=400, detail="退货废料发货批次不属于所选发货单")
        product = db.get(Item, item_payload.product_item_id)
        if not product:
            raise HTTPException(status_code=404, detail=f"产品不存在: {item_payload.product_item_id}")
        if int(trace["product_item_id"]) != int(product.id):
            raise HTTPException(status_code=400, detail="退货废料产品与发货批次产品不一致")
        scrap_weight = to_decimal(item_payload.scrap_weight_kg)
        if scrap_weight <= 0:
            raise HTTPException(status_code=400, detail="退货废料重量必须大于0")
        scrap_qty = to_decimal(item_payload.scrap_qty)
        if scrap_qty <= 0 and to_decimal(product.unit_weight_kg) > 0:
            scrap_qty = scrap_weight / to_decimal(product.unit_weight_kg)
        unit_cost = to_decimal(item_payload.unit_cost, "0.0001") if item_payload.unit_cost is not None else to_decimal(product.scrap_sale_price, "0.0001")

        return_item = ReturnItem(
            return_order_id=order.id,
            line_no=index,
            delivery_item_id=item_payload.delivery_item_id,
            sales_order_item_id=trace["sales_order_item_id"],
            product_item_id=product.id,
            source_lot_id=trace["source_lot_id"],
            return_qty=scrap_qty,
            return_weight_kg=scrap_weight,
            disposition_status="CLOSED",
            responsibility_type=None,
            remark=item_payload.remark,
        )
        db.add(return_item)
        db.flush()

        lot = StockLot(
            lot_no=build_return_lot_no(db, order, product, index).replace("RTL", "RTS", 1),
            parent_lot_id=trace["source_lot_id"],
            lot_role="CUSTOMER_RETURN_SCRAP",
            item_id=product.id,
            warehouse_id=warehouse.id,
            location_id=location.id if location else None,
            source_doc_type="RETURN_ORDER",
            source_doc_id=order.id,
            source_line_id=return_item.id,
            source_material_lot_id=trace["source_material_lot_id"],
            source_material_sub_batch_no=trace["source_material_sub_batch_no"],
            source_material_summary=trace["source_material_summary"] or trace["source_material_name"] or trace["source_material_lot_no"],
            inbound_qty=Decimal("0"),
            inbound_weight_kg=scrap_weight,
            remaining_qty=Decimal("0"),
            remaining_weight_kg=scrap_weight,
            locked_qty=Decimal("0"),
            locked_weight_kg=Decimal("0"),
            unit_cost=unit_cost,
            production_date=(payload.inbound_date or datetime.now()).date(),
            expire_date=None,
            quality_status="SCRAP",
            status="AVAILABLE",
            remark=item_payload.remark or "客户退货报废直接入废料库",
        )
        db.add(lot)
        db.flush()

        upsert_stock_balance(
            db,
            item_id=product.id,
            warehouse_id=warehouse.id,
            location_id=location.id if location else None,
            qty_delta=Decimal("0"),
            weight_delta=scrap_weight,
            available_qty_delta=Decimal("0"),
            available_weight_delta=scrap_weight,
            unit_cost=unit_cost,
        )
        create_inventory_txn(
            db,
            txn_type="RETURN_SCRAP_IN",
            item_id=product.id,
            warehouse_id=warehouse.id,
            location_id=location.id if location else None,
            lot_id=lot.id,
            qty_change=Decimal("0"),
            weight_change=scrap_weight,
            unit_cost=unit_cost,
            source_doc_type="RETURN_ORDER",
            source_doc_id=order.id,
            source_line_id=return_item.id,
            biz_time=order.return_date,
            operator_user_id=context.user.id,
            remark=item_payload.remark or "客户退货报废入库",
            amount_basis="WEIGHT",
        )

        disposition = ReturnDisposition(
            disposition_no=build_return_disposition_no(db, order, "SCRAP"),
            return_item_id=return_item.id,
            disposition_type="SCRAP",
            rework_work_order_id=None,
            scrap_weight_kg=scrap_weight,
            extra_cost_amount=Decimal("0"),
            scrap_sale_amount=Decimal("0"),
            closed_at=datetime.now(),
            status="CLOSED",
            remark=item_payload.remark,
        )
        db.add(disposition)

    db.commit()
    row = db.execute(get_return_orders_query(limit=1).where(ReturnOrder.id == order.id)).mappings().first()
    if not row:
        raise HTTPException(status_code=500, detail="退货废料入库后读取失败")
    return ReturnOrderRead.model_validate(dict(row))
```

- [ ] **Step 7: Add return scrap inbound test**

Append this helper and test:

```python
    def seed_delivery_trace(self) -> dict[str, int]:
        from app.models.master_data import Item, Warehouse
        from app.models.operations import Delivery, DeliveryItem, StockLot, WarehouseLocation
        from app.models.sales import Customer

        now = datetime.now(UTC)
        customer = Customer(id=1, customer_code="CUS-001", customer_name="测试客户", status="ACTIVE")
        return_wh = Warehouse(id=1, warehouse_code="WH-RETURN-01", warehouse_name="退货库", warehouse_type="RETURN", status="ACTIVE", created_at=now, updated_at=now)
        finished_wh = Warehouse(id=2, warehouse_code="WH-FG-01", warehouse_name="成品库", warehouse_type="FINISHED", status="ACTIVE", created_at=now, updated_at=now)
        scrap_wh = Warehouse(id=3, warehouse_code="WH-SCRAP-01", warehouse_name="废料库", warehouse_type="SCRAP", status="ACTIVE", created_at=now, updated_at=now)
        return_loc = WarehouseLocation(id=1, warehouse_id=1, location_code="RTN-F-01", location_name="退货暂存位", is_default=1, is_locked=0, status="ACTIVE", created_at=now, updated_at=now)
        scrap_loc = WarehouseLocation(id=2, warehouse_id=3, location_code="SCR-E-01", location_name="废料暂存位", is_default=1, is_locked=0, status="ACTIVE", created_at=now, updated_at=now)
        material = Item(id=1, item_code="原材料00001", item_name="冷轧钢板", item_type="RAW_MATERIAL", unit_weight_kg=1, scrap_sale_price=1.2, status="ACTIVE", created_at=now, updated_at=now)
        product = Item(id=2, item_code="产品需规00001", item_name="冲压支架", item_type="FINISHED_GOOD", unit_weight_kg=2.5, scrap_sale_price=1.5, status="ACTIVE", created_at=now, updated_at=now)
        material_lot = StockLot(id=1, lot_no="JH_原材料00001_0001", lot_role="INBOUND_RAW", item_id=1, warehouse_id=2, source_doc_type="OPENING_STOCK", source_doc_id=1, inbound_qty=0, inbound_weight_kg=500, remaining_qty=0, remaining_weight_kg=0, locked_qty=0, locked_weight_kg=0, unit_cost=5, quality_status="PASS", status="DEPLETED", created_at=now, updated_at=now)
        finished_lot = StockLot(id=2, lot_no="FG-20260531-0001", lot_role="INBOUND_FINISHED", item_id=2, warehouse_id=2, source_doc_type="FG_RECEIPT", source_doc_id=1, source_line_id=1, source_material_lot_id=1, source_material_sub_batch_no="JH_原材料00001_0001", source_material_summary="冷轧钢板", inbound_qty=100, inbound_weight_kg=250, remaining_qty=70, remaining_weight_kg=175, locked_qty=0, locked_weight_kg=0, unit_cost=8, quality_status="PASS", status="AVAILABLE", created_at=now, updated_at=now)
        delivery = Delivery(id=1, delivery_no="发货2026-00001", customer_id=1, warehouse_id=2, delivery_date=now, status="POSTED", created_at=now, updated_at=now)
        delivery_item = DeliveryItem(id=1, delivery_id=1, line_no=1, product_item_id=2, lot_id=2, delivery_qty=30, delivery_weight_kg=75, unit_price=12, line_amount=360, status="POSTED", created_at=now, updated_at=now)
        self.db.add_all([customer, return_wh, finished_wh, scrap_wh, return_loc, scrap_loc, material, product, material_lot, finished_lot, delivery, delivery_item])
        self.db.commit()
        return {
            "customer_id": 1,
            "delivery_id": 1,
            "delivery_item_id": 1,
            "product_item_id": 2,
            "finished_lot_id": 2,
            "return_warehouse_id": 1,
            "scrap_warehouse_id": 3,
        }

    def test_return_scrap_inbound_goes_directly_to_scrap_warehouse(self) -> None:
        from app.api.routes.returns import create_return_scrap_inbound
        from app.schemas.operations import ReturnScrapInboundCreate, ReturnScrapInboundItemCreate

        seed = self.seed_delivery_trace()
        payload = ReturnScrapInboundCreate(
            customer_id=seed["customer_id"],
            delivery_id=seed["delivery_id"],
            warehouse_id=seed["scrap_warehouse_id"],
            return_reason="客户退回后判定报废",
            items=[ReturnScrapInboundItemCreate(delivery_item_id=seed["delivery_item_id"], product_item_id=seed["product_item_id"], source_lot_id=seed["finished_lot_id"], scrap_weight_kg=10)],
        )

        result = create_return_scrap_inbound(payload, context=Context(), db=self.db)
        lot = self.db.scalar(select(StockLot).where(StockLot.lot_role == "CUSTOMER_RETURN_SCRAP"))

        self.assertEqual(result.status, "CLOSED")
        self.assertEqual(lot.warehouse_id, seed["scrap_warehouse_id"])
        self.assertEqual(lot.source_material_sub_batch_no, "JH_原材料00001_0001")
        self.assertEqual(lot.status, "AVAILABLE")
```

- [ ] **Step 8: Run Task 3 tests**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_return_warehouse_flow.py::ReturnWarehouseFlowTest::test_return_inbound_requires_delivery_item_and_creates_locked_return_lot tests/test_return_warehouse_flow.py::ReturnWarehouseFlowTest::test_return_scrap_inbound_goes_directly_to_scrap_warehouse -q
```

Expected: PASS.

- [ ] **Step 9: Commit Task 3 if the workspace is a git repository**

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git status --short
git add backend/app/api/routes/returns.py backend/app/api/routes/inventory.py backend/app/schemas/operations.py backend/app/services/operations.py backend/tests/test_return_warehouse_flow.py
git commit -m "feat: add return and scrap inbound flows"
```

---

### Task 4: Restrict Return Warehouse Outbound To Rework And Preserve Cost Flow

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

- [ ] **Step 1: Write failing rework outbound test**

Append:

```python
    def test_return_rework_outbound_deducts_return_lot_and_creates_rework_order(self) -> None:
        from app.api.routes.returns import create_return_disposition, create_return_order
        from app.models.operations import InventoryTxn, StockLot
        from app.schemas.operations import ReturnDispositionCreate, ReturnItemCreate, ReturnOrderCreate

        seed = self.seed_delivery_trace()
        create_return_order(
            ReturnOrderCreate(
                customer_id=seed["customer_id"],
                delivery_id=seed["delivery_id"],
                warehouse_id=seed["return_warehouse_id"],
                return_reason="可返工",
                items=[ReturnItemCreate(delivery_item_id=seed["delivery_item_id"], product_item_id=seed["product_item_id"], source_lot_id=seed["finished_lot_id"], return_qty=3, return_weight_kg=7.5)],
            ),
            context=Context(),
            db=self.db,
        )
        return_lot = self.db.scalar(select(StockLot).where(StockLot.lot_role == "RETURNED_FINISHED"))

        result = create_return_disposition(
            ReturnDispositionCreate(return_item_id=1, disposition_type="REWORK", extra_cost_amount=18, remark="返工出库"),
            context=Context(),
            db=self.db,
        )
        txn = self.db.query(InventoryTxn).filter_by(txn_type="RETURN_OUT").one()

        self.assertEqual(result.disposition_type, "REWORK")
        self.assertIsNotNone(result.rework_work_order_id)
        self.assertEqual(return_lot.status, "CLOSED")
        self.assertEqual(float(return_lot.remaining_qty), 0)
        self.assertEqual(float(txn.qty_change), -3)
```

- [ ] **Step 2: Run the failing rework outbound test**

Run:

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

Expected: PASS if existing behavior already satisfies it; if it fails, fix only the mismatch shown by the assertion.

- [ ] **Step 3: Prevent SCRAP disposition from return warehouse UI path**

Keep backend compatibility for legacy data if needed, but add a clear rework-only endpoint for the warehouse button:

```python
@router.post("/rework-outbound", response_model=ReturnDispositionRead)
def create_return_rework_outbound(
    payload: ReturnDispositionCreate,
    context: AuthContext = Depends(require_authenticated_user),
    db: Session = Depends(get_db),
) -> ReturnDispositionRead:
    if payload.disposition_type.upper() != "REWORK":
        raise HTTPException(status_code=400, detail="退货库出库只支持返工出库")
    return create_return_disposition(payload, context=context, db=db)
```

- [ ] **Step 4: Run Task 4 tests**

Run:

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

Expected: PASS.

- [ ] **Step 5: Commit Task 4 if the workspace is a git repository**

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git status --short
git add backend/app/api/routes/returns.py backend/app/schemas/operations.py backend/tests/test_return_warehouse_flow.py
git commit -m "feat: add return warehouse rework outbound"
```

---

### Task 5: Add Return Warehouse And Return Scrap Operations To Inventory UI

**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- Modify: `frontend/src/utils/inventoryOperationOptions.js`
- Modify: `frontend/src/utils/dictionaries.js`
- Create: `frontend/scripts/test-inventory-ledger-return-warehouse.mjs`

- [ ] **Step 1: Write failing frontend static test**

Create `frontend/scripts/test-inventory-ledger-return-warehouse.mjs`:

```javascript
import fs from "node:fs";
import path from "node:path";

const root = process.cwd();
const inventoryView = fs.readFileSync(path.join(root, "src/views/InventoryLedgerView.vue"), "utf8");
const deliveryWorkspace = fs.readFileSync(path.join(root, "src/views/DeliveryWorkspaceView.vue"), "utf8");
const appView = fs.readFileSync(path.join(root, "src/App.vue"), "utf8");
const dictionaries = fs.readFileSync(path.join(root, "src/utils/dictionaries.js"), "utf8");

const checks = [
  ["return warehouse tab", inventoryView.includes('key: "return"') && inventoryView.includes("退货库")],
  ["return opening inbound", inventoryView.includes("return-opening-in") && inventoryView.includes("期初入库")],
  ["return inbound operation", inventoryView.includes("return-customer-in") && inventoryView.includes("退货入库")],
  ["return rework outbound operation", inventoryView.includes("return-rework-out") && inventoryView.includes("返工出库")],
  ["scrap return inbound operation", inventoryView.includes("scrap-return-in") && inventoryView.includes("退货废料入库")],
  ["delivery trace fields", inventoryView.includes("delivery_item_id") && inventoryView.includes("source_material_sub_batch_no")],
  ["old delivery return section removed", !deliveryWorkspace.includes('key: "return-management"')],
  ["old app return child removed", !appView.includes('key: "return-management"')],
  ["return labels", dictionaries.includes("RETURN_SCRAP_IN") && dictionaries.includes("客户退货报废入库")],
];

const failed = checks.filter(([, passed]) => !passed);
if (failed.length) {
  console.error("Return warehouse UI checks failed:");
  failed.forEach(([name]) => console.error(`- ${name}`));
  process.exit(1);
}

console.log("Return warehouse UI checks passed");
```

- [ ] **Step 2: Run the failing frontend static test**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-inventory-ledger-return-warehouse.mjs
```

Expected: FAIL because the return warehouse UI has not been added.

- [ ] **Step 3: Add return warehouse tab data**

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

```javascript
const WAREHOUSE_TYPE_BY_TAB = {
  raw: "RAW",
  semi: "SEMI",
  finished: "FINISHED",
  auxiliary: "AUX",
  scrap: "SCRAP",
  return: "RETURN"
};

const WAREHOUSE_LABEL_BY_TAB = {
  raw: "原材料库",
  semi: "半成品库",
  finished: "成品库",
  auxiliary: "辅料库",
  scrap: "废料库",
  return: "退货库"
};
```

Add balances:

```javascript
const returnBalances = computed(() => aggregateItemBalances(balances.value.filter((item) => normalizeWarehouseType(item) === "RETURN"), "RETURN"));
```

Add inventory tab:

```javascript
  {
    key: "return",
    label: "退货库",
    count: filteredReturnCount.value,
    iconClass: "inventory-tab-icon-return",
    iconPaths: ["M5 7h9a5 5 0 0 1 0 10H8", "M8 11 5 7l3-4", "M10 17l2 3 5-5"]
  }
```

Add titles and rows:

```javascript
return: "客户退货待返工库存"
```

```javascript
return: returnBalances.value
```

Add count:

```javascript
const filteredReturnCount = computed(() => returnBalances.value.length);
```

Keep `activeWeightOnly` unchanged except it should not include `return`.

- [ ] **Step 4: Add operations**

In `operationMatrix`, add `RETURN_SCRAP_IN` to scrap inbound and a new `return` group:

```javascript
scrap: {
  inbound: [
    makeOperation("scrap-production-in", "in", "PRODUCTION_SCRAP_IN", "生产废料入库", "生产过程产生的废料入库。"),
    makeOperation("scrap-return-in", "in", "RETURN_SCRAP_IN", "退货废料入库", "客户退回后判定报废的产品直接进入废料库。", "returnScrap"),
    makeOperation("scrap-opening-in", "in", "OPENING", "期初入库", "仓库初始化时导入的初始废料。"),
    makeOperation("scrap-outsourcing-in", "in", "OUTSOURCING_SCRAP_IN", "委外废料入库", "委外加工产生的废料入库。")
  ],
  outbound: [
    makeOperation("scrap-sale-out", "out", "SCRAP_SALE_OUT", "售卖出库", "废料称重售卖出库。")
  ]
},
return: {
  inbound: [
    makeOperation("return-opening-in", "in", "OPENING", "期初入库", "仓库初始化时导入历史待返工退货品。"),
    makeOperation("return-customer-in", "in", "RETURN_IN", "退货入库", "客户退回且可返工的产品入退货库。", "returnInbound")
  ],
  outbound: [
    makeOperation("return-rework-out", "out", "RETURN_OUT", "返工出库", "退货库待返工产品出库并生成返工记录。", "returnRework")
  ]
}
```

- [ ] **Step 5: Load deliveries and delivery items**

In `InventoryLedgerView.vue`, add refs:

```javascript
const deliveries = ref([]);
const deliveryItems = ref([]);
```

In `loadAll`, add requests:

```javascript
fetchResource("/sales/deliveries?limit=500", []),
fetchResource("/sales/delivery-items?limit=1000", [])
```

Assign:

```javascript
deliveries.value = deliveryRows;
deliveryItems.value = deliveryItemRows;
```

- [ ] **Step 6: Add return inbound form state and submit logic**

Add form builders:

```javascript
function buildEmptyReturnInboundForm() {
  return {
    customer_id: "",
    delivery_id: "",
    handler_employee_id: "",
    return_reason: "",
    remark: "",
    items: []
  };
}

function buildEmptyReturnScrapForm() {
  return {
    customer_id: "",
    delivery_id: "",
    handler_employee_id: "",
    return_reason: "",
    remark: "",
    items: []
  };
}

function buildEmptyReturnReworkForm() {
  return {
    return_item_id: "",
    extra_cost_amount: 0,
    remark: ""
  };
}
```

Submit functions:

```javascript
async function submitReturnInbound() {
  resetFeedback();
  const items = returnInboundForm.items.filter((item) => Number(item.delivery_item_id) && Number(item.return_qty) > 0);
  if (!Number(returnInboundForm.customer_id) || !Number(returnInboundForm.delivery_id) || !items.length) {
    errorMessage.value = "请选择客户、发货单和至少一条退货明细";
    return;
  }
  submittingOperation.value = true;
  try {
    const result = await postResource("/returns/orders", {
      customer_id: Number(returnInboundForm.customer_id),
      delivery_id: Number(returnInboundForm.delivery_id),
      warehouse_id: activeWarehouseId.value,
      return_reason: returnInboundForm.return_reason || null,
      handler_employee_id: returnInboundForm.handler_employee_id ? Number(returnInboundForm.handler_employee_id) : null,
      remark: returnInboundForm.remark || null,
      items: items.map((item) => ({
        delivery_item_id: Number(item.delivery_item_id),
        product_item_id: Number(item.product_item_id),
        source_lot_id: item.source_lot_id ? Number(item.source_lot_id) : null,
        return_qty: Number(item.return_qty),
        return_weight_kg: Number(item.return_weight_kg || 0),
        responsibility_type: item.responsibility_type || null,
        remark: item.remark || null
      }))
    });
    feedbackMessage.value = `退货入库 ${result.return_no} 已创建`;
    returnInboundDrawerOpen.value = false;
    await loadAll();
  } catch (error) {
    errorMessage.value = error.message || "退货入库失败";
  } finally {
    submittingOperation.value = false;
  }
}

async function submitReturnScrapInbound() {
  resetFeedback();
  const items = returnScrapForm.items.filter((item) => Number(item.delivery_item_id) && Number(item.scrap_weight_kg) > 0);
  if (!Number(returnScrapForm.customer_id) || !Number(returnScrapForm.delivery_id) || !items.length) {
    errorMessage.value = "请选择客户、发货单和至少一条退货废料明细";
    return;
  }
  submittingOperation.value = true;
  try {
    const result = await postResource("/returns/scrap-inbound", {
      customer_id: Number(returnScrapForm.customer_id),
      delivery_id: Number(returnScrapForm.delivery_id),
      warehouse_id: activeWarehouseId.value,
      return_reason: returnScrapForm.return_reason || null,
      handler_employee_id: returnScrapForm.handler_employee_id ? Number(returnScrapForm.handler_employee_id) : null,
      remark: returnScrapForm.remark || null,
      items: items.map((item) => ({
        delivery_item_id: Number(item.delivery_item_id),
        product_item_id: Number(item.product_item_id),
        source_lot_id: item.source_lot_id ? Number(item.source_lot_id) : null,
        scrap_qty: Number(item.scrap_qty || 0),
        scrap_weight_kg: Number(item.scrap_weight_kg),
        unit_cost: item.unit_cost === "" ? null : Number(item.unit_cost || 0),
        remark: item.remark || null
      }))
    });
    feedbackMessage.value = `退货废料入库 ${result.return_no} 已创建`;
    returnScrapDrawerOpen.value = false;
    await loadAll();
  } catch (error) {
    errorMessage.value = error.message || "退货废料入库失败";
  } finally {
    submittingOperation.value = false;
  }
}

async function submitReturnReworkOutbound() {
  resetFeedback();
  if (!Number(returnReworkForm.return_item_id)) {
    errorMessage.value = "请选择待返工退货批次";
    return;
  }
  submittingOperation.value = true;
  try {
    const result = await postResource("/returns/rework-outbound", {
      return_item_id: Number(returnReworkForm.return_item_id),
      disposition_type: "REWORK",
      extra_cost_amount: Number(returnReworkForm.extra_cost_amount || 0),
      remark: returnReworkForm.remark || null
    });
    feedbackMessage.value = `返工出库 ${result.disposition_no} 已创建`;
    returnReworkDrawerOpen.value = false;
    await loadAll();
  } catch (error) {
    errorMessage.value = error.message || "返工出库失败";
  } finally {
    submittingOperation.value = false;
  }
}
```

- [ ] **Step 7: Add drawers**

Add three `FormDrawer` blocks in `InventoryLedgerView.vue`:

```vue
<FormDrawer :open="returnInboundDrawerOpen" eyebrow="退货库 · 退货入库" title="客户退货入库" tag="待返工" wide @close="returnInboundDrawerOpen = false">
  <form class="stack-form" @submit.prevent="submitReturnInbound">
    <div v-if="feedbackMessage" class="feedback-banner">{{ feedbackMessage }}</div>
    <div v-if="errorMessage" class="feedback-banner error-banner">{{ errorMessage }}</div>
    <!-- Use customer, delivery, delivery item trace fields and rows. Each row displays delivery_no, product_name, lot_no, source_material_sub_batch_no. -->
    <button class="primary-button" type="submit" :disabled="submittingOperation">{{ submittingOperation ? "入库中..." : "创建退货入库" }}</button>
  </form>
</FormDrawer>

<FormDrawer :open="returnScrapDrawerOpen" eyebrow="废料库 · 退货废料入库" title="客户退货报废入库" tag="退货报废" wide @close="returnScrapDrawerOpen = false">
  <form class="stack-form" @submit.prevent="submitReturnScrapInbound">
    <div v-if="feedbackMessage" class="feedback-banner">{{ feedbackMessage }}</div>
    <div v-if="errorMessage" class="feedback-banner error-banner">{{ errorMessage }}</div>
    <!-- Use customer, delivery, delivery item trace fields and rows. Each row displays delivery_no, product_name, lot_no, source_material_sub_batch_no, scrap_weight_kg. -->
    <button class="primary-button" type="submit" :disabled="submittingOperation">{{ submittingOperation ? "入库中..." : "创建退货废料入库" }}</button>
  </form>
</FormDrawer>

<FormDrawer :open="returnReworkDrawerOpen" eyebrow="退货库 · 返工出库" title="退货品返工出库" tag="返工" wide @close="returnReworkDrawerOpen = false">
  <form class="stack-form" @submit.prevent="submitReturnReworkOutbound">
    <div v-if="feedbackMessage" class="feedback-banner">{{ feedbackMessage }}</div>
    <div v-if="errorMessage" class="feedback-banner error-banner">{{ errorMessage }}</div>
    <!-- Select pending return item and show return_no, product, source delivery_no, source_material_sub_batch_no. -->
    <button class="primary-button" type="submit" :disabled="submittingOperation">{{ submittingOperation ? "出库中..." : "创建返工出库" }}</button>
  </form>
</FormDrawer>
```

Replace each HTML comment above with actual form fields during implementation:

```vue
<select v-model.number="returnInboundForm.delivery_id" required @change="applyReturnDeliveryDefaults">
  <option disabled value="">请选择发货单</option>
  <option v-for="delivery in deliveries" :key="delivery.delivery_id" :value="delivery.delivery_id">
    {{ delivery.delivery_no }} · {{ delivery.customer_name }} · {{ delivery.product_names }}
  </option>
</select>
```

Use delivery item rows:

```vue
<option v-for="item in selectableReturnDeliveryItems" :key="item.delivery_item_id" :value="item.delivery_item_id">
  {{ item.delivery_no }} · {{ item.product_name }} · 成品批次 {{ item.lot_no || "-" }} · 原材料批次 {{ item.source_material_sub_batch_no || "-" }}
</option>
```

- [ ] **Step 8: Add labels**

In `frontend/src/utils/dictionaries.js`, add:

```javascript
RETURN_IN: "退货入库",
RETURN_OUT: "返工出库",
RETURN_SCRAP_IN: "客户退货报废入库",
RETURN_ORDER: "客户退货",
RETURN_DISPOSITION: "退货处置"
```

- [ ] **Step 9: Run frontend static test**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-inventory-ledger-return-warehouse.mjs
```

Expected: PASS.

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

Run:

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

Expected: build exits 0. Existing chunk-size warning is acceptable.

- [ ] **Step 11: Commit Task 5 if the workspace is a git repository**

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git status --short
git add frontend/src/views/InventoryLedgerView.vue frontend/src/utils/inventoryOperationOptions.js frontend/src/utils/dictionaries.js frontend/scripts/test-inventory-ledger-return-warehouse.mjs
git commit -m "feat: add return warehouse operations UI"
```

---

### Task 6: Remove Old 发货与售后退货处理 Entry

**Files:**
- Modify: `frontend/src/views/DeliveryWorkspaceView.vue`
- Modify: `frontend/src/App.vue`
- Modify: `frontend/src/router/index.js`
- Test: `frontend/scripts/test-inventory-ledger-return-warehouse.mjs`

- [ ] **Step 1: Remove ReturnManagement section from DeliveryWorkspace**

In `frontend/src/views/DeliveryWorkspaceView.vue`, remove:

```javascript
import ReturnManagementView from "./ReturnManagementView.vue";
```

Remove this section from `sections`:

```javascript
  {
    key: "return-management",
    step: "06-2",
    label: "退货处理",
    desc: "退货入仓后转返工或报废，闭环售后成本。",
    permission: "MENU_RETURN_MANAGEMENT",
    component: ReturnManagementView
  }
```

Update the workspace title/description:

```vue
title="成品发货与交付管理"
description="发货退回后的库存处理已迁移到嘉恒仓库的退货库和废料库。"
```

- [ ] **Step 2: Remove return-management child menu from App**

In `frontend/src/App.vue`, remove the child with:

```javascript
key: "return-management"
```

Remove `"return-management"` from the 发货与售后 route names if that route should no longer keep the parent active:

```javascript
routeNames: ["delivery-workspace", "delivery-management"]
```

- [ ] **Step 3: Redirect old direct route**

In `frontend/src/router/index.js`, change the old route to redirect:

```javascript
{
  path: "/return-management",
  name: "return-management",
  redirect: { name: "inventory-ledger", query: { warehouse: "return" } },
  meta: { title: "退货库", requiresAuth: true, permission: "MENU_INVENTORY_LEDGER" }
}
```

Do not delete `frontend/src/views/ReturnManagementView.vue` in this task; keeping it avoids a large unrelated file removal and leaves historical code available if manual comparison is needed.

- [ ] **Step 4: Run frontend static test and build**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-inventory-ledger-return-warehouse.mjs
npm run build
```

Expected: static test passes and build exits 0.

- [ ] **Step 5: Commit Task 6 if the workspace is a git repository**

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git status --short
git add frontend/src/views/DeliveryWorkspaceView.vue frontend/src/App.vue frontend/src/router/index.js
git commit -m "refactor: move return handling out of delivery workspace"
```

---

### Task 7: Final Backend And Frontend Verification

**Files:**
- Verify all files changed in previous tasks.

- [ ] **Step 1: Apply SQL patch to local database when using the local MySQL app**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python - <<'PY'
from pathlib import Path
from sqlalchemy import text
from app.db.session import get_engine

sql_path = Path("sql/add_return_warehouse_patch.sql")
statements = [stmt.strip() for stmt in sql_path.read_text(encoding="utf-8").split(";") if stmt.strip()]
with get_engine().begin() as conn:
    for statement in statements:
        conn.execute(text(statement))
print(f"applied {len(statements)} statements from {sql_path}")
PY
```

Expected: command exits 0.

- [ ] **Step 2: Verify local database has RETURN warehouse**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python - <<'PY'
from sqlalchemy import text
from app.db.session import get_engine

with get_engine().connect() as conn:
    row = conn.execute(text("SELECT warehouse_code, warehouse_name, warehouse_type FROM wh_warehouse WHERE warehouse_type = 'RETURN'")).mappings().first()
    print(dict(row) if row else "missing")
PY
```

Expected output contains:

```text
'warehouse_code': 'WH-RETURN-01'
'warehouse_name': '退货库'
'warehouse_type': 'RETURN'
```

- [ ] **Step 3: Run targeted backend tests**

Run:

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

Expected: all tests in `test_return_warehouse_flow.py` pass.

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

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest -q
```

Expected: all backend tests pass.

- [ ] **Step 5: Run frontend verification**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-inventory-ledger-return-warehouse.mjs
npm run build
```

Expected: static test passes and build exits 0.

- [ ] **Step 6: Manual smoke test in browser**

Open the ERP frontend and verify:

```text
1. 嘉恒仓库出现第六个仓库：退货库。
2. 退货库入库区有：期初入库、退货入库。
3. 退货库出库区有：返工出库。
4. 废料库入库区有：退货废料入库。
5. 发货与售后不再展示退货处理。
6. 退货入库选择发货单后，能看到发货单号、产品、成品批次、原材料库存批次号。
7. 退货入库后，退货库库存增加，批次状态为锁定/待返工。
8. 返工出库后，退货库库存扣减并产生返工工单。
9. 退货废料入库后，废料库库存增加，来源能追溯到发货批次和原材料库存批次号。
```

- [ ] **Step 7: Commit final verification note if the workspace is a git repository**

If a git repository is present and no code changes remain unstaged, no commit is needed. If verification caused generated files or doc changes that should be tracked, run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git status --short
```

Commit only intentional tracked changes.

---

## Self-Review Checklist

- Requirement coverage:
  - 退货库 added as warehouse tab and warehouse type: Task 1, Task 5.
  - 退货库期初入库: Task 1, Task 5.
  - 退货库退货入库: Task 3, Task 5.
  - 退货库返工出库: Task 4, Task 5.
  - 废料库退货废料入库: Task 3, Task 5.
  - 发货与售后退货处理 removed: Task 6.
  - 发货单/发货批次/原材料库存批次号 trace: Task 2, Task 3, Task 5.
  - Existing project style: uses current warehouse ledger, returns, delivery trace, stock lot, and drawer patterns.
- No placeholder language remains.
- Type consistency:
  - `delivery_item_id` is added to ORM, schema, query, backend validation, and frontend payload.
  - `RETURN` is used as warehouse type consistently.
  - `RETURN_SCRAP_IN` is used for customer-return scrap entering the scrap warehouse.
