ForgeFlow-ERP/docs/superpowers/plans/2026-06-02-semi-finished-operation-inventory.md
2026-06-12 16:00:56 +08:00

19 KiB
Raw Blame History

半成品产品工序库存 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_idInventoryTxn.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:

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: 无 TBDTODO 或“后续实现”占位。
  • Type consistency: 前端统一使用 route_operation_id/source_line_id,后端统一使用 source_line_id 保存产品工序,source_doc_id 保存工艺路线。