# 半成品产品工序库存 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:** 后端继续使用 `md_item.id` 表示产品，但用 `StockLot.source_line_id` 和 `InventoryTxn.source_line_id` 记录产品需规清单中的 `route_operation_id`，从而区分同一产品不同工序阶段的半成品。前端半成品入库从 `/master-data/process-routes` 展开产品工序选项，半成品出库和库存明细按 `item_id + source_line_id` 过滤与展示，避免跨工序混扣库存。

**Tech Stack:** FastAPI + SQLAlchemy + Pytest；Vue 3 + Vite + 现有静态 Node 测试脚本。

---

### Task 1: 前端选项工具增加产品工序选项

**Files:**
- Modify: `frontend/src/utils/inventoryOperationOptions.js`
- Test: `frontend/scripts/test-inventory-ledger-options.mjs`

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

Add assertions to `frontend/scripts/test-inventory-ledger-options.mjs` after `productMasterRows`:

```js
const productOperationRows = [
  {
    option_key: "601::7001",
    item_id: 601,
    item_code: "产品需规00003",
    item_name: "委外成品",
    route_id: 8001,
    route_operation_id: 7001,
    seq_no: 1,
    operation_name: "冲压"
  },
  {
    option_key: "601::7002",
    item_id: 601,
    item_code: "产品需规00003",
    item_name: "委外成品",
    route_id: 8001,
    route_operation_id: 7002,
    seq_no: 2,
    operation_name: "折弯"
  }
];
```

Change the semi inbound test to pass `productOperations: productOperationRows` and expect both operation keys:

```js
assert.deepEqual(semiInboundOptions.map((item) => item.option_key), ["601::7001", "601::7002"]);
assert.deepEqual(semiInboundOptions.map((item) => item.route_operation_id), [7001, 7002]);
assert.deepEqual(finishedInboundOptions.map((item) => item.item_id), [601]);
```

- [ ] **Step 2: Run test to verify it fails**

Run: `node frontend/scripts/test-inventory-ledger-options.mjs`

Expected: FAIL because `buildGenericItemOptions` ignores `productOperations`.

- [ ] **Step 3: Implement minimal option behavior**

In `frontend/src/utils/inventoryOperationOptions.js`, add `productOperations = []` to the function parameters and return `productOperations` for semi inbound:

```js
export function buildGenericItemOptions({
  direction = "in",
  activeInventoryTab = "raw",
  activeWarehouseType = "",
  warehouseId = "",
  balances = [],
  materials = [],
  products = [],
  productOperations = []
} = {}) {
  if (String(direction || "").toLowerCase() === "out") {
    return buildOutboundItemOptions({
      balances,
      warehouseId,
      warehouseType: activeWarehouseType,
      activeInventoryTab
    });
  }
  if (activeInventoryTab === "semi" || String(activeWarehouseType || "").toUpperCase() === "SEMI") {
    return productOperations;
  }
  if (activeInventoryTab === "scrap" || String(activeWarehouseType || "").toUpperCase() === "SCRAP") {
    return [...materials, ...products];
  }
  if (["raw", "auxiliary"].includes(activeInventoryTab)) {
    return materials;
  }
  if (activeInventoryTab === "return" || String(activeWarehouseType || "").toUpperCase() === "RETURN") {
    return products;
  }
  return products;
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `node frontend/scripts/test-inventory-ledger-options.mjs`

Expected: PASS.

---

### Task 2: 后端保存半成品工序维度并按工序出库

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

- [ ] **Step 1: Write failing backend tests**

Create `backend/tests/test_semi_finished_operation_inventory.py` with two tests:

```python
from decimal import Decimal

from fastapi import HTTPException
from sqlalchemy import select

from app.api.routes.inventory import create_warehouse_inbound, create_warehouse_outbound
from app.models.master_data import Item, Process, ProcessRoute, ProcessRouteOperation, Warehouse, WarehouseLocation, WorkCenter
from app.models.operations import InventoryTxn, StockLot
from app.schemas.operations import WarehouseInboundCreate, WarehouseOutboundCreate


