259 lines
10 KiB
Python
259 lines
10 KiB
Python
# ============================================================
|
||
# 换型矩阵主数据(moduleId: domain-changeover, MD-06 / C10,可重生 ✅)
|
||
# 产品族 × 产品族 setup 分钟;相邻工单换型耗时进 RuleEngine 占槽
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
World = dict[str, Any]
|
||
|
||
# 缺矩阵条目时的跨族默认换型(分钟);同族为 0
|
||
DEFAULT_CROSS_FAMILY_MIN = 30.0
|
||
|
||
|
||
def ensure_changeover_table(world: World) -> None:
|
||
"""旧 world 缺表时自愈。"""
|
||
if "changeoverMatrix" not in world or not isinstance(world.get("changeoverMatrix"), list):
|
||
world["changeoverMatrix"] = []
|
||
|
||
|
||
def product_family(mat: dict[str, Any] | None) -> str:
|
||
"""取产品族编码:显式 productFamily,否则用物料编码前缀(CTRL-A → CTRL)。"""
|
||
if not mat:
|
||
return "?"
|
||
fam = str(mat.get("productFamily") or "").strip()
|
||
if fam:
|
||
return fam
|
||
code = str(mat.get("code") or "").strip()
|
||
if "-" in code:
|
||
return code.split("-", 1)[0]
|
||
return code or f"P{mat.get('id', '?')}"
|
||
|
||
|
||
def family_of_product(world: World, product_id: int | None) -> str | None:
|
||
if product_id is None:
|
||
return None
|
||
mat = next((m for m in world.get("materials", []) if m["id"] == product_id), None)
|
||
return product_family(mat) if mat else None
|
||
|
||
|
||
def lookup_setup_minutes(
|
||
world: World,
|
||
from_family: str | None,
|
||
to_family: str | None,
|
||
*,
|
||
default_cross: float | None = None,
|
||
) -> float:
|
||
"""
|
||
查换型分钟。同族 / 无前序 → 0;有矩阵条目用条目;否则跨族用 default_cross。
|
||
default_cross 缺省读 world.changeoverPolicy(IND-02),再回落模块常量。
|
||
"""
|
||
if not to_family:
|
||
return 0.0
|
||
if not from_family or from_family == to_family:
|
||
return 0.0
|
||
ensure_changeover_table(world)
|
||
for row in world.get("changeoverMatrix") or []:
|
||
if row.get("fromFamily") == from_family and row.get("toFamily") == to_family:
|
||
return float(row.get("setupMinutes") or 0)
|
||
# 对称回退:若只录了反向
|
||
for row in world.get("changeoverMatrix") or []:
|
||
if row.get("fromFamily") == to_family and row.get("toFamily") == from_family:
|
||
return float(row.get("setupMinutes") or 0)
|
||
if default_cross is None:
|
||
pol = world.get("changeoverPolicy") or {}
|
||
default_cross = float(pol.get("defaultCrossFamilyMin") or DEFAULT_CROSS_FAMILY_MIN)
|
||
return float(default_cross)
|
||
|
||
def extra_setup_minutes(world: World, from_family: str | None, to_family: str | None) -> float:
|
||
"""RuleEngine 用:相对「无换型」的额外准备分钟。"""
|
||
return lookup_setup_minutes(world, from_family, to_family)
|
||
|
||
|
||
def list_families(world: World) -> list[dict[str, Any]]:
|
||
"""成品/半成品族清单(主数据页下拉)。"""
|
||
bags: dict[str, list[dict[str, Any]]] = {}
|
||
for m in world.get("materials", []):
|
||
if m.get("type") not in ("FINISHED_PRODUCT", "SEMI_FINISHED"):
|
||
continue
|
||
if m.get("status", "ACTIVE") != "ACTIVE":
|
||
continue
|
||
fam = product_family(m)
|
||
bags.setdefault(fam, []).append({"id": m["id"], "code": m["code"], "name": m["name"]})
|
||
return [
|
||
{"code": fam, "productCount": len(prods), "products": prods}
|
||
for fam, prods in sorted(bags.items(), key=lambda x: x[0])
|
||
]
|
||
|
||
|
||
def build_changeover_view(world: World) -> dict[str, Any]:
|
||
"""P0 投影:矩阵行 + 族清单 + 缺省跨族分钟。"""
|
||
ensure_changeover_table(world)
|
||
families = list_families(world)
|
||
fam_codes = [f["code"] for f in families]
|
||
rows = []
|
||
for r in world.get("changeoverMatrix") or []:
|
||
rows.append({
|
||
"id": r.get("id"),
|
||
"fromFamily": r.get("fromFamily"),
|
||
"toFamily": r.get("toFamily"),
|
||
"setupMinutes": float(r.get("setupMinutes") or 0),
|
||
"note": r.get("note") or "",
|
||
})
|
||
rows.sort(key=lambda x: (x["fromFamily"] or "", x["toFamily"] or ""))
|
||
# 完整方阵:有录入用录入,同族 0,缺省跨族 default
|
||
grid = []
|
||
for a in fam_codes:
|
||
for b in fam_codes:
|
||
mins = lookup_setup_minutes(world, a, b)
|
||
explicit = next(
|
||
(r for r in rows if r["fromFamily"] == a and r["toFamily"] == b), None
|
||
)
|
||
grid.append({
|
||
"fromFamily": a, "toFamily": b, "setupMinutes": mins,
|
||
"explicit": explicit is not None or a == b,
|
||
"id": explicit["id"] if explicit else None,
|
||
})
|
||
return {
|
||
"defaultCrossFamilyMin": float((world.get("changeoverPolicy") or {}).get("defaultCrossFamilyMin")
|
||
or DEFAULT_CROSS_FAMILY_MIN),
|
||
"families": families,
|
||
"rows": rows,
|
||
"grid": grid,
|
||
"hint": "同族换型 0 分;跨族查矩阵,缺省见 changeoverPolicy(SOP 可编译覆盖)。排产首道工序叠加额外换型(C10)。",
|
||
"policy": world.get("changeoverPolicy") or {},
|
||
}
|
||
|
||
|
||
def normalize_changeover_payload(world: World, payload: dict[str, Any]) -> dict[str, Any]:
|
||
"""upsert / delete 归一化。"""
|
||
ensure_changeover_table(world)
|
||
if payload.get("delete") or str(payload.get("op") or "").lower() == "delete":
|
||
rid = int(payload.get("id") or 0)
|
||
row = next((r for r in world["changeoverMatrix"] if r.get("id") == rid), None)
|
||
if row is None:
|
||
raise ValueError(f"换型矩阵行不存在:{rid}")
|
||
return {
|
||
"op": "delete", "id": rid,
|
||
"fromFamily": row.get("fromFamily"), "toFamily": row.get("toFamily"),
|
||
}
|
||
|
||
frm = str(payload.get("fromFamily") or "").strip()
|
||
to = str(payload.get("toFamily") or "").strip()
|
||
if not frm or not to:
|
||
raise ValueError("必须提供 fromFamily 与 toFamily")
|
||
if frm == to:
|
||
raise ValueError("同族换型固定为 0,无需录入对角线")
|
||
mins = float(payload.get("setupMinutes") if payload.get("setupMinutes") is not None else 30)
|
||
if mins < 0:
|
||
raise ValueError("换型分钟不能为负")
|
||
note = str(payload.get("note") or "").strip()
|
||
existing = next(
|
||
(r for r in world["changeoverMatrix"]
|
||
if r.get("fromFamily") == frm and r.get("toFamily") == to),
|
||
None,
|
||
)
|
||
out: dict[str, Any] = {
|
||
"op": "upsert",
|
||
"fromFamily": frm, "toFamily": to,
|
||
"setupMinutes": mins, "note": note,
|
||
}
|
||
if payload.get("id"):
|
||
out["id"] = int(payload["id"])
|
||
elif existing:
|
||
out["id"] = existing["id"]
|
||
return out
|
||
|
||
|
||
def apply_changeover_action(world: World, next_id, payload: dict[str, Any]) -> dict[str, Any]:
|
||
"""写入换型矩阵(P2)。"""
|
||
p = normalize_changeover_payload(world, payload)
|
||
ensure_changeover_table(world)
|
||
if p["op"] == "delete":
|
||
world["changeoverMatrix"] = [r for r in world["changeoverMatrix"] if r.get("id") != p["id"]]
|
||
return {"kind": "CHANGEOVER", "op": "delete", "id": p["id"],
|
||
"fromFamily": p["fromFamily"], "toFamily": p["toFamily"]}
|
||
if p.get("id"):
|
||
row = next((r for r in world["changeoverMatrix"] if r.get("id") == p["id"]), None)
|
||
if row is None:
|
||
# id 指定但找不到 → 按族键更新或新建
|
||
row = next(
|
||
(r for r in world["changeoverMatrix"]
|
||
if r.get("fromFamily") == p["fromFamily"] and r.get("toFamily") == p["toFamily"]),
|
||
None,
|
||
)
|
||
if row:
|
||
row.update({
|
||
"fromFamily": p["fromFamily"], "toFamily": p["toFamily"],
|
||
"setupMinutes": p["setupMinutes"], "note": p["note"],
|
||
})
|
||
return {"kind": "CHANGEOVER", "op": "update", "id": row["id"],
|
||
"fromFamily": p["fromFamily"], "toFamily": p["toFamily"],
|
||
"setupMinutes": p["setupMinutes"]}
|
||
# 新建或按族键覆盖
|
||
existing = next(
|
||
(r for r in world["changeoverMatrix"]
|
||
if r.get("fromFamily") == p["fromFamily"] and r.get("toFamily") == p["toFamily"]),
|
||
None,
|
||
)
|
||
if existing:
|
||
existing.update({"setupMinutes": p["setupMinutes"], "note": p["note"]})
|
||
return {"kind": "CHANGEOVER", "op": "update", "id": existing["id"],
|
||
"fromFamily": p["fromFamily"], "toFamily": p["toFamily"],
|
||
"setupMinutes": p["setupMinutes"]}
|
||
rid = next_id("changeoverMatrix")
|
||
world["changeoverMatrix"].append({
|
||
"id": rid, "fromFamily": p["fromFamily"], "toFamily": p["toFamily"],
|
||
"setupMinutes": p["setupMinutes"], "note": p["note"],
|
||
})
|
||
return {"kind": "CHANGEOVER", "op": "create", "id": rid,
|
||
"fromFamily": p["fromFamily"], "toFamily": p["toFamily"],
|
||
"setupMinutes": p["setupMinutes"]}
|
||
|
||
|
||
def sort_entries_changeover_min(
|
||
world: World,
|
||
entries: list[dict[str, Any]],
|
||
*,
|
||
level_weight_fn,
|
||
) -> list[dict[str, Any]]:
|
||
"""
|
||
SC-07:换型最小化排序(贪心最近邻)。
|
||
种子取交期最早(预测垫后、插单优先);其后每次选相对上一产品族换型分钟最小的项,
|
||
平手再比交期/优先级。非全局最优,毫秒级可解释。
|
||
"""
|
||
if len(entries) <= 1:
|
||
return list(entries)
|
||
|
||
def _fam(entry: dict[str, Any]) -> str:
|
||
pid = entry["item"]["productId"]
|
||
mat = next((m for m in world.get("materials", []) if m["id"] == pid), None)
|
||
return product_family(mat)
|
||
|
||
def _seed_key(entry: dict[str, Any]):
|
||
so = entry["so"]
|
||
forecast = 1 if so.get("isForecast") else 0
|
||
rush = 0 if so.get("isRush") else 1
|
||
lw = -level_weight_fn(world, so.get("customerLevel"))
|
||
return (forecast, rush, so["deliveryDate"], lw, so["priority"], so.get("orderNo") or "")
|
||
|
||
remaining = sorted(entries, key=_seed_key)
|
||
ordered: list[dict[str, Any]] = [remaining.pop(0)]
|
||
last_fam = _fam(ordered[0])
|
||
|
||
while remaining:
|
||
best_i = 0
|
||
best_score: tuple | None = None
|
||
for i, entry in enumerate(remaining):
|
||
so = entry["so"]
|
||
cost = extra_setup_minutes(world, last_fam, _fam(entry))
|
||
score = (cost, so["deliveryDate"], so["priority"], so.get("orderNo") or "")
|
||
if best_score is None or score < best_score:
|
||
best_score = score
|
||
best_i = i
|
||
nxt = remaining.pop(best_i)
|
||
ordered.append(nxt)
|
||
last_fam = _fam(nxt)
|
||
return ordered
|