# ============================================================ # 约束配置中心(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, "goldenCase": "golden-c1-precedence", }, { "id": "C2_no_overlap", "code": "C2", "name": "工位/设备独占", "group": "capacity", "kind": "hard", "enabled": True, "configurable": False, "description": "同一工位同一时刻不可重叠;硬约束,违反阻止发布,不可关闭", "weight": 1.0, "goldenCase": "golden-c2-no-overlap", }, { "id": "C3_calendar", "code": "C3", "name": "班次日历", "group": "calendar", "kind": "hard", "enabled": True, "configurable": False, "description": "工单只能落在可用班次内;硬约束,违反阻止发布,不可关闭", "weight": 1.0, "goldenCase": "golden-c3-calendar", }, { "id": "C4_maintenance", "code": "C4", "name": "设备维保窗口", "group": "equipment", "kind": "hard", "enabled": True, "configurable": True, "description": "维保时段不可排产;硬约束,违反报 EQUIPMENT 并阻止发布,不可关闭", "weight": 1.0, "goldenCase": "golden-c4-maintenance", }, { "id": "C5_capability", "code": "C5", "name": "机器资格/选线", "group": "equipment", "kind": "hard", "enabled": True, "configurable": False, "description": "工序只能落在有资格的产线/工位/能力池;硬约束,违反阻止发布,不可关闭", "weight": 1.0, "goldenCase": "golden-c5-capability", }, { "id": "C6_material_kit", "code": "C6", "name": "物料齐套", "group": "material", "kind": "soft", "enabled": True, "configurable": True, "kindConfigurable": True, "description": "缺料:硬=阻断发布;软=带风险排入;关闭=不检查(硬/软可配)", "weight": 0.8, "goldenCase": "golden-c6-material-kit", "params": {"strategy": "risk"}, }, { "id": "C7_capacity", "code": "C7", "name": "产线日产能上限", "group": "capacity", "kind": "hard", "enabled": True, "configurable": True, "description": "单日占用超出可用分钟则报 CAPACITY;硬约束,违反阻止发布,不可关闭", "weight": 1.0, "goldenCase": "golden-c7-capacity", }, { "id": "C8_due_date", "code": "C8", "name": "订单交期", "group": "order", "kind": "soft", "enabled": True, "configurable": True, "description": "延期进冲突与 KPI,默认不拦发布", "weight": 0.6, "goldenCase": "golden-c8-due-date", }, { "id": "C9_customer_priority", "code": "C9", "name": "客户优先级/VIP", "group": "order", "kind": "soft", "enabled": True, "configurable": True, "description": "客户等级与订单优先级的排序/延期权重来源(level_weight)", "weight": 0.8, "goldenCase": "golden-c9-customer-priority", }, { "id": "C10_changeover", "code": "C10", "name": "顺序相关换型", "group": "process", "kind": "soft", "enabled": True, "configurable": True, "description": "相邻工单产品族切换时叠加换型矩阵分钟(MD-06)", "weight": 0.7, "goldenCase": "golden-c10-changeover", }, { "id": "C11_freeze", "code": "C11", "name": "滚动/冻结窗口", "group": "order", "kind": "hard", "enabled": True, "configurable": True, "description": "窗外工单延期/截断(柔性滚动窗);硬约束,违反阻止发布,不可关闭", "weight": 1.0, "goldenCase": "golden-c11-freeze", }, { "id": "C12_team", "code": "C12", "name": "班组人力并发", "group": "personnel", "kind": "hard", "enabled": True, "configurable": True, "description": "柔性轨班组同时占用上限(SC-11);硬约束,违反阻止发布,不可关闭", "weight": 1.0, "goldenCase": "golden-c12-team", }, { "id": "C12_tooling", "code": "C12", "name": "工装/模具", "group": "tooling", "kind": "hard", "enabled": True, "configurable": True, "description": "模具适配与寿命锁定(SC-10);硬约束,违反阻止发布,不可关闭", "weight": 1.0, "goldenCase": "golden-c12-tooling", }, { "id": "C13_sop", "code": "C13", "name": "SOP 规则包", "group": "process", "kind": "soft", "enabled": False, "configurable": True, "kindConfigurable": True, "description": "行业 SOP 编译生效标记(IND-02);含换型宵禁等派生规则(硬/软可配)", "weight": 0.5, "goldenCase": "golden-c13-sop", "params": {}, }, ] GROUP_LABELS = { "process": "工艺", "capacity": "产能", "calendar": "日历", "equipment": "设备", "material": "物料", "order": "订单", "personnel": "人员", "tooling": "工装", } # plan.md §9.2a.1 的 13 类规范化类名(矩阵 113 盘点口径;C12 聚合班组/工装两个子资源) CLASS_NAMES: dict[str, str] = { "C1": "工艺先后序", "C2": "工位/设备独占", "C3": "班次日历", "C4": "设备维保窗口", "C5": "机器资格/选线", "C6": "物料齐套", "C7": "产线日产能上限", "C8": "订单交期", "C9": "客户优先级/VIP", "C10": "顺序相关换型", "C11": "滚动/冻结窗口", "C12": "班组/工装等累积资源", "C13": "企业SOP规则", } 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 class ConstraintProfileDenied(ValueError): """约束剖面保存被门禁拒绝(关闭硬约束/降级/软权重归零等)。 message 为中文说明;constraint_id/requested 供审计事件引用。 继承 ValueError:现有调用方(workflow/app 的 except ValueError 路径)无需改动。 """ def __init__(self, message: str, *, constraint_id: str | None = None, requested: dict[str, Any] | None = None) -> None: super().__init__(message) self.constraint_id = constraint_id self.requested = requested def constraint_catalog(world: World | None = None) -> dict[str, Any]: """只读 13 类约束目录(矩阵 113:类别齐全 + 每类硬/软 + 可关闭性 + 黄金用例标识)。 按 plan.md §9.2a.1 的 C1..C13 分类 code 聚合(C12 聚合班组/工装两个子资源); 软约束可关闭(enabled=False),硬约束不可关闭(关闭/降级/软权重归零会被门禁拒绝)。 """ profile = get_constraint_profile(world if world is not None else {}) by_code: dict[str, dict[str, Any]] = {} for c in profile["constraints"]: code = c["code"] cls = by_code.setdefault(code, { "code": code, "name": CLASS_NAMES.get(code, c["name"]), "group": c.get("group"), "kind": "hard", "closable": True, "configurable": bool(c.get("configurable")), "kindConfigurable": bool(c.get("kindConfigurable")), "enabled": True, "goldenCase": c.get("goldenCase") or f"golden-{c['id']}", "items": [], }) cls["items"].append(c["id"]) if c["kind"] != "hard": cls["kind"] = "soft" else: cls["closable"] = False if not c.get("enabled", True): cls["enabled"] = False classes = [by_code[k] for k in sorted(by_code)] return { "catalogVersion": "9.2a.1", "total": len(classes), "classes": classes, "summary": { "hardCount": sum(1 for x in classes if x["kind"] == "hard"), "softCount": sum(1 for x in classes if x["kind"] == "soft"), "closableCount": sum(1 for x in classes if x["closable"]), "enabledCount": sum(1 for x in classes if x["enabled"]), }, } def _audit_next_id_for(world: World): """world 内 append-only 审计 ID 分配器(与 WorldStore.next_id('audit') 一致:max+1)。""" def _next_id(kind: str) -> int: ids = [e.get("id") for e in world.get("auditEvents", []) if isinstance(e.get("id"), int)] return (max(ids, default=0)) + 1 return _next_id def _write_profile_denied_audit(world: World, payload: dict[str, Any], exc: ConstraintProfileDenied) -> dict[str, Any]: """拒绝「关闭硬约束」类请求的审计事件(action=constraint.profile.save, result=DENIED)。""" from server.agent_core.audit import write_audit profile = world.get("constraintProfile") or {} return write_audit( world, _audit_next_id_for(world), actor="agent", category="GATE", action="constraint.profile.save", target={"type": "CONSTRAINT_PROFILE", "id": profile.get("profileId") or "default"}, power="P2", rationale={ "reason": str(exc), "constraintId": exc.constraint_id, "requested": payload, "result": "DENIED", }, result="DENIED", ) def _gate_hard_constraint_close(world: World, normalized: dict[str, Any]) -> None: """门禁:任何使硬约束失效的保存请求一律拒绝(中文说明)。 覆盖三种「关闭」路径,均先写 DENIED 审计再抛 ConstraintProfileDenied: 1) enabled=False 落在硬约束上(含同包 kind=hard + enabled=False) 2) 硬约束降级为软约束(规避发布门禁);仅 kindConfigurable 类(C6/C13)可软硬互转 3) 对硬约束设置权重(软权重无法关闭硬约束) """ if normalized.get("resetDefaults"): return cur = {c["id"]: c for c in get_constraint_profile(world)["constraints"]} defaults = _index_defaults() for cid, patch in normalized["constraints"].items(): item = cur.get(cid) or defaults.get(cid) or {} name = item.get("name") or cid cur_kind = item.get("kind") or "hard" target_kind = patch.get("kind", cur_kind) kind_configurable = bool((defaults.get(cid) or {}).get("kindConfigurable")) if patch.get("enabled") is False and target_kind == "hard": raise ConstraintProfileDenied( f"{name}({cid})是硬约束,不可关闭:硬约束违反将阻止发布," "关闭请求被门禁拒绝并已记录审计", constraint_id=cid, requested=patch) if patch.get("kind") == "soft" and cur_kind == "hard" and not kind_configurable: raise ConstraintProfileDenied( f"{name}({cid})是硬约束,不可降级为软约束以规避发布门禁;" "关闭请求被门禁拒绝并已记录审计", constraint_id=cid, requested=patch) if "weight" in patch and target_kind == "hard": raise ConstraintProfileDenied( f"{name}({cid})是硬约束,权重固定为 1;软权重无法关闭硬约束," "请求被门禁拒绝并已记录审计", constraint_id=cid, requested=patch) def _normalize_or_denied(world: World, payload: dict[str, Any]) -> dict[str, Any]: """校验约束剖面载荷;关闭硬约束被门禁拒绝时写审计并抛 ConstraintProfileDenied。""" try: return normalize_profile_payload(payload, world=world) except ConstraintProfileDenied as exc: _write_profile_denied_audit(world, payload, exc) raise def normalize_profile_payload(payload: dict[str, Any], world: World | None = None) -> 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"): if patch.get("enabled") is False: raise ConstraintProfileDenied( f"{defaults[cid]['name']}({cid})为内建硬约束,不可在配置中心关闭:" "硬约束违反将阻止发布,关闭请求被门禁拒绝并已记录审计", constraint_id=cid, requested=patch) 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 if world is not None: _gate_hard_constraint_close(world, {"constraints": out}) return {"constraints": out} def confirmation_for_profile_save(world: World, payload: dict[str, Any]) -> tuple[str, list[str]]: norm = _normalize_or_denied(world, 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_or_denied(world, 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": {}}