class _User:
    id = 1
    username = "admin"
    role = "ADMIN"


class _Context:
    user = _User()


def _seed_route(db):
    product = Item(item_code="产品需规00001", item_name="五金支架", item_type="FINISHED_GOOD", status="ACTIVE")
    process = Process(process_code="GX01", process_name="冲压", process_type="INHOUSE", status="ACTIVE")
    center = WorkCenter(work_center_code="WC01", work_center_name="一号工位", status="ACTIVE")
    warehouse = Warehouse(warehouse_code="WH-SEMI-T", warehouse_name="半成品测试仓", warehouse_type="SEMI", status="ACTIVE")
    db.add_all([product, process, center, warehouse])
    db.flush()
    location = WarehouseLocation(
        warehouse_id=warehouse.id,
        location_code="SEMI-A",
        location_name="半成品默认库位",
        is_default=True,
        status="ACTIVE",
    )
    route = ProcessRoute(
        route_code="R-001",
        route_name="五金支架工艺",
        product_item_id=product.id,
        version_no="V1.0",
        is_default=True,
        status="ACTIVE",
    )
    db.add_all([location, route])
    db.flush()
    op1 = ProcessRouteOperation(
        route_id=route.id,
        seq_no=1,
        process_id=process.id,
        work_center_id=center.id,
        operation_name="冲压",
        status="ACTIVE",
    )
    op2 = ProcessRouteOperation(
        route_id=route.id,
        seq_no=2,
        process_id=process.id,
        work_center_id=center.id,
        operation_name="折弯",
        status="ACTIVE",
    )
    db.add_all([op1, op2])
    db.commit()
    return product, warehouse, route, op1, op2


def test_semi_inbound_records_product_operation_source_line(db_session):
    product, warehouse, route, op1, _ = _seed_route(db_session)

    row = create_warehouse_inbound(
        WarehouseInboundCreate(
            biz_type="WIP_IN",
            item_id=product.id,
            warehouse_id=warehouse.id,
            inbound_weight_kg=120,
            unit_cost=8,
            source_doc_type="PRODUCT_ROUTE_OPERATION",
            source_doc_id=route.id,
            source_line_id=op1.id,
            source_material_summary="产品：五金支架；已完成工序：第1道 冲压",
        ),
        context=_Context(),
        db=db_session,
    )

    lot = db_session.get(StockLot, row.lot_id)
    assert lot.source_line_id == op1.id
    assert lot.source_doc_id == route.id
    assert "第1道" in lot.source_material_summary
    txn = db_session.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == lot.id))
    assert txn.source_line_id == op1.id


def test_semi_outbound_deducts_only_selected_product_operation(db_session):
    product, warehouse, route, op1, op2 = _seed_route(db_session)
    context = _Context()
    first = create_warehouse_inbound(
        WarehouseInboundCreate(
            biz_type="WIP_IN",
            item_id=product.id,
            warehouse_id=warehouse.id,
            inbound_weight_kg=50,
            unit_cost=8,
            source_doc_type="PRODUCT_ROUTE_OPERATION",
            source_doc_id=route.id,
            source_line_id=op1.id,
            source_material_summary="产品：五金支架；已完成工序：第1道 冲压",
        ),
        context=context,
        db=db_session,
    )
    second = create_warehouse_inbound(
        WarehouseInboundCreate(
            biz_type="WIP_IN",
            item_id=product.id,
            warehouse_id=warehouse.id,
            inbound_weight_kg=80,
            unit_cost=9,
            source_doc_type="PRODUCT_ROUTE_OPERATION",
            source_doc_id=route.id,
            source_line_id=op2.id,
            source_material_summary="产品：五金支架；已完成工序：第2道 折弯",
        ),
        context=context,
        db=db_session,
    )

    create_warehouse_outbound(
        WarehouseOutboundCreate(
            biz_type="WIP_OUT",
            item_id=product.id,
            warehouse_id=warehouse.id,
            outbound_weight_kg=30,
            target_doc_type="PRODUCT_ROUTE_OPERATION",
            target_doc_id=route.id,
            target_doc_line_id=op2.id,
            target_material_sub_batch_no="第2道 折弯",
        ),
        context=context,
        db=db_session,
    )

    first_lot = db_session.get(StockLot, first.lot_id)
    second_lot = db_session.get(StockLot, second.lot_id)
    assert first_lot.remaining_weight_kg == Decimal("50.000")
    assert second_lot.remaining_weight_kg == Decimal("50.000")
