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