aps-agent/server/aps_domain/changeover.py

259 lines
10 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-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