# 半成品产品工序库存 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 `