```

- [ ] **Step 2: Run backend test to verify it fails**

Run: `cd backend && .venv/bin/python -m pytest tests/test_semi_finished_operation_inventory.py -q`

Expected: FAIL because `WarehouseInboundCreate` has no `source_line_id` and inbound ignores operation.

- [ ] **Step 3: Implement schema and query fields**

Modify `backend/app/schemas/operations.py`:

```python
class StockLotRead(BaseModel):
    ...
    source_doc_id: int
    source_line_id: int | None = None
    ...


class WarehouseInboundCreate(BaseModel):
    ...
    source_doc_id: int | None = None
    source_line_id: int | None = None
    ...
```

Modify `backend/app/services/operations.py` inside `get_stock_lots_query` select list:

```python
StockLot.source_doc_id.label("source_doc_id"),
StockLot.source_line_id.label("source_line_id"),
```

- [ ] **Step 4: Implement inbound validation and persistence**

In `backend/app/api/routes/inventory.py`, import `ProcessRoute` and `ProcessRouteOperation` if missing. Add helper:

```python
def _validate_semi_product_operation(db: Session, item_id: int, source_line_id: int | None) -> ProcessRouteOperation:
    if not source_line_id:
        raise HTTPException(status_code=400, detail="半成品入出库必须选择产品工序")
    operation = db.get(ProcessRouteOperation, source_line_id)
    if not operation:
        raise HTTPException(status_code=404, detail="产品工序不存在")
    route = db.get(ProcessRoute, operation.route_id)
    if not route or route.product_item_id != item_id:
        raise HTTPException(status_code=400, detail="产品工序与所选产品不一致")
    if str(operation.status or "").upper() != "ACTIVE":
        raise HTTPException(status_code=400, detail="停用的产品工序不能用于半成品入出库")
    return operation
```

Before creating a new half-finished inbound lot:

```python
if warehouse_type == "SEMI" and biz_type in {"WIP_IN", "OUTSOURCING_IN"}:
    operation = _validate_semi_product_operation(db, payload.item_id, payload.source_line_id)
    source_doc_type = payload.source_doc_type or "PRODUCT_ROUTE_OPERATION"
    source_doc_id = payload.source_doc_id or operation.route_id
    source_line_id = operation.id
else:
    source_doc_id = payload.source_doc_id or 0
    source_line_id = payload.source_line_id
```

Then use `source_doc_id` and `source_line_id` when creating `StockLot` and `InventoryTxn`.

- [ ] **Step 5: Implement semi outbound operation filter**

In `create_warehouse_outbound`, before FIFO query for non-selected lots:

```python
if warehouse_type == "SEMI" and biz_type in {"WIP_OUT", "OUTSOURCING_OUT"}:
    _validate_semi_product_operation(db, payload.item_id, payload.target_doc_line_id)
```

Add this filter to `lot_filters`:

```python
if warehouse_type == "SEMI" and payload.target_doc_line_id:
    lot_filters.append(StockLot.source_line_id == payload.target_doc_line_id)
