aps-agent/server/aps_domain/sourcing.py

231 lines
9.6 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"外协|委外|调质|正火|退火|淬火(?!前)|热处理|电镀|氧化|喷涂|发黑|渗碳|氮化|粉末|表面处理",
)
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