ForgeFlow-ERP/docs/superpowers/plans/2026-06-02-semi-finished-operation-inventory.md
2026-06-14 21:05:49 +08:00

594 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 半成品产品工序库存 Implementation Plan
> **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 + PytestVue 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` 保存工艺路线。