```

- [ ] **Step 6: Run backend test to verify it passes**

Run: `cd backend && .venv/bin/python -m pytest tests/test_semi_finished_operation_inventory.py -q`

Expected: PASS.

---

### Task 3: 前端半成品页面按产品工序展示、提交和过滤

**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- Test: `frontend/scripts/test-inventory-ledger-options.mjs`

- [ ] **Step 1: Add process route loading state**

Add:

```js
const processRoutes = ref([]);
```

Update `loadAll()` to fetch:

```js
fetchResource("/master-data/process-routes?limit=500", [])
```

and assign:

```js
processRoutes.value = processRouteRows;
```

- [ ] **Step 2: Build active product operation options**

Add computed:

```js
const productOperationOptions = computed(() =>
  processRoutes.value
    .filter((route) => String(route.status || "").toUpperCase() === "ACTIVE")
    .flatMap((route) =>
      (route.operations || [])
        .filter((operation) => String(operation.status || "").toUpperCase() === "ACTIVE")
        .map((operation) => ({
          option_key: `${route.product_item_id}::${operation.route_operation_id}`,
          item_id: Number(route.product_item_id),
          item_code: route.product_code,
          item_name: route.product_name,
          version_no: route.version_no,
          route_id: Number(route.route_id),
          route_operation_id: Number(operation.route_operation_id),
          seq_no: Number(operation.seq_no || 0),
          operation_name: operation.operation_name || operation.process_name || "",
          source_material_summary: `产品：${route.product_name}；已完成工序：第${operation.seq_no}道 ${operation.operation_name || operation.process_name || ""}`
        }))
    )
    .sort((left, right) =>
      String(left.item_code || "").localeCompare(String(right.item_code || ""), "zh-CN", { numeric: true }) ||
      Number(left.seq_no || 0) - Number(right.seq_no || 0)
    )
);
```

- [ ] **Step 3: Change generic form selection to option key**

Extend `buildEmptyGenericForm()`:

```js
item_option_key: "",
source_doc_id: null,
source_line_id: null,
```

Add:

```js
const isSemiWarehouseOperation = computed(() => activeWarehouseType.value === "SEMI");
const genericItemFieldLabel = computed(() => (isSemiWarehouseOperation.value ? "产品工序" : activeInventoryTab.value === "finished" || activeInventoryTab.value === "return" ? "产品" : "物料"));
const genericItemPlaceholder = computed(() => `请选择${genericItemFieldLabel.value}`);
const selectedGenericItemOption = computed(() =>
  genericItemOptions.value.find((item) => String(item.option_key || item.item_id) === String(genericForm.item_option_key)) || null
);
```

Change the generic item `<select>` to bind `genericForm.item_option_key` and show `genericItemFieldLabel`.

- [ ] **Step 4: Submit semi operation metadata**

Before POST in `submitGenericOperation()`, derive:

```js
const selectedOption = selectedGenericItemOption.value;
const selectedItemId = Number(selectedOption?.item_id || genericForm.item_id);
const selectedSourceDocId = selectedOption?.route_id || genericForm.source_doc_id || null;
const selectedSourceLineId = selectedOption?.route_operation_id || genericForm.source_line_id || null;
const selectedSourceSummary = selectedOption?.source_material_summary || genericForm.provider_name || genericForm.source_material_sub_batch_no || null;
```

Use `selectedItemId` for `item_id`. For semi inbound send:

```js
source_doc_type: "PRODUCT_ROUTE_OPERATION",
source_doc_id: selectedSourceDocId,
source_line_id: selectedSourceLineId,
source_material_summary: selectedSourceSummary,
```

For semi outbound send:

```js
target_doc_type: "PRODUCT_ROUTE_OPERATION",
target_doc_id: selectedSourceDocId,
target_doc_line_id: selectedSourceLineId,
target_material_sub_batch_no: selectedOption ? `第${selectedOption.seq_no}道 ${selectedOption.operation_name}` : null,
```

- [ ] **Step 5: Display semi balances by product operation**

Create `semiFinishedBalances` from `lots` instead of `balances`, grouped by `item_id + source_line_id`:

```js
const semiFinishedBalances = computed(() => aggregateSemiOperationBalances(lots.value.filter((item) => normalizeWarehouseType(item) === "SEMI")));
```

Implement:

```js
function aggregateSemiOperationBalances(rows) {
  const map = new Map();
  rows.forEach((lot) => {
    const key = `${Number(lot.item_id)}::${Number(lot.source_line_id || 0)}`;
    if (!map.has(key)) {
      map.set(key, {
        ...lot,
        stock_balance_id: `SEMI-${key}`,
        source_line_id: lot.source_line_id || null,
        operation_label: lot.source_line_id ? lot.source_material_summary || "产品工序未说明" : "未绑定工序",
        qty_on_hand: 0,
        weight_on_hand_kg: 0,
        qty_available: 0,
        weight_available_kg: 0,
        qty_allocated: 0,
        weight_allocated_kg: 0,
        avg_unit_cost: 0,
        _cost_amount: 0,
        _weight_for_cost: 0,
        _warehouses: new Set(),
        _locations: new Set()
      });
    }
    const row = map.get(key);
    row.weight_on_hand_kg += Number(lot.remaining_weight_kg || 0);
    row.weight_available_kg += String(lot.status || "").toUpperCase() === "AVAILABLE" ? Number(lot.remaining_weight_kg || 0) : 0;
    row._cost_amount += Number(lot.unit_cost || 0) * Number(lot.remaining_weight_kg || 0);
    row._weight_for_cost += Number(lot.remaining_weight_kg || 0);
    row._warehouses.add(lot.warehouse_name);
    if (lot.location_name) {
      row._locations.add(lot.location_name);
    }
  });
  return Array.from(map.values()).map((item) => ({
    ...item,
    warehouse_name: Array.from(item._warehouses).join(" / ") || "-",
    location_name: Array.from(item._locations).join(" / ") || "-",
    avg_unit_cost: item._weight_for_cost > 0 ? item._cost_amount / item._weight_for_cost : 0
  }));
}
```

- [ ] **Step 6: Filter detail drawer by operation**

In `selectedLots`, add:

```js
if (selectedWarehouseType.value === "SEMI" && selectedBalance.value?.source_line_id !== undefined) {
  return rows.filter((lot) => Number(lot.source_line_id || 0) === Number(selectedBalance.value.source_line_id || 0));
}
```

In `selectedTransactions`, add the same `source_line_id` filter for `SEMI`.

- [ ] **Step 7: Run frontend tests**

Run:

```bash
node frontend/scripts/test-inventory-ledger-options.mjs
npm run build
```

Expected: both PASS.

---

### Task 4: 全量验证

**Files:**
- No direct file changes; verification only.

- [ ] **Step 1: Run focused backend tests**

Run: `cd backend && .venv/bin/python -m pytest tests/test_semi_finished_operation_inventory.py -q`

Expected: PASS.

- [ ] **Step 2: Run full backend suite**

Run: `cd backend && .venv/bin/python -m pytest tests -q`

Expected: PASS.

- [ ] **Step 3: Run frontend option test and build**

Run:

```bash
node frontend/scripts/test-inventory-ledger-options.mjs
npm run build
```

Expected: PASS.

- [ ] **Step 4: Manual UI sanity check if local server is already running**

Open the inventory page, select `嘉恒仓库 -> 半成品库` and verify:

```text
半成品入库字段显示“产品工序”
下拉项显示“产品编码 · 产品名 · 第N道工序 · 工序名”
库存总览按“同产品不同工序”拆成多行
点开明细只看到该产品工序对应的批次与流水
```

---

### Self-Review

- Spec coverage: 计划覆盖用户确认的方案 A，包括产品工序作为半成品库存对象、入库选产品工序、出库按工序扣减、明细和流水按工序过滤。
- Placeholder scan: 无 `TBD`、`TODO` 或“后续实现”占位。
- Type consistency: 前端统一使用 `route_operation_id/source_line_id`，后端统一使用 `source_line_id` 保存产品工序，`source_doc_id` 保存工艺路线。
