594 lines
19 KiB
Markdown
594 lines
19 KiB
Markdown
# 半成品产品工序库存 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` 保存工艺路线。
|