aps-agent/server/aps_domain/constraints.py

317 lines
13 KiB
Python
Raw Normal View History

# ============================================================
# 约束配置中心(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": {}}