aps-agent/server/aps_domain/changeover.py

259 lines
10 KiB
Python
Raw Normal View History

# ============================================================
# 换型矩阵主数据(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