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
|