aps-agent/server/aps_domain/sourcing.py

363 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 工序/物料供应类型(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"外协|委外|调质|正火|退火|淬火(?!前)|热处理|电镀|氧化|喷涂|发黑|渗碳|氮化|粉末|表面处理",
)
# 半成品名称启发式(焊接组件/铆接组件 均含“组件”;支架也是组件类半成品)
_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(), "")
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,
has_bom: bool = False,
) -> str:
"""物料供应:BUY(采购)| MAKE(自制/半成品继续分解)。
判定优先级:显式采购属性 > BOM/工艺结构 > 物料类型 > 旧字段兜底。
半成品缺工艺是主数据缺口,不能因此伪装成采购件。
"""
mat = mat or {}
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"
name = str(mat.get("name") or "")
mtype = str(mat.get("type") or "").upper()
if has_routing or has_bom:
return "MAKE"
if mtype in ("FINISHED_PRODUCT", "SEMI_FINISHED"):
return "MAKE"
if legacy in ("MAKE", "自制", "PRODUCTION"):
return "MAKE"
if "毛坯" in name or mtype == "RAW_MATERIAL":
return "BUY"
return "BUY"
def annotate_world_sourcing(world: World) -> dict[str, int]:
"""给柔性/经典工艺、工序与物料补齐供应类型(幂等)。
注意:旧 SQL 投影曾把所有步骤写成 isExternal=False;此处对 WZ* 工序强制纠正为委外。
物料按显式采购属性、BOM/工艺结构和物料类型重算,避免旧的派生 BUY 值把半成品锁死为采购。
"""
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
# 物料供应类型:显式采购属性优先;BOM 父项/有默认工艺/成品半成品均按自制。
product_ids_with_route = {
r.get("productId") for r in (world.get("routings") or []) if r.get("isDefault")
}
product_ids_with_bom = {
b.get("productId") for b in (world.get("boms") or []) if b.get("isDefault", True)
}
codes_with_route = {
str(r.get("productCode") or "") for r in (world.get("flexRoutings") or [])
if r.get("productCode")
}
codes_with_bom = {
str(b.get("productCode") or "") for b in (world.get("flexBom") or [])
if b.get("productCode")
}
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
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"
)
for mat in world.get("flexMaterials") or []:
has_rt = mat.get("code") in codes_with_route
has_bom = mat.get("code") in codes_with_bom
classic = mats_by_code.get(mat.get("code"))
if classic and classic.get("sourcingType"):
mat["sourcingType"] = classic["sourcingType"]
mat["sourcingTypeSource"] = classic.get("sourcingTypeSource") or "INFERRED"
else:
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"
)
return stats
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 "原料"
def build_process_tree(world: World, product_code: str, *, max_depth: int = 8) -> dict[str, Any]:
"""工艺路线树:物料节点下挂工序,再挂 BOM 子件(对标现场主数据树)。
树形表格契约(只增不删):
- 物料节点:typeLabel(成品/半成品/原料) / sourcingLabel(自制/委外/采购)
/ quantity / unit / drawingNo / sourcingType / hasRouting / isLeafMaterial
- 工序节点:typeLabel=工序 / sourcingLabel / seq / operationName / sourcingType
"""
mats = {m.get("code"): m for m in (world.get("flexMaterials") or []) + (world.get("materials") or [])
if m.get("code")}
# 工序字典(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 优先)
bom_by_pc: dict[str, list] = {}
bom_seen: dict[str, set[str]] = {}
for b in world.get("flexBom") or []:
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 已覆盖的子件跳过)
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
if mc in bom_seen.setdefault(pc, set()):
continue
bom_seen[pc].add(mc)
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)
# classic fallback(该料号无 flex 工艺时)
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 {}
op_code = op.get("code")
op_name = op.get("name") or (op_by_code.get(str(op_code or "")) or {}).get("name")
ops_by_pc.setdefault(pc, []).append({
"productCode": pc, "seq": s.get("sequenceNo"),
"operationCode": op_code, "operationName": op_name,
"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)
mat_type = mat.get("type") or ""
type_label = infer_material_type_label(mat, is_bom_parent=code in bom_by_pc)
node: dict[str, Any] = {
"kind": "material",
"code": code,
"name": mat.get("name") or code,
"type": mat_type,
# 展示辅助字段(只增不减):工艺路线树层级语义
"typeLabel": type_label,
"sourcingLabel": sourcing_label(sourcing),
"hasRouting": has_rt,
"isLeafMaterial": False, # 无工序且无子件时为叶子
"unit": mat.get("unit") or "",
"sourcingType": sourcing,
"quantity": qty,
"drawingNo": mat.get("spec") or "",
"children": [],
}
if depth >= max_depth or code in seen:
node["isLeafMaterial"] = True
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"))
op_code = r.get("operationCode")
op_name = r.get("operationName") or (op_by_code.get(str(op_code or "")) or {}).get("name")
node["children"].append({
"kind": "operation",
"code": op_code,
"name": op_name or op_code,
"typeLabel": "工序",
"sourcingLabel": sourcing_label(src),
"seq": r.get("seq"),
"operationName": op_name or "",
"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)
node["isLeafMaterial"] = not node["children"]
return node
root = _node(product_code, 1.0, 0)
root["productId"] = code_to_id.get(product_code)
return root