aps-agent/server/aps_domain/sourcing.py

231 lines
9.6 KiB
Python
Raw Normal View History

# ============================================================
# 工序/物料供应类型(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"外协|委外|调质|正火|退火|淬火(?!前)|热处理|电镀|氧化|喷涂|发黑|渗碳|氮化|粉末|表面处理",
)
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,
) -> str:
"""物料供应:BUY(采购)| MAKE(自制/半成品继续分解)。"""
mat = mat or {}
explicit = str(mat.get("sourcingType") or mat.get("procurementType") or "").upper()
if explicit in ("BUY", "PURCHASE", "采购"):
return "BUY"
if explicit in ("MAKE", "自制", "PRODUCTION"):
return "MAKE"
name = str(mat.get("name") or "")
mtype = str(mat.get("type") or "").upper()
if "毛坯" in name or mtype == "RAW_MATERIAL":
return "BUY"
if has_routing:
return "MAKE"
if mtype in ("FINISHED_PRODUCT", "SEMI_FINISHED"):
# 有成品/半成品类型但无工艺 → 仍按采购(外购件)
return "BUY"
return "BUY"
def annotate_world_sourcing(world: World) -> dict[str, int]:
"""给柔性/经典工艺与工序补齐委外标记(幂等)。
注意:旧 SQL 投影曾把所有步骤写成 isExternal=False;此处对 WZ* 工序强制纠正为委外。
"""
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
# 物料供应类型:毛坯/原料=采购;有默认工艺=自制
product_ids_with_route = {
r.get("productId") for r in (world.get("routings") or []) if r.get("isDefault")
}
codes_with_route = {
str(r.get("productCode") or "") for r in (world.get("flexRoutings") or [])
}
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
mat["sourcingType"] = infer_material_sourcing(mat, has_routing=has_rt)
for mat in world.get("flexMaterials") or []:
has_rt = mat.get("code") in codes_with_route
classic = mats_by_code.get(mat.get("code"))
if classic and classic.get("sourcingType"):
mat["sourcingType"] = classic["sourcingType"]
else:
mat["sourcingType"] = infer_material_sourcing(mat, has_routing=has_rt)
return stats
def build_process_tree(world: World, product_code: str, *, max_depth: int = 8) -> dict[str, Any]:
"""工艺路线树:物料节点下挂工序,再挂 BOM 子件(对标人家主数据树)。"""
mats = {m.get("code"): m for m in (world.get("flexMaterials") or []) + (world.get("materials") or [])
if m.get("code")}
bom_by_pc: dict[str, list] = {}
for b in world.get("flexBom") or []:
bom_by_pc.setdefault(str(b.get("productCode") or ""), []).append(b)
# classic BOM by product code
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
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
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 {}
ops_by_pc.setdefault(pc, []).append({
"productCode": pc, "seq": s.get("sequenceNo"),
"operationCode": op.get("code"), "operationName": op.get("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)
node: dict[str, Any] = {
"kind": "material",
"code": code,
"name": mat.get("name") or code,
"type": mat.get("type") or "",
"sourcingType": sourcing,
"quantity": qty,
"drawingNo": mat.get("spec") or "",
"children": [],
}
if depth >= max_depth or code in seen:
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"))
node["children"].append({
"kind": "operation",
"code": r.get("operationCode"),
"name": r.get("operationName") or r.get("operationCode"),
"seq": r.get("seq"),
"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)
return node
root = _node(product_code, 1.0, 0)
root["productId"] = code_to_id.get(product_code)
return root