# ============================================================ # 约束配置中心(moduleId: domain-constraints, 可重生 ✅) # SC-04:ConstraintDef 注册表 + ConstraintProfile;硬约束拦发布、软约束报风险 # ============================================================ from __future__ import annotations from copy import deepcopy from typing import Any World = dict[str, Any] # 冲突类型 → 约束 ID(发布门禁与启停映射) CONFLICT_TO_CONSTRAINT: dict[str, str] = { "NO_LINE": "C5_capability", "NO_WORKSTATION": "C5_capability", "NO_CAPABILITY": "C5_capability", "NO_ROUTING": "C5_capability", "NO_MOLD": "C12_tooling", "NO_TEAM": "C12_team", "MOLD_LIFE": "C12_tooling", "MATERIAL_SHORTAGE": "C6_material_kit", "EQUIPMENT": "C4_maintenance", "CAPACITY": "C7_capacity", "DELAY": "C8_due_date", "WINDOW_DEFERRED": "C11_freeze", "WINDOW_TRUNCATED": "C11_freeze", "SOP_CHANGEOVER_CURFEW": "C13_sop", } # 默认约束目录(plan §9.2a 子集;configurable=False 的项不可在配置中心关闭) DEFAULT_CONSTRAINT_DEFS: list[dict[str, Any]] = [ { "id": "C1_precedence", "code": "C1", "name": "工艺先后序", "group": "process", "kind": "hard", "enabled": True, "configurable": False, "description": "前序工序完成后才能开下一工序", "weight": 1.0, }, { "id": "C2_no_overlap", "code": "C2", "name": "工位/设备独占", "group": "capacity", "kind": "hard", "enabled": True, "configurable": False, "description": "同一工位同一时刻不可重叠", "weight": 1.0, }, { "id": "C3_calendar", "code": "C3", "name": "班次日历", "group": "calendar", "kind": "hard", "enabled": True, "configurable": False, "description": "工单只能落在可用班次内", "weight": 1.0, }, { "id": "C4_maintenance", "code": "C4", "name": "设备维保窗口", "group": "equipment", "kind": "hard", "enabled": True, "configurable": True, "description": "维保时段不可排产;关闭后不报 EQUIPMENT 冲突", "weight": 1.0, }, { "id": "C5_capability", "code": "C5", "name": "机器资格/选线", "group": "equipment", "kind": "hard", "enabled": True, "configurable": False, "description": "工序只能落在有资格的产线/工位/能力池", "weight": 1.0, }, { "id": "C6_material_kit", "code": "C6", "name": "物料齐套", "group": "material", "kind": "soft", "enabled": True, "configurable": True, "description": "缺料:硬=阻断发布;软=带风险排入;关闭=不检查", "weight": 0.8, "params": {"strategy": "risk"}, }, { "id": "C7_capacity", "code": "C7", "name": "产线日产能上限", "group": "capacity", "kind": "hard", "enabled": True, "configurable": True, "description": "单日占用超出可用分钟则报 CAPACITY", "weight": 1.0, }, { "id": "C8_due_date", "code": "C8", "name": "订单交期", "group": "order", "kind": "soft", "enabled": True, "configurable": True, "description": "延期进冲突与 KPI,默认不拦发布", "weight": 0.6, }, { "id": "C11_freeze", "code": "C11", "name": "滚动/冻结窗口", "group": "order", "kind": "hard", "enabled": True, "configurable": True, "description": "窗外工单延期/截断(柔性滚动窗)", "weight": 1.0, }, { "id": "C12_team", "code": "C12", "name": "班组人力并发", "group": "personnel", "kind": "hard", "enabled": True, "configurable": True, "description": "柔性轨班组同时占用上限(SC-11)", "weight": 1.0, }, { "id": "C12_tooling", "code": "C12b", "name": "工装/模具", "group": "tooling", "kind": "hard", "enabled": True, "configurable": True, "description": "模具适配与寿命锁定(SC-10)", "weight": 1.0, }, { "id": "C10_changeover", "code": "C10", "name": "顺序相关换型", "group": "process", "kind": "soft", "enabled": True, "configurable": True, "description": "相邻工单产品族切换时叠加换型矩阵分钟(MD-06)", "weight": 0.7, }, { "id": "C13_sop", "code": "C13", "name": "SOP 规则包", "group": "process", "kind": "soft", "enabled": False, "configurable": True, "description": "行业 SOP 编译生效标记(IND-02);含换型宵禁等派生规则", "weight": 0.5, "params": {}, }, ] GROUP_LABELS = { "process": "工艺", "capacity": "产能", "calendar": "日历", "equipment": "设备", "material": "物料", "order": "订单", "personnel": "人员", "tooling": "工装", } def _index_defaults() -> dict[str, dict[str, Any]]: return {d["id"]: deepcopy(d) for d in DEFAULT_CONSTRAINT_DEFS} def get_constraint_profile(world: World) -> dict[str, Any]: """合并默认目录与世界覆盖后的约束配置剖面。""" defaults = _index_defaults() stored = (world.get("constraintProfile") or {}).get("constraints") or {} items: list[dict[str, Any]] = [] for cid, base in defaults.items(): overlay = stored.get(cid) or {} item = deepcopy(base) if item.get("configurable"): if "enabled" in overlay: item["enabled"] = bool(overlay["enabled"]) if overlay.get("kind") in ("hard", "soft"): item["kind"] = overlay["kind"] if "weight" in overlay: try: item["weight"] = float(overlay["weight"]) except (TypeError, ValueError): pass if isinstance(overlay.get("params"), dict): item["params"] = {**(item.get("params") or {}), **overlay["params"]} items.append(item) return { "profileId": (world.get("constraintProfile") or {}).get("profileId") or "default", "name": (world.get("constraintProfile") or {}).get("name") or "默认约束剖面", "constraints": items, "groups": [ {"id": gid, "label": label, "items": [c["id"] for c in items if c["group"] == gid]} for gid, label in GROUP_LABELS.items() if any(c["group"] == gid for c in items) ], } def get_constraint(world: World, constraint_id: str) -> dict[str, Any] | None: for c in get_constraint_profile(world)["constraints"]: if c["id"] == constraint_id: return c return None def is_enabled(world: World, constraint_id: str) -> bool: c = get_constraint(world, constraint_id) return bool(c and c.get("enabled", True)) def constraint_kind(world: World, constraint_id: str) -> str: c = get_constraint(world, constraint_id) return str((c or {}).get("kind") or "hard") def engine_constraint_flags(world: World) -> dict[str, bool]: """映射到 EngineParams.constraints 布尔开关(兼容旧字段)。""" return { "materialKit": is_enabled(world, "C6_material_kit"), "equipment": is_enabled(world, "C4_maintenance"), "personnel": is_enabled(world, "C12_team"), "changeover": is_enabled(world, "C10_changeover"), "capacity": is_enabled(world, "C7_capacity"), "dueDate": is_enabled(world, "C8_due_date"), "tooling": is_enabled(world, "C12_tooling"), "freeze": is_enabled(world, "C11_freeze"), } def material_shortage_severity(world: World) -> str | None: """None=不检查;否则返回冲突 severity。""" if not is_enabled(world, "C6_material_kit"): return None return "MAJOR" if constraint_kind(world, "C6_material_kit") == "hard" else "MINOR" def profile_snapshot(world: World) -> dict[str, Any]: """写入排产版本的约束快照(只影响审计/解释,不回写配置)。""" p = get_constraint_profile(world) return { "profileId": p["profileId"], "constraints": { c["id"]: {"enabled": c["enabled"], "kind": c["kind"], "weight": c.get("weight", 1.0)} for c in p["constraints"] }, } def hard_blocking_conflicts(world: World, version_id: int | None = None, track: str = "fixed") -> list[dict[str, Any]]: """未解决且映射到「启用的硬约束」的冲突(发布门禁用)。""" key = "flexConflicts" if track == "flex" else "conflicts" rows = [] for c in world.get(key) or []: if c.get("isResolved") or c.get("status") == "RESOLVED": continue if version_id is not None and c.get("versionId") != version_id: continue cid = CONFLICT_TO_CONSTRAINT.get(str(c.get("conflictType") or "")) if not cid: continue if not is_enabled(world, cid): continue if constraint_kind(world, cid) != "hard": continue rows.append({**c, "constraintId": cid}) return rows def normalize_profile_payload(payload: dict[str, Any]) -> dict[str, Any]: """校验 constraint.profile.save 载荷。""" if payload.get("resetDefaults"): return {"resetDefaults": True} raw = payload.get("constraints") if not isinstance(raw, dict) or not raw: raise ValueError("constraints 必须是非空对象(constraintId → {enabled,kind,weight})") defaults = _index_defaults() out: dict[str, dict[str, Any]] = {} for cid, patch in raw.items(): if cid not in defaults: raise ValueError(f"未知约束:{cid}") if not defaults[cid].get("configurable"): raise ValueError(f"{defaults[cid]['name']} 为内建硬约束,不可在配置中心修改") if not isinstance(patch, dict): raise ValueError(f"{cid} 配置必须是对象") entry: dict[str, Any] = {} if "enabled" in patch: entry["enabled"] = bool(patch["enabled"]) if "kind" in patch: if patch["kind"] not in ("hard", "soft"): raise ValueError(f"{cid}.kind 只能是 hard/soft") entry["kind"] = patch["kind"] if "weight" in patch: try: w = float(patch["weight"]) except (TypeError, ValueError) as exc: raise ValueError(f"{cid}.weight 必须是数字") from exc if w < 0 or w > 10: raise ValueError(f"{cid}.weight 须在 0~10") entry["weight"] = w if isinstance(patch.get("params"), dict): entry["params"] = dict(patch["params"]) if not entry: raise ValueError(f"{cid} 未提供可更新字段") out[cid] = entry return {"constraints": out} def confirmation_for_profile_save(world: World, payload: dict[str, Any]) -> tuple[str, list[str]]: norm = normalize_profile_payload(payload) if norm.get("resetDefaults"): return "恢复默认约束剖面", [ "物料齐套恢复为软约束并启用", "维保/产能/班组/模具等可配项恢复默认", "只影响后续新排产与发布门禁,不回写历史版本", ] cur = {c["id"]: c for c in get_constraint_profile(world)["constraints"]} lines: list[str] = [] for cid, patch in norm["constraints"].items(): name = cur[cid]["name"] bits = [] if "enabled" in patch: bits.append(("启用" if patch["enabled"] else "关闭")) if "kind" in patch: bits.append("硬约束" if patch["kind"] == "hard" else "软约束") if "weight" in patch: bits.append(f"权重={patch['weight']}") lines.append(f"{name}({cid}):" + " / ".join(bits)) lines.append("硬约束违反将阻止发布;软约束仅报告风险") lines.append("只影响后续新排产版本") return "更新约束配置", lines def apply_profile_save(world: World, payload: dict[str, Any]) -> dict[str, Any]: norm = normalize_profile_payload(payload) before = deepcopy(get_constraint_profile(world)) if norm.get("resetDefaults"): world["constraintProfile"] = { "profileId": "default", "name": "默认约束剖面", "constraints": {}, } else: profile = world.setdefault("constraintProfile", { "profileId": "default", "name": "默认约束剖面", "constraints": {}, }) stored = profile.setdefault("constraints", {}) for cid, patch in norm["constraints"].items(): prev = stored.get(cid) or {} merged = {**prev, **patch} if isinstance(prev.get("params"), dict) and isinstance(patch.get("params"), dict): merged["params"] = {**prev["params"], **patch["params"]} stored[cid] = merged after = get_constraint_profile(world) return { "kind": "CONSTRAINT_PROFILE", "id": after["profileId"], "name": after["name"], "before": before, "after": after, "reset": bool(norm.get("resetDefaults")), } def default_constraint_profile() -> dict[str, Any]: return {"profileId": "default", "name": "默认约束剖面", "constraints": {}}