19 KiB
半成品产品工序库存 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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
const processRoutes = ref([]);
Update loadAll() to fetch:
fetchResource("/master-data/process-routes?limit=500", [])
and assign:
processRoutes.value = processRouteRows;
- Step 2: Build active product operation options
Add computed:
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():
item_option_key: "",
source_doc_id: null,
source_line_id: null,
Add:
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:
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:
source_doc_type: "PRODUCT_ROUTE_OPERATION",
source_doc_id: selectedSourceDocId,
source_line_id: selectedSourceLineId,
source_material_summary: selectedSourceSummary,
For semi outbound send:
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:
const semiFinishedBalances = computed(() => aggregateSemiOperationBalances(lots.value.filter((item) => normalizeWarehouseType(item) === "SEMI")));
Implement:
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:
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:
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:
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:
半成品入库字段显示“产品工序”
下拉项显示“产品编码 · 产品名 · 第N道工序 · 工序名”
库存总览按“同产品不同工序”拆成多行
点开明细只看到该产品工序对应的批次与流水
Self-Review
- Spec coverage: 计划覆盖用户确认的方案 A,包括产品工序作为半成品库存对象、入库选产品工序、出库按工序扣减、明细和流水按工序过滤。
- Placeholder scan: 无
TBD、TODO或“后续实现”占位。 - Type consistency: 前端统一使用
route_operation_id/source_line_id,后端统一使用source_line_id保存产品工序,source_doc_id保存工艺路线。