2026-07-28 02:12:46 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
# 工序/物料供应类型(moduleId: domain-sourcing, 可重生 ✅)
|
|
|
|
|
|
# 对标现场 MES:NZ* = 厂内自制,WZ* = 委外;BOM 子件无工艺 → 采购。
|
|
|
|
|
|
# 排产围绕工艺路线分解:成品自制 → 外协工序委外单 → 原料/毛坯采购单。
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
World = dict[str, Any]
|
|
|
|
|
|
|
|
|
|
|
|
# 委外工序名启发式(WZ* 编码优先;名称兜底)
|
|
|
|
|
|
_OUTSOURCE_NAME_RE = re.compile(
|
|
|
|
|
|
r"外协|委外|调质|正火|退火|淬火(?!前)|热处理|电镀|氧化|喷涂|发黑|渗碳|氮化|粉末|表面处理",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 00:54:05 +08:00
|
|
|
|
# 半成品名称启发式(焊接组件/铆接组件 均含“组件”;支架也是组件类半成品)
|
|
|
|
|
|
_SEMI_NAME_RE = re.compile(r"组件|总成|部件|支架")
|
|
|
|
|
|
# 标准件编号(GB / GB-T / ISO…)不推导为半成品
|
|
|
|
|
|
_STD_PART_RE = re.compile(r"GB\s*(?:/|\s)?T?\s*[-—]?\s*\d+|ISO\s*-?\s*\d+")
|
|
|
|
|
|
# 来源展示标签:MAKE→自制 / OUTSOURCE→委外 / BUY→采购
|
|
|
|
|
|
_SOURCING_LABEL = {
|
|
|
|
|
|
"MAKE": "自制",
|
|
|
|
|
|
"OUTSOURCE": "委外",
|
|
|
|
|
|
"BUY": "采购",
|
|
|
|
|
|
"OWNER_SUPPLIED": "船东供料",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def sourcing_label(sourcing_type: str | None) -> str:
|
|
|
|
|
|
"""来源展示标签:MAKE→自制 / OUTSOURCE→委外 / BUY→采购。"""
|
|
|
|
|
|
return _SOURCING_LABEL.get(str(sourcing_type or "").upper(), "")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
def infer_op_sourcing(code: str | None, name: str | None = None) -> str:
|
|
|
|
|
|
"""工序供应类型:MAKE(自制)| OUTSOURCE(委外)。"""
|
|
|
|
|
|
c = str(code or "").strip().upper()
|
|
|
|
|
|
n = str(name or "").strip()
|
|
|
|
|
|
if c.startswith("WZ"):
|
|
|
|
|
|
return "OUTSOURCE"
|
|
|
|
|
|
if re.search(r"外协|委外", n):
|
|
|
|
|
|
return "OUTSOURCE"
|
|
|
|
|
|
# 无编码仅靠名称时,调质/电镀等也标委外(现场常见)
|
|
|
|
|
|
if not c and _OUTSOURCE_NAME_RE.search(n):
|
|
|
|
|
|
return "OUTSOURCE"
|
|
|
|
|
|
return "MAKE"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_outsource_step(step: dict[str, Any], op: dict[str, Any] | None = None) -> bool:
|
|
|
|
|
|
"""步骤是否委外:步骤显式 isExternal 优先;否则看 sourcingType / 工序 type / 编码推断。"""
|
|
|
|
|
|
if "isExternal" in step:
|
|
|
|
|
|
return bool(step.get("isExternal"))
|
|
|
|
|
|
st = str(step.get("sourcingType") or step.get("opType") or "").upper()
|
|
|
|
|
|
if st in ("OUTSOURCE", "EXTERNAL", "外协", "委外"):
|
|
|
|
|
|
return True
|
|
|
|
|
|
if st in ("MAKE", "INTERNAL", "自制"):
|
|
|
|
|
|
return False
|
|
|
|
|
|
op = op or {}
|
|
|
|
|
|
if str(op.get("type") or "").upper() in ("EXTERNAL", "OUTSOURCE"):
|
|
|
|
|
|
return True
|
|
|
|
|
|
code = step.get("operationCode") or op.get("code")
|
|
|
|
|
|
name = step.get("operationName") or op.get("name")
|
|
|
|
|
|
return infer_op_sourcing(code, name) == "OUTSOURCE"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def infer_material_sourcing(
|
|
|
|
|
|
mat: dict[str, Any] | None,
|
|
|
|
|
|
*,
|
|
|
|
|
|
has_routing: bool = False,
|
2026-08-11 00:54:05 +08:00
|
|
|
|
has_bom: bool = False,
|
2026-07-28 02:12:46 +08:00
|
|
|
|
) -> str:
|
2026-08-11 00:54:05 +08:00
|
|
|
|
"""物料供应:BUY(采购)| MAKE(自制/半成品继续分解)。
|
|
|
|
|
|
|
|
|
|
|
|
判定优先级:显式采购属性 > BOM/工艺结构 > 物料类型 > 旧字段兜底。
|
|
|
|
|
|
半成品缺工艺是主数据缺口,不能因此伪装成采购件。
|
|
|
|
|
|
"""
|
2026-07-28 02:12:46 +08:00
|
|
|
|
mat = mat or {}
|
2026-08-11 00:54:05 +08:00
|
|
|
|
explicit_values = (mat.get("sourcingMode"), mat.get("procurementType"))
|
|
|
|
|
|
for explicit_value in explicit_values:
|
|
|
|
|
|
explicit = str(explicit_value or "").strip().upper().replace("-", "_")
|
|
|
|
|
|
if explicit in ("BUY", "PURCHASE", "采购"):
|
|
|
|
|
|
return "BUY"
|
|
|
|
|
|
if explicit in ("MAKE", "自制", "PRODUCTION"):
|
|
|
|
|
|
return "MAKE"
|
|
|
|
|
|
if explicit in ("OUTSOURCE", "EXTERNAL", "SUBCONTRACT", "委外", "外协"):
|
|
|
|
|
|
return "OUTSOURCE"
|
|
|
|
|
|
if explicit in (
|
|
|
|
|
|
"OWNER_SUPPLIED",
|
|
|
|
|
|
"CUSTOMER_SUPPLIED",
|
|
|
|
|
|
"OWNER",
|
|
|
|
|
|
"甲供",
|
|
|
|
|
|
"船东供",
|
|
|
|
|
|
"船东供料",
|
|
|
|
|
|
):
|
|
|
|
|
|
return "OWNER_SUPPLIED"
|
|
|
|
|
|
|
|
|
|
|
|
legacy = str(mat.get("sourcingType") or "").strip().upper()
|
|
|
|
|
|
source = str(mat.get("sourcingTypeSource") or "").strip().upper()
|
|
|
|
|
|
if source in ("EXPLICIT", "MANUAL", "IMPORT"):
|
|
|
|
|
|
if legacy in ("BUY", "PURCHASE", "采购"):
|
|
|
|
|
|
return "BUY"
|
|
|
|
|
|
if legacy in ("MAKE", "自制", "PRODUCTION"):
|
|
|
|
|
|
return "MAKE"
|
|
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
name = str(mat.get("name") or "")
|
|
|
|
|
|
mtype = str(mat.get("type") or "").upper()
|
2026-08-11 00:54:05 +08:00
|
|
|
|
if has_routing or has_bom:
|
2026-07-28 02:12:46 +08:00
|
|
|
|
return "MAKE"
|
|
|
|
|
|
if mtype in ("FINISHED_PRODUCT", "SEMI_FINISHED"):
|
2026-08-11 00:54:05 +08:00
|
|
|
|
return "MAKE"
|
|
|
|
|
|
if legacy in ("MAKE", "自制", "PRODUCTION"):
|
|
|
|
|
|
return "MAKE"
|
|
|
|
|
|
if "毛坯" in name or mtype == "RAW_MATERIAL":
|
2026-07-28 02:12:46 +08:00
|
|
|
|
return "BUY"
|
|
|
|
|
|
return "BUY"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def annotate_world_sourcing(world: World) -> dict[str, int]:
|
2026-08-11 00:54:05 +08:00
|
|
|
|
"""给柔性/经典工艺、工序与物料补齐供应类型(幂等)。
|
2026-07-28 02:12:46 +08:00
|
|
|
|
|
|
|
|
|
|
注意:旧 SQL 投影曾把所有步骤写成 isExternal=False;此处对 WZ* 工序强制纠正为委外。
|
2026-08-11 00:54:05 +08:00
|
|
|
|
物料按显式采购属性、BOM/工艺结构和物料类型重算,避免旧的派生 BUY 值把半成品锁死为采购。
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"""
|
|
|
|
|
|
stats = {"flexRoutings": 0, "operations": 0, "routingSteps": 0, "flexOperations": 0}
|
|
|
|
|
|
|
|
|
|
|
|
for row in world.get("flexRoutings") or []:
|
|
|
|
|
|
src = infer_op_sourcing(row.get("operationCode"), row.get("operationName"))
|
|
|
|
|
|
row["sourcingType"] = src
|
|
|
|
|
|
ext = src == "OUTSOURCE"
|
|
|
|
|
|
if bool(row.get("isExternal")) != ext:
|
|
|
|
|
|
stats["flexRoutings"] += 1
|
|
|
|
|
|
row["isExternal"] = ext
|
|
|
|
|
|
|
|
|
|
|
|
for op in world.get("flexOperations") or []:
|
|
|
|
|
|
src = infer_op_sourcing(op.get("code"), op.get("name"))
|
|
|
|
|
|
op["sourcingType"] = src
|
|
|
|
|
|
want = "EXTERNAL" if src == "OUTSOURCE" else "INTERNAL"
|
|
|
|
|
|
if op.get("type") != want:
|
|
|
|
|
|
stats["flexOperations"] += 1
|
|
|
|
|
|
op["type"] = want
|
|
|
|
|
|
|
|
|
|
|
|
for op in world.get("operations") or []:
|
|
|
|
|
|
src = infer_op_sourcing(op.get("code"), op.get("name"))
|
|
|
|
|
|
op["sourcingType"] = src
|
|
|
|
|
|
want = "EXTERNAL" if src == "OUTSOURCE" else "INTERNAL"
|
|
|
|
|
|
if op.get("type") != want:
|
|
|
|
|
|
stats["operations"] += 1
|
|
|
|
|
|
op["type"] = want
|
|
|
|
|
|
|
|
|
|
|
|
ops_by_id = {o.get("id"): o for o in (world.get("operations") or [])}
|
|
|
|
|
|
for step in world.get("routingSteps") or []:
|
|
|
|
|
|
op = ops_by_id.get(step.get("operationId")) or {}
|
|
|
|
|
|
inferred = infer_op_sourcing(op.get("code"), op.get("name"))
|
|
|
|
|
|
if inferred == "OUTSOURCE":
|
|
|
|
|
|
ext = True
|
|
|
|
|
|
else:
|
|
|
|
|
|
# 非 WZ:保留人工勾选;无字段时默认自制
|
|
|
|
|
|
ext = bool(step["isExternal"]) if "isExternal" in step else False
|
|
|
|
|
|
step["sourcingType"] = "OUTSOURCE" if ext else "MAKE"
|
|
|
|
|
|
if bool(step.get("isExternal")) != ext:
|
|
|
|
|
|
stats["routingSteps"] += 1
|
|
|
|
|
|
step["isExternal"] = ext
|
|
|
|
|
|
|
2026-08-11 00:54:05 +08:00
|
|
|
|
# 物料供应类型:显式采购属性优先;BOM 父项/有默认工艺/成品半成品均按自制。
|
2026-07-28 02:12:46 +08:00
|
|
|
|
product_ids_with_route = {
|
|
|
|
|
|
r.get("productId") for r in (world.get("routings") or []) if r.get("isDefault")
|
|
|
|
|
|
}
|
2026-08-11 00:54:05 +08:00
|
|
|
|
product_ids_with_bom = {
|
|
|
|
|
|
b.get("productId") for b in (world.get("boms") or []) if b.get("isDefault", True)
|
|
|
|
|
|
}
|
2026-07-28 02:12:46 +08:00
|
|
|
|
codes_with_route = {
|
|
|
|
|
|
str(r.get("productCode") or "") for r in (world.get("flexRoutings") or [])
|
2026-08-11 00:54:05 +08:00
|
|
|
|
if r.get("productCode")
|
|
|
|
|
|
}
|
|
|
|
|
|
codes_with_bom = {
|
|
|
|
|
|
str(b.get("productCode") or "") for b in (world.get("flexBom") or [])
|
|
|
|
|
|
if b.get("productCode")
|
2026-07-28 02:12:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
mats_by_code = {m.get("code"): m for m in (world.get("materials") or []) if m.get("code")}
|
|
|
|
|
|
for mat in world.get("materials") or []:
|
|
|
|
|
|
has_rt = mat.get("id") in product_ids_with_route or mat.get("code") in codes_with_route
|
2026-08-11 00:54:05 +08:00
|
|
|
|
has_bom = mat.get("id") in product_ids_with_bom or mat.get("code") in codes_with_bom
|
|
|
|
|
|
source = str(mat.get("sourcingTypeSource") or "").strip().upper()
|
|
|
|
|
|
mat["sourcingType"] = infer_material_sourcing(
|
|
|
|
|
|
mat, has_routing=has_rt, has_bom=has_bom,
|
|
|
|
|
|
)
|
|
|
|
|
|
mat["sourcingTypeSource"] = (
|
|
|
|
|
|
source if source in ("EXPLICIT", "MANUAL", "IMPORT") else "INFERRED"
|
|
|
|
|
|
)
|
2026-07-28 02:12:46 +08:00
|
|
|
|
for mat in world.get("flexMaterials") or []:
|
|
|
|
|
|
has_rt = mat.get("code") in codes_with_route
|
2026-08-11 00:54:05 +08:00
|
|
|
|
has_bom = mat.get("code") in codes_with_bom
|
2026-07-28 02:12:46 +08:00
|
|
|
|
classic = mats_by_code.get(mat.get("code"))
|
|
|
|
|
|
if classic and classic.get("sourcingType"):
|
|
|
|
|
|
mat["sourcingType"] = classic["sourcingType"]
|
2026-08-11 00:54:05 +08:00
|
|
|
|
mat["sourcingTypeSource"] = classic.get("sourcingTypeSource") or "INFERRED"
|
2026-07-28 02:12:46 +08:00
|
|
|
|
else:
|
2026-08-11 00:54:05 +08:00
|
|
|
|
source = str(mat.get("sourcingTypeSource") or "").strip().upper()
|
|
|
|
|
|
mat["sourcingType"] = infer_material_sourcing(
|
|
|
|
|
|
mat, has_routing=has_rt, has_bom=has_bom,
|
|
|
|
|
|
)
|
|
|
|
|
|
mat["sourcingTypeSource"] = (
|
|
|
|
|
|
source if source in ("EXPLICIT", "MANUAL", "IMPORT") else "INFERRED"
|
|
|
|
|
|
)
|
2026-07-28 02:12:46 +08:00
|
|
|
|
|
|
|
|
|
|
return stats
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 00:54:05 +08:00
|
|
|
|
def infer_material_type_label(mat: dict[str, Any] | None, *, is_bom_parent: bool = False) -> str:
|
|
|
|
|
|
"""物料类型展示标签:成品 / 半成品 / 原料。
|
|
|
|
|
|
|
|
|
|
|
|
半成品推导(APS 专家口径):type 为 RAW_MATERIAL,但
|
|
|
|
|
|
(a) BOM 里有它作为父级 product 的引用,或
|
|
|
|
|
|
(b) 名称含 组件/总成/部件/支架 且不是标准件(无 GB/ISO 编号)
|
|
|
|
|
|
→ typeLabel=半成品(如“上导轨组件 / 定位轮支架”这类半成品组件)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
mat = mat or {}
|
|
|
|
|
|
mtype = str(mat.get("type") or "").upper()
|
|
|
|
|
|
if mtype == "FINISHED_PRODUCT":
|
|
|
|
|
|
return "成品"
|
|
|
|
|
|
if mtype == "SEMI_FINISHED":
|
|
|
|
|
|
return "半成品"
|
|
|
|
|
|
name = " ".join(str(mat.get(k) or "") for k in ("name", "spec"))
|
|
|
|
|
|
if is_bom_parent or (_SEMI_NAME_RE.search(name) and not _STD_PART_RE.search(name)):
|
|
|
|
|
|
return "半成品"
|
|
|
|
|
|
return "原料"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
def build_process_tree(world: World, product_code: str, *, max_depth: int = 8) -> dict[str, Any]:
|
2026-08-11 00:54:05 +08:00
|
|
|
|
"""工艺路线树:物料节点下挂工序,再挂 BOM 子件(对标现场主数据树)。
|
|
|
|
|
|
|
|
|
|
|
|
树形表格契约(只增不删):
|
|
|
|
|
|
- 物料节点:typeLabel(成品/半成品/原料) / sourcingLabel(自制/委外/采购)
|
|
|
|
|
|
/ quantity / unit / drawingNo / sourcingType / hasRouting / isLeafMaterial
|
|
|
|
|
|
- 工序节点:typeLabel=工序 / sourcingLabel / seq / operationName / sourcingType
|
|
|
|
|
|
"""
|
2026-07-28 02:12:46 +08:00
|
|
|
|
mats = {m.get("code"): m for m in (world.get("flexMaterials") or []) + (world.get("materials") or [])
|
|
|
|
|
|
if m.get("code")}
|
2026-08-11 00:54:05 +08:00
|
|
|
|
# 工序字典(flexOperations 优先,classic operations 兜底):给工序节点补名称
|
|
|
|
|
|
op_by_code: dict[str, dict] = {}
|
|
|
|
|
|
for op in (world.get("flexOperations") or []) + (world.get("operations") or []):
|
|
|
|
|
|
c = op.get("code")
|
|
|
|
|
|
if c:
|
|
|
|
|
|
op_by_code.setdefault(str(c), op)
|
|
|
|
|
|
|
|
|
|
|
|
# BOM 按父料号聚合;flexBom 与经典 bomItems 是同一数据的两种表示 → 同父下物料去重(flexBom 优先)
|
2026-07-28 02:12:46 +08:00
|
|
|
|
bom_by_pc: dict[str, list] = {}
|
2026-08-11 00:54:05 +08:00
|
|
|
|
bom_seen: dict[str, set[str]] = {}
|
2026-07-28 02:12:46 +08:00
|
|
|
|
for b in world.get("flexBom") or []:
|
2026-08-11 00:54:05 +08:00
|
|
|
|
pc = str(b.get("productCode") or "")
|
|
|
|
|
|
mc = str(b.get("materialCode") or "")
|
|
|
|
|
|
if not pc or not mc:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if mc in bom_seen.setdefault(pc, set()):
|
|
|
|
|
|
continue
|
|
|
|
|
|
bom_seen[pc].add(mc)
|
|
|
|
|
|
bom_by_pc.setdefault(pc, []).append(b)
|
|
|
|
|
|
# classic BOM by product code(仅默认 BOM;与 flexBom 已覆盖的子件跳过)
|
2026-07-28 02:12:46 +08:00
|
|
|
|
id_to_code = {m.get("id"): m.get("code") for m in (world.get("materials") or [])}
|
|
|
|
|
|
code_to_id = {m.get("code"): m.get("id") for m in (world.get("materials") or []) if m.get("code")}
|
|
|
|
|
|
for bom in world.get("boms") or []:
|
|
|
|
|
|
if not bom.get("isDefault"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
pc = id_to_code.get(bom.get("productId"))
|
|
|
|
|
|
if not pc:
|
|
|
|
|
|
continue
|
|
|
|
|
|
for bi in world.get("bomItems") or []:
|
|
|
|
|
|
if bi.get("bomId") != bom["id"]:
|
|
|
|
|
|
continue
|
|
|
|
|
|
mc = id_to_code.get(bi.get("materialId"))
|
|
|
|
|
|
if not mc:
|
|
|
|
|
|
continue
|
2026-08-11 00:54:05 +08:00
|
|
|
|
if mc in bom_seen.setdefault(pc, set()):
|
|
|
|
|
|
continue
|
|
|
|
|
|
bom_seen[pc].add(mc)
|
2026-07-28 02:12:46 +08:00
|
|
|
|
bom_by_pc.setdefault(pc, []).append({
|
|
|
|
|
|
"productCode": pc, "materialCode": mc,
|
|
|
|
|
|
"quantity": bi.get("quantity") or 1,
|
|
|
|
|
|
"materialName": (mats.get(mc) or {}).get("name") or mc,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
ops_by_pc: dict[str, list] = {}
|
|
|
|
|
|
for r in world.get("flexRoutings") or []:
|
|
|
|
|
|
ops_by_pc.setdefault(str(r.get("productCode") or ""), []).append(r)
|
2026-08-11 00:54:05 +08:00
|
|
|
|
# classic fallback(该料号无 flex 工艺时)
|
2026-07-28 02:12:46 +08:00
|
|
|
|
ops_by_id = {o["id"]: o for o in (world.get("operations") or [])}
|
|
|
|
|
|
for routing in world.get("routings") or []:
|
|
|
|
|
|
if not routing.get("isDefault"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
pc = id_to_code.get(routing.get("productId"))
|
|
|
|
|
|
if not pc or pc in ops_by_pc:
|
|
|
|
|
|
continue
|
|
|
|
|
|
steps = [s for s in (world.get("routingSteps") or []) if s.get("routingId") == routing["id"]]
|
|
|
|
|
|
for s in sorted(steps, key=lambda x: x.get("sequenceNo") or 0):
|
|
|
|
|
|
op = ops_by_id.get(s.get("operationId")) or {}
|
2026-08-11 00:54:05 +08:00
|
|
|
|
op_code = op.get("code")
|
|
|
|
|
|
op_name = op.get("name") or (op_by_code.get(str(op_code or "")) or {}).get("name")
|
2026-07-28 02:12:46 +08:00
|
|
|
|
ops_by_pc.setdefault(pc, []).append({
|
|
|
|
|
|
"productCode": pc, "seq": s.get("sequenceNo"),
|
2026-08-11 00:54:05 +08:00
|
|
|
|
"operationCode": op_code, "operationName": op_name,
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"isExternal": s.get("isExternal"), "sourcingType": s.get("sourcingType"),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
seen: set[str] = set()
|
|
|
|
|
|
|
|
|
|
|
|
def _node(code: str, qty: float, depth: int) -> dict[str, Any]:
|
|
|
|
|
|
mat = mats.get(code) or {"code": code, "name": code}
|
|
|
|
|
|
has_rt = bool(ops_by_pc.get(code))
|
|
|
|
|
|
sourcing = infer_material_sourcing(mat, has_routing=has_rt)
|
2026-08-11 00:54:05 +08:00
|
|
|
|
mat_type = mat.get("type") or ""
|
|
|
|
|
|
type_label = infer_material_type_label(mat, is_bom_parent=code in bom_by_pc)
|
2026-07-28 02:12:46 +08:00
|
|
|
|
node: dict[str, Any] = {
|
|
|
|
|
|
"kind": "material",
|
|
|
|
|
|
"code": code,
|
|
|
|
|
|
"name": mat.get("name") or code,
|
2026-08-11 00:54:05 +08:00
|
|
|
|
"type": mat_type,
|
|
|
|
|
|
# 展示辅助字段(只增不减):工艺路线树层级语义
|
|
|
|
|
|
"typeLabel": type_label,
|
|
|
|
|
|
"sourcingLabel": sourcing_label(sourcing),
|
|
|
|
|
|
"hasRouting": has_rt,
|
|
|
|
|
|
"isLeafMaterial": False, # 无工序且无子件时为叶子
|
|
|
|
|
|
"unit": mat.get("unit") or "",
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"sourcingType": sourcing,
|
|
|
|
|
|
"quantity": qty,
|
|
|
|
|
|
"drawingNo": mat.get("spec") or "",
|
|
|
|
|
|
"children": [],
|
|
|
|
|
|
}
|
|
|
|
|
|
if depth >= max_depth or code in seen:
|
2026-08-11 00:54:05 +08:00
|
|
|
|
node["isLeafMaterial"] = True
|
2026-07-28 02:12:46 +08:00
|
|
|
|
return node
|
|
|
|
|
|
seen.add(code)
|
|
|
|
|
|
for r in sorted(ops_by_pc.get(code) or [], key=lambda x: int(x.get("seq") or 0)):
|
|
|
|
|
|
src = r.get("sourcingType") or infer_op_sourcing(r.get("operationCode"), r.get("operationName"))
|
2026-08-11 00:54:05 +08:00
|
|
|
|
op_code = r.get("operationCode")
|
|
|
|
|
|
op_name = r.get("operationName") or (op_by_code.get(str(op_code or "")) or {}).get("name")
|
2026-07-28 02:12:46 +08:00
|
|
|
|
node["children"].append({
|
|
|
|
|
|
"kind": "operation",
|
2026-08-11 00:54:05 +08:00
|
|
|
|
"code": op_code,
|
|
|
|
|
|
"name": op_name or op_code,
|
|
|
|
|
|
"typeLabel": "工序",
|
|
|
|
|
|
"sourcingLabel": sourcing_label(src),
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"seq": r.get("seq"),
|
2026-08-11 00:54:05 +08:00
|
|
|
|
"operationName": op_name or "",
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"sourcingType": src,
|
|
|
|
|
|
"isExternal": bool(r.get("isExternal")) or src == "OUTSOURCE",
|
|
|
|
|
|
"children": [],
|
|
|
|
|
|
})
|
|
|
|
|
|
for b in bom_by_pc.get(code) or []:
|
|
|
|
|
|
mc = str(b.get("materialCode") or "")
|
|
|
|
|
|
if not mc:
|
|
|
|
|
|
continue
|
|
|
|
|
|
child_qty = float(b.get("quantity") or 1) * qty
|
|
|
|
|
|
node["children"].append(_node(mc, child_qty, depth + 1))
|
|
|
|
|
|
seen.discard(code)
|
2026-08-11 00:54:05 +08:00
|
|
|
|
node["isLeafMaterial"] = not node["children"]
|
2026-07-28 02:12:46 +08:00
|
|
|
|
return node
|
|
|
|
|
|
|
|
|
|
|
|
root = _node(product_code, 1.0, 0)
|
|
|
|
|
|
root["productId"] = code_to_id.get(product_code)
|
|
|
|
|
|
return root
|