aps-agent/server/aps_domain/constraints.py

485 lines
21 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-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": {}}