2026-08-11 00:54:05 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
# 冲突归因与最小冲突集(moduleId: domain-attribution, 可重生 ✅)
|
|
|
|
|
|
# M5 第 89 项:紧约束/影子价、IIS 与冲突归因
|
|
|
|
|
|
# ------------------------------------------------------------
|
|
|
|
|
|
# 解决的问题:
|
|
|
|
|
|
# 1) 对不可行排产版本产出「最小冲突集(IIS,不可约不可行子集)」:
|
|
|
|
|
|
# 冲突组内的每条约束都是必要的——删除其中任一约束后,该冲突组
|
|
|
|
|
|
# 恢复可行(其余冲突组不受影响时整体可行)。
|
|
|
|
|
|
# 2) 从求解日志(rule/CP 引擎写入 world["conflicts"] 的约束违反记录)
|
|
|
|
|
|
# 提取可复核结论:哪个约束、哪些实体(订单/资源/物料)、违反度,
|
|
|
|
|
|
# 每条结论携带 evidence(冲突 ID + 版本 + 日志描述),可对照复核。
|
|
|
|
|
|
# 3) 生成现场计划员可读的中文归因文案(避免纯数学术语)。
|
|
|
|
|
|
# ------------------------------------------------------------
|
|
|
|
|
|
# 设计说明(与现有模块的关系):
|
|
|
|
|
|
# - 复用 constraints.hard_blocking_conflicts 的检测结果作为输入证据,
|
|
|
|
|
|
# 不修改任何现有接口;本模块为只读消费者(P0)。
|
|
|
|
|
|
# - 约束违反记录默认只归属引擎映射的单一约束(CONFLICT_TO_CONSTRAINT);
|
|
|
|
|
|
# 当日志/数据中存在联合成因证据时(例如「订单延误的根源是产能不足」),
|
|
|
|
|
|
# 自动扩充解除约束集合 relief(放松其中任一约束即可消除该违反)。
|
2026-08-26 00:25:46 +08:00
|
|
|
|
# - 原「松弛收益」仍是求解日志代理量;Round 79 另做 GLOP 诊断线性松弛
|
|
|
|
|
|
# 重解,有限差分为主、cap dual 为数值校验。该结果不是原 CP-SAT 对偶,
|
|
|
|
|
|
# 也不可跨约束相加。
|
2026-08-11 00:54:05 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from collections.abc import Iterable
|
|
|
|
|
|
from itertools import combinations
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
from server.aps_domain.constraints import (
|
|
|
|
|
|
CONFLICT_TO_CONSTRAINT,
|
|
|
|
|
|
get_constraint,
|
|
|
|
|
|
get_constraint_profile,
|
|
|
|
|
|
hard_blocking_conflicts,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
World = dict[str, Any]
|
|
|
|
|
|
|
|
|
|
|
|
# 通俗解释:约束 ID → 现场计划员用语(归因文案使用,避免纯数学术语)
|
|
|
|
|
|
PLAIN_LANGUAGE: dict[str, str] = {
|
|
|
|
|
|
"C1_precedence": "工艺先后序(上一道工序干完才能干下一道)",
|
|
|
|
|
|
"C2_no_overlap": "工位/设备独占(同一台设备同一时刻只能干一个活)",
|
|
|
|
|
|
"C3_calendar": "班次日历(工单只能落在可用班次内)",
|
|
|
|
|
|
"C4_maintenance": "设备维保窗口(维保时段不能排产)",
|
|
|
|
|
|
"C5_capability": "机器资格/选线(只能在有资格的产线上干)",
|
|
|
|
|
|
"C6_material_kit": "物料齐套(缺料不能开工)",
|
|
|
|
|
|
"C7_capacity": "产线日产能上限(一天能干的活有上限)",
|
|
|
|
|
|
"C8_due_date": "订单交期(答应客户的交付时间)",
|
|
|
|
|
|
"C11_freeze": "滚动/冻结窗口(已冻结的排产不能动)",
|
|
|
|
|
|
"C12_team": "班组人力并发(同班组同时干活人数有上限)",
|
|
|
|
|
|
"C12_tooling": "工装/模具(模具适配与寿命限制)",
|
|
|
|
|
|
"C10_changeover": "顺序相关换型(换产品要额外时间)",
|
|
|
|
|
|
"C13_sop": "SOP 规则包(行业规则限制)",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
SEVERITY_LABEL: dict[str, str] = {"CRITICAL": "致命", "MAJOR": "严重", "MINOR": "一般"}
|
|
|
|
|
|
|
|
|
|
|
|
# 一条违反记录最多接受的解除约束数量(防止组合爆炸,约束级 IIS 的保守上限)
|
|
|
|
|
|
_MAX_RELIEF = 4
|
|
|
|
|
|
# 约束级组合枚举上限(IIS 检测只枚举出现违反的约束,受此保护)
|
|
|
|
|
|
_MAX_IIS_CONSTRAINTS = 10
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 实体解析(订单 / 资源 / 物料) ----------------
|
|
|
|
|
|
def _raw(record: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
|
"""unwrap enriched violation dict back to the raw conflict record."""
|
|
|
|
|
|
inner = record.get("_record")
|
|
|
|
|
|
return inner if isinstance(inner, dict) else record
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_order_nos(world: World, record: dict[str, Any]) -> list[str]:
|
|
|
|
|
|
"""从冲突记录解析涉及的订单号(可复核字段)。"""
|
|
|
|
|
|
out: list[str] = []
|
|
|
|
|
|
raw = record.get("orderNo")
|
|
|
|
|
|
if raw:
|
|
|
|
|
|
out.append(str(raw))
|
|
|
|
|
|
wo_id = record.get("workOrderId")
|
|
|
|
|
|
if wo_id is not None:
|
|
|
|
|
|
for pool in ("workOrders", "flexWorkOrders"):
|
|
|
|
|
|
for wo in world.get(pool) or []:
|
|
|
|
|
|
if wo.get("id") == wo_id:
|
|
|
|
|
|
no = wo.get("orderNo") or wo.get("productionOrderNo")
|
|
|
|
|
|
if no:
|
|
|
|
|
|
out.append(str(no))
|
|
|
|
|
|
po_id = wo.get("productionOrderId")
|
|
|
|
|
|
if po_id is not None:
|
|
|
|
|
|
for po in world.get("productionOrders") or []:
|
|
|
|
|
|
if po.get("id") == po_id and po.get("orderNo"):
|
|
|
|
|
|
out.append(str(po["orderNo"]))
|
|
|
|
|
|
po_id = record.get("productionOrderId")
|
|
|
|
|
|
if po_id is not None:
|
|
|
|
|
|
for po in world.get("productionOrders") or []:
|
|
|
|
|
|
if po.get("id") == po_id and po.get("orderNo"):
|
|
|
|
|
|
out.append(str(po["orderNo"]))
|
|
|
|
|
|
seen: list[str] = []
|
|
|
|
|
|
for no in out:
|
|
|
|
|
|
if no not in seen:
|
|
|
|
|
|
seen.append(no)
|
|
|
|
|
|
return seen
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _order_line_name(world: World, record: dict[str, Any]) -> str | None:
|
|
|
|
|
|
"""延迟/维保冲突定位订单所在产线名(用于产能联合成因证据)。"""
|
|
|
|
|
|
wo_id = record.get("workOrderId")
|
|
|
|
|
|
if wo_id is not None:
|
|
|
|
|
|
for wo in world.get("workOrders") or []:
|
|
|
|
|
|
if wo.get("id") == wo_id:
|
|
|
|
|
|
return wo.get("lineName") or _line_name_by_id(world, wo.get("lineId"))
|
|
|
|
|
|
order_nos = _resolve_order_nos(world, record)
|
|
|
|
|
|
if not order_nos:
|
|
|
|
|
|
return None
|
|
|
|
|
|
target = order_nos[0]
|
|
|
|
|
|
for wo in world.get("workOrders") or []:
|
|
|
|
|
|
no = wo.get("orderNo") or wo.get("productionOrderNo")
|
|
|
|
|
|
if no == target or (no and no.startswith(target + "-")):
|
|
|
|
|
|
return wo.get("lineName") or _line_name_by_id(world, wo.get("lineId"))
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _line_name_by_id(world: World, line_id: Any) -> str | None:
|
|
|
|
|
|
for line in world.get("lines") or []:
|
|
|
|
|
|
if line.get("id") == line_id:
|
|
|
|
|
|
return line.get("name")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fmt_date_short(iso: str | None) -> str:
|
|
|
|
|
|
"""2026-08-03 -> 8/3(文案用短日期)。"""
|
|
|
|
|
|
if not iso:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
parts = str(iso).split(" ")[0].split("-")
|
|
|
|
|
|
if len(parts) == 3:
|
|
|
|
|
|
return f"{int(parts[1])}/{int(parts[2])}"
|
|
|
|
|
|
return str(iso)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 违反度(violation degree,可复核量) ----------------
|
|
|
|
|
|
def violation_degree(world: World, record: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
|
"""计算违反度:解析日志描述中的量化字段,缺省为计数 1。"""
|
|
|
|
|
|
ctype = record.get("conflictType") or ""
|
|
|
|
|
|
desc = str(record.get("description") or "")
|
|
|
|
|
|
if ctype == "CAPACITY":
|
|
|
|
|
|
degree = _parse_capacity_degree(desc)
|
|
|
|
|
|
if degree:
|
|
|
|
|
|
return degree
|
|
|
|
|
|
if ctype == "DELAY":
|
|
|
|
|
|
degree = _parse_delay_degree(desc)
|
|
|
|
|
|
if degree:
|
|
|
|
|
|
return degree
|
|
|
|
|
|
if ctype == "EQUIPMENT":
|
|
|
|
|
|
overlap = _equipment_overlap_minutes(world, record)
|
|
|
|
|
|
if overlap:
|
|
|
|
|
|
return overlap
|
|
|
|
|
|
return {"kind": "count", "value": 1.0, "unit": "次", "detail": "违反 1 次"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_capacity_degree(desc: str) -> dict[str, Any] | None:
|
|
|
|
|
|
"""负荷 X 分钟,超出可用 Y 分钟 -> 超载分钟。"""
|
|
|
|
|
|
import re
|
|
|
|
|
|
m = re.search(r"负荷\s*([\d.]+)\s*分钟[,,]\s*超出可用\s*([\d.]+)\s*分钟", desc)
|
|
|
|
|
|
if not m:
|
|
|
|
|
|
return None
|
|
|
|
|
|
load, avail = float(m.group(1)), float(m.group(2))
|
|
|
|
|
|
overload = max(0.0, load - avail)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"kind": "overload_minutes", "value": overload, "unit": "分钟",
|
|
|
|
|
|
"detail": f"超载 {overload:.0f} 分钟(负荷 {load:.0f},可用 {avail:.0f})",
|
|
|
|
|
|
"loadMinutes": round(load, 1), "availableMinutes": round(avail, 1),
|
|
|
|
|
|
"overloadMinutes": round(overload, 1),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_delay_degree(desc: str) -> dict[str, Any] | None:
|
|
|
|
|
|
import re
|
|
|
|
|
|
m = re.search(r"晚于交期\s*([\d.]+)\s*小时", desc)
|
|
|
|
|
|
if not m:
|
|
|
|
|
|
return None
|
|
|
|
|
|
hours = float(m.group(1))
|
|
|
|
|
|
return {
|
|
|
|
|
|
"kind": "hours_late", "value": hours, "unit": "小时",
|
|
|
|
|
|
"detail": f"延误 {hours:.1f} 小时", "hoursLate": round(hours, 1),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _equipment_overlap_minutes(world: World, record: dict[str, Any]) -> dict[str, Any] | None:
|
|
|
|
|
|
"""维保冲突:用工单时段与维保时段计算重叠分钟(违反度)。"""
|
|
|
|
|
|
from server.timeutil import parse_dt
|
|
|
|
|
|
wo_id = record.get("workOrderId")
|
|
|
|
|
|
if wo_id is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
wo = next((x for x in world.get("workOrders") or [] if x.get("id") == wo_id), None)
|
|
|
|
|
|
if not wo:
|
|
|
|
|
|
return None
|
|
|
|
|
|
eq = next((e for e in world.get("equipment") or []
|
|
|
|
|
|
if e.get("workstationId") == wo.get("workstationId")), None)
|
|
|
|
|
|
if not eq:
|
|
|
|
|
|
return None
|
|
|
|
|
|
ws_t, we_t = parse_dt(wo["plannedStartTime"]), parse_dt(wo["plannedEndTime"])
|
|
|
|
|
|
for mnt in world.get("maintenance") or []:
|
|
|
|
|
|
if mnt.get("equipmentId") != eq.get("id"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if mnt.get("status", "PLANNED") not in ("PLANNED", "IN_PROGRESS"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
ms, me = parse_dt(mnt["plannedStart"]), parse_dt(mnt["plannedEnd"])
|
|
|
|
|
|
overlap = min(we_t, me) - max(ws_t, ms)
|
|
|
|
|
|
if overlap.total_seconds() > 0:
|
|
|
|
|
|
minutes = overlap.total_seconds() / 60
|
|
|
|
|
|
return {
|
|
|
|
|
|
"kind": "overlap_minutes", "value": minutes, "unit": "分钟",
|
|
|
|
|
|
"detail": f"与维保重叠 {minutes:.0f} 分钟",
|
|
|
|
|
|
"overlapMinutes": round(minutes, 1),
|
|
|
|
|
|
"maintenanceStart": mnt["plannedStart"], "maintenanceEnd": mnt["plannedEnd"],
|
|
|
|
|
|
"maintenanceDescription": mnt.get("description") or "",
|
|
|
|
|
|
}
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 硬违反记录(求解日志证据层) ----------------
|
|
|
|
|
|
def hard_violations(world: World, version_id: int | None = None,
|
|
|
|
|
|
track: str = "fixed") -> list[dict[str, Any]]:
|
|
|
|
|
|
"""当前版本的硬约束违反记录(复用 hard_blocking_conflicts 的检测结果)。
|
|
|
|
|
|
|
|
|
|
|
|
每条记录字段可直接展示,并可对照 world["conflicts"] / 求解日志复核:
|
|
|
|
|
|
conflictId / versionId / constraintId / conflictType / severity /
|
|
|
|
|
|
orderNos / resourceType / resourceName / description / degree / evidence。
|
|
|
|
|
|
"""
|
|
|
|
|
|
rows = hard_blocking_conflicts(world, version_id=version_id, track=track)
|
|
|
|
|
|
out: list[dict[str, Any]] = []
|
|
|
|
|
|
for r in rows:
|
|
|
|
|
|
cid = r["constraintId"]
|
|
|
|
|
|
cons = get_constraint(world, cid)
|
|
|
|
|
|
order_nos = _resolve_order_nos(world, r)
|
|
|
|
|
|
out.append({
|
|
|
|
|
|
"conflictId": r.get("id"),
|
|
|
|
|
|
"versionId": r.get("versionId"),
|
|
|
|
|
|
"constraintId": cid,
|
|
|
|
|
|
"constraintName": (cons or {}).get("name") or cid,
|
|
|
|
|
|
"constraintKind": (cons or {}).get("kind") or "hard",
|
|
|
|
|
|
"conflictType": r.get("conflictType"),
|
|
|
|
|
|
"severity": r.get("severity"),
|
|
|
|
|
|
"severityLabel": SEVERITY_LABEL.get(str(r.get("severity") or ""), str(r.get("severity") or "")),
|
|
|
|
|
|
"orderNos": order_nos,
|
|
|
|
|
|
"resourceType": r.get("resourceType"),
|
|
|
|
|
|
"resourceName": r.get("resourceName"),
|
|
|
|
|
|
"conflictTimeStart": r.get("conflictTimeStart"),
|
|
|
|
|
|
"description": r.get("description"),
|
|
|
|
|
|
"suggestedSolution": r.get("suggestedSolution"),
|
|
|
|
|
|
"degree": violation_degree(world, r),
|
|
|
|
|
|
"evidence": {
|
|
|
|
|
|
"conflictId": r.get("id"),
|
|
|
|
|
|
"versionId": r.get("versionId"),
|
|
|
|
|
|
"description": r.get("description"),
|
|
|
|
|
|
},
|
|
|
|
|
|
"reliefConstraintIds": r.get("reliefConstraintIds"),
|
|
|
|
|
|
"_record": r,
|
|
|
|
|
|
})
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 解除约束集合(relief)与联合成因 ----------------
|
|
|
|
|
|
def relief_constraints(world: World, record: dict[str, Any],
|
2026-08-26 00:25:46 +08:00
|
|
|
|
version_id: int | None = None,
|
|
|
|
|
|
track: str = "fixed") -> list[str]:
|
2026-08-11 00:54:05 +08:00
|
|
|
|
"""一条违反记录的「解除约束集合」:放松其中任一约束即可消除该违反。
|
|
|
|
|
|
|
|
|
|
|
|
优先使用记录自带 reliefConstraintIds(求解器/上游显式声明的成因,
|
|
|
|
|
|
向后兼容的扩展字段);缺省时 = 引擎映射约束 + 有证据时的联合成因扩充。
|
|
|
|
|
|
"""
|
|
|
|
|
|
record = _raw(record)
|
|
|
|
|
|
explicit = record.get("reliefConstraintIds")
|
|
|
|
|
|
if isinstance(explicit, list) and explicit:
|
|
|
|
|
|
known = []
|
|
|
|
|
|
for cid in explicit:
|
|
|
|
|
|
if get_constraint(world, str(cid)) and str(cid) not in known:
|
|
|
|
|
|
known.append(str(cid))
|
|
|
|
|
|
if known:
|
|
|
|
|
|
return known[:_MAX_RELIEF]
|
|
|
|
|
|
base = CONFLICT_TO_CONSTRAINT.get(str(record.get("conflictType") or ""))
|
|
|
|
|
|
if not base:
|
|
|
|
|
|
return []
|
|
|
|
|
|
relief = [base]
|
|
|
|
|
|
# 联合成因一:订单延误 + 所在产线当日存在产能冲突 → 产能不足是延误根源
|
|
|
|
|
|
if (record.get("conflictType") == "DELAY" and base == "C8_due_date"
|
2026-08-26 00:25:46 +08:00
|
|
|
|
and _capacity_evidence(world, record, version_id, track)
|
2026-08-11 00:54:05 +08:00
|
|
|
|
and "C7_capacity" not in relief):
|
|
|
|
|
|
relief.append("C7_capacity")
|
|
|
|
|
|
# 联合成因二:产能冲突 + 产线无替代线可分流 → 选线受限是产能根源
|
|
|
|
|
|
if (record.get("conflictType") == "CAPACITY" and base == "C7_capacity"
|
|
|
|
|
|
and _no_alternative_line(world, record)
|
|
|
|
|
|
and "C5_capability" not in relief):
|
|
|
|
|
|
relief.append("C5_capability")
|
|
|
|
|
|
return relief[:_MAX_RELIEF]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _capacity_evidence(world: World, record: dict[str, Any],
|
2026-08-26 00:25:46 +08:00
|
|
|
|
version_id: int | None, track: str) -> bool:
|
2026-08-11 00:54:05 +08:00
|
|
|
|
"""同版本中,延误订单所在产线是否存在 CAPACITY 冲突(日志复核证据)。"""
|
|
|
|
|
|
line_name = _order_line_name(world, record)
|
|
|
|
|
|
if not line_name:
|
|
|
|
|
|
return False
|
2026-08-26 00:25:46 +08:00
|
|
|
|
conflict_key = "flexConflicts" if track == "flex" else "conflicts"
|
|
|
|
|
|
for c in world.get(conflict_key) or []:
|
2026-08-11 00:54:05 +08:00
|
|
|
|
if c.get("conflictType") != "CAPACITY":
|
|
|
|
|
|
continue
|
|
|
|
|
|
if version_id is not None and c.get("versionId") != version_id:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if c.get("isResolved"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if str(c.get("resourceName") or "") == line_name:
|
|
|
|
|
|
return True
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _no_alternative_line(world: World, record: dict[str, Any]) -> bool:
|
|
|
|
|
|
"""产能冲突所在产线是否无替代产线(选线受限证据)。"""
|
|
|
|
|
|
name = record.get("resourceName")
|
|
|
|
|
|
line = next((l for l in world.get("lines") or [] if l.get("name") == name), None)
|
|
|
|
|
|
if not line:
|
|
|
|
|
|
return False
|
|
|
|
|
|
alternatives = line.get("alternativeLineIds") or []
|
|
|
|
|
|
return not alternatives
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- IIS 检测(最小冲突集) ----------------
|
|
|
|
|
|
def _active_hard_constraint_ids(world: World) -> set[str]:
|
|
|
|
|
|
return {
|
|
|
|
|
|
c["id"] for c in get_constraint_profile(world)["constraints"]
|
|
|
|
|
|
if c.get("enabled") and c.get("kind") == "hard"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def detect_iis(world: World, version_id: int | None = None,
|
|
|
|
|
|
track: str = "fixed", max_iis: int = 8) -> dict[str, Any]:
|
|
|
|
|
|
"""最小冲突集(IIS)检测:对不可行排产输入产出不可约不可行子集。
|
|
|
|
|
|
|
|
|
|
|
|
模型:
|
|
|
|
|
|
- 输入 = 硬约束违反记录(求解日志证据),每条记录有一个解除约束集合
|
|
|
|
|
|
relief(放松其中任一约束即可消除该违反)。
|
|
|
|
|
|
- 约束子集 S 为「不可行」当且仅当存在违反记录 v 满足 relief(v) 包含于 S
|
|
|
|
|
|
(该违反只有在 S 中的约束全部生效时才成立——联合成因语义)。
|
|
|
|
|
|
- S 为 IIS 当且仅当 S 不可行,且删除其中任一约束 c 后 S 去掉 c 可行
|
|
|
|
|
|
(不可约性质:每个成员都是必要的)。
|
|
|
|
|
|
返回:
|
|
|
|
|
|
feasible=True 时 iis 为空并附中文说明;否则返回 iis 组列表,
|
|
|
|
|
|
每组含 constraints / violations / verification(逐约束删除验证)/
|
|
|
|
|
|
copy(中文文案)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
rows = hard_violations(world, version_id=version_id, track=track)
|
|
|
|
|
|
if not rows:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"feasible": True, "iis": [], "violations": [],
|
|
|
|
|
|
"message": "当前排产版本没有硬约束违反,方案可行,无需冲突归因。",
|
|
|
|
|
|
}
|
|
|
|
|
|
reliefs: list[tuple[dict[str, Any], frozenset[str]]] = []
|
|
|
|
|
|
for r in rows:
|
2026-08-26 00:25:46 +08:00
|
|
|
|
rs = frozenset(relief_constraints(world, r, version_id=version_id, track=track))
|
2026-08-11 00:54:05 +08:00
|
|
|
|
if rs:
|
|
|
|
|
|
reliefs.append((r, rs))
|
|
|
|
|
|
if not reliefs:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"feasible": True, "iis": [], "violations": rows,
|
|
|
|
|
|
"message": "当前版本仅存在软约束风险或无映射约束的冲突,不构成不可行。",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
constraint_ids = sorted({cid for _, rs in reliefs for cid in rs})
|
|
|
|
|
|
if len(constraint_ids) > _MAX_IIS_CONSTRAINTS:
|
|
|
|
|
|
constraint_ids = constraint_ids[:_MAX_IIS_CONSTRAINTS]
|
|
|
|
|
|
|
|
|
|
|
|
def infeasible(S: frozenset[str]) -> bool:
|
|
|
|
|
|
return any(rs <= S for _, rs in reliefs)
|
|
|
|
|
|
|
|
|
|
|
|
found: list[frozenset[str]] = []
|
|
|
|
|
|
for size in range(1, len(constraint_ids) + 1):
|
|
|
|
|
|
for combo in combinations(constraint_ids, size):
|
|
|
|
|
|
S = frozenset(combo)
|
|
|
|
|
|
if not infeasible(S):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if any(infeasible(S - {c}) for c in S):
|
|
|
|
|
|
continue
|
|
|
|
|
|
found.append(S)
|
|
|
|
|
|
# 只保留集合包含意义下的最小 IIS(不可约)
|
|
|
|
|
|
minimal = [S for S in found if not any(T < S for T in found)]
|
|
|
|
|
|
minimal.sort(key=lambda S: (len(S), sorted(S)))
|
|
|
|
|
|
minimal = minimal[:max_iis]
|
|
|
|
|
|
|
|
|
|
|
|
groups = []
|
|
|
|
|
|
for S in minimal:
|
|
|
|
|
|
group_violations = [r for r, rs in reliefs if rs <= S]
|
|
|
|
|
|
cons = [_constraint_meta(world, cid) for cid in sorted(S)]
|
|
|
|
|
|
verification = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"constraintId": cid,
|
|
|
|
|
|
"constraintName": (get_constraint(world, cid) or {}).get("name") or cid,
|
|
|
|
|
|
"deleted": cid,
|
|
|
|
|
|
"feasibleAfterDelete": not infeasible(S - {cid}),
|
|
|
|
|
|
}
|
|
|
|
|
|
for cid in sorted(S)
|
|
|
|
|
|
]
|
|
|
|
|
|
group = {
|
2026-08-26 00:25:46 +08:00
|
|
|
|
"iisId": "IIS-" + "-".join(sorted(S)),
|
2026-08-11 00:54:05 +08:00
|
|
|
|
"constraints": cons,
|
|
|
|
|
|
"constraintIds": sorted(S),
|
|
|
|
|
|
"violations": group_violations,
|
|
|
|
|
|
"verification": verification,
|
|
|
|
|
|
"irreducible": True,
|
|
|
|
|
|
}
|
|
|
|
|
|
group["copy"] = iis_copy(group)
|
|
|
|
|
|
groups.append(group)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"feasible": False,
|
|
|
|
|
|
"iis": groups,
|
|
|
|
|
|
"violations": rows,
|
|
|
|
|
|
"message": (
|
|
|
|
|
|
f"当前排产版本不可行:{len(rows)} 条硬约束违反,"
|
|
|
|
|
|
f"构成 {len(groups)} 组最小冲突集(IIS)。"
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _constraint_meta(world: World, cid: str) -> dict[str, Any]:
|
|
|
|
|
|
cons = get_constraint(world, cid) or {}
|
|
|
|
|
|
return {
|
|
|
|
|
|
"constraintId": cid,
|
|
|
|
|
|
"constraintName": cons.get("name") or cid,
|
|
|
|
|
|
"constraintCode": cons.get("code") or "",
|
|
|
|
|
|
"group": cons.get("group") or "",
|
|
|
|
|
|
"kind": cons.get("kind") or "hard",
|
|
|
|
|
|
"plainLanguage": PLAIN_LANGUAGE.get(cid, cons.get("description") or cid),
|
|
|
|
|
|
"enabled": bool(cons.get("enabled", True)),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def feasibility_after_relax(world: World, constraint_ids: Iterable[str],
|
|
|
|
|
|
version_id: int | None = None,
|
|
|
|
|
|
track: str = "fixed") -> bool:
|
|
|
|
|
|
"""删除(松弛)给定约束后,剩余硬约束是否全部满足(是否恢复可行)。
|
|
|
|
|
|
|
|
|
|
|
|
纯只读模拟:不修改 world。IIS 语义下的「删除任一约束后可恢复可行」
|
|
|
|
|
|
即对每个 IIS 成员 c:feasibility_after_relax(world, [c]) == True。
|
|
|
|
|
|
注意:多个独立 IIS 并存时,需从每个 IIS 各松弛一条约束整体才可行。
|
|
|
|
|
|
"""
|
|
|
|
|
|
drop = {str(c) for c in constraint_ids}
|
|
|
|
|
|
active = _active_hard_constraint_ids(world) - drop
|
|
|
|
|
|
for r in hard_violations(world, version_id=version_id, track=track):
|
2026-08-26 00:25:46 +08:00
|
|
|
|
relief = frozenset(relief_constraints(world, r, version_id=version_id, track=track))
|
2026-08-11 00:54:05 +08:00
|
|
|
|
if relief and relief <= active:
|
|
|
|
|
|
return False
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 归因报告 ----------------
|
|
|
|
|
|
def build_attribution_report(world: World, version_id: int | None = None,
|
|
|
|
|
|
track: str = "fixed") -> dict[str, Any]:
|
|
|
|
|
|
"""结构化归因报告:字段可直接展示,结论可对照求解日志复核。
|
|
|
|
|
|
|
|
|
|
|
|
结构:
|
|
|
|
|
|
feasible / message / versionId / summary
|
|
|
|
|
|
iis :最小冲突集(含中文文案与逐约束删除验证)
|
|
|
|
|
|
attributions :逐违反记录归因(约束/实体/违反度/证据/文案)
|
2026-08-26 00:25:46 +08:00
|
|
|
|
masterControl :主控参数结论(日志代理 + 诊断 LP 局部边际率)
|
|
|
|
|
|
diagnosticLpAnalysis:诊断线性松弛模型与 CP-SAT 边界
|
2026-08-11 00:54:05 +08:00
|
|
|
|
evidence :复核索引(conflictId -> 版本 + 日志描述)
|
|
|
|
|
|
"""
|
|
|
|
|
|
iis_res = detect_iis(world, version_id=version_id, track=track)
|
|
|
|
|
|
rows = iis_res.get("violations") or []
|
2026-08-26 00:25:46 +08:00
|
|
|
|
attributions = [_attribution_entry(world, r, track=track) for r in rows]
|
|
|
|
|
|
from server.aps_domain.shadow_price import analyze_diagnostic_lp
|
|
|
|
|
|
shadow_analysis = analyze_diagnostic_lp(attributions)
|
|
|
|
|
|
master = _master_control_entries(
|
|
|
|
|
|
world, rows, diagnostic_lp_by_constraint=shadow_analysis["constraints"],
|
|
|
|
|
|
)
|
2026-08-11 00:54:05 +08:00
|
|
|
|
evidence = {
|
|
|
|
|
|
f"conflict#{r.get('conflictId')}": {
|
|
|
|
|
|
"conflictId": r.get("conflictId"),
|
|
|
|
|
|
"versionId": r.get("versionId"),
|
|
|
|
|
|
"conflictType": r.get("conflictType"),
|
|
|
|
|
|
"constraintId": r.get("constraintId"),
|
|
|
|
|
|
"description": r.get("description"),
|
|
|
|
|
|
}
|
|
|
|
|
|
for r in rows
|
|
|
|
|
|
}
|
|
|
|
|
|
if iis_res.get("feasible"):
|
|
|
|
|
|
summary = iis_res["message"]
|
|
|
|
|
|
else:
|
|
|
|
|
|
summary = iis_res["message"] + " 已生成逐条归因与主控参数结论(见 attributions / masterControl)。"
|
|
|
|
|
|
return {
|
|
|
|
|
|
"feasible": bool(iis_res.get("feasible")),
|
|
|
|
|
|
"message": iis_res.get("message", ""),
|
|
|
|
|
|
"versionId": version_id,
|
|
|
|
|
|
"summary": summary,
|
|
|
|
|
|
"iis": iis_res.get("iis") or [],
|
|
|
|
|
|
"attributions": attributions,
|
|
|
|
|
|
"masterControl": master,
|
2026-08-26 00:25:46 +08:00
|
|
|
|
"diagnosticLpAnalysis": shadow_analysis,
|
2026-08-11 00:54:05 +08:00
|
|
|
|
"evidence": evidence,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-26 00:25:46 +08:00
|
|
|
|
def _attribution_entry(world: World, r: dict[str, Any], *, track: str) -> dict[str, Any]:
|
|
|
|
|
|
relief = relief_constraints(world, r, version_id=r.get("versionId"), track=track)
|
2026-08-11 00:54:05 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"conflictId": r.get("conflictId"),
|
|
|
|
|
|
"constraintId": r.get("constraintId"),
|
|
|
|
|
|
"constraintName": r.get("constraintName"),
|
|
|
|
|
|
"plainLanguage": PLAIN_LANGUAGE.get(r.get("constraintId") or "",
|
|
|
|
|
|
r.get("constraintName") or ""),
|
|
|
|
|
|
"reliefConstraintIds": relief,
|
|
|
|
|
|
"conflictType": r.get("conflictType"),
|
|
|
|
|
|
"severity": r.get("severity"),
|
|
|
|
|
|
"severityLabel": r.get("severityLabel"),
|
|
|
|
|
|
"orderNos": r.get("orderNos") or [],
|
|
|
|
|
|
"resourceType": r.get("resourceType"),
|
|
|
|
|
|
"resourceName": r.get("resourceName"),
|
|
|
|
|
|
"conflictTimeStart": r.get("conflictTimeStart"),
|
|
|
|
|
|
"degree": r.get("degree"),
|
|
|
|
|
|
"description": r.get("description"),
|
|
|
|
|
|
"suggestedSolution": r.get("suggestedSolution"),
|
|
|
|
|
|
"evidence": r.get("evidence"),
|
|
|
|
|
|
"copy": conflict_copy(r),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _master_control_entries(world: World,
|
2026-08-26 00:25:46 +08:00
|
|
|
|
rows: list[dict[str, Any]],
|
|
|
|
|
|
*,
|
|
|
|
|
|
diagnostic_lp_by_constraint: dict[str, dict[str, Any]] | None = None,
|
|
|
|
|
|
) -> list[dict[str, Any]]:
|
|
|
|
|
|
"""主控参数结论:日志代理和诊断 LP 对偶值并列,避免口径混淆。"""
|
2026-08-11 00:54:05 +08:00
|
|
|
|
by_constraint: dict[str, dict[str, Any]] = {}
|
|
|
|
|
|
for r in rows:
|
|
|
|
|
|
cid = r.get("constraintId") or ""
|
|
|
|
|
|
entry = by_constraint.setdefault(cid, {
|
|
|
|
|
|
"constraintId": cid,
|
|
|
|
|
|
"constraintName": r.get("constraintName") or cid,
|
|
|
|
|
|
"plainLanguage": PLAIN_LANGUAGE.get(cid, r.get("constraintName") or cid),
|
|
|
|
|
|
"blockingCount": 0,
|
|
|
|
|
|
"orderNos": [],
|
|
|
|
|
|
"resourceNames": [],
|
|
|
|
|
|
"totalMinutes": 0.0,
|
|
|
|
|
|
"hoursLate": 0.0,
|
|
|
|
|
|
"evidenceRefs": [],
|
|
|
|
|
|
})
|
|
|
|
|
|
entry["blockingCount"] += 1
|
|
|
|
|
|
for no in r.get("orderNos") or []:
|
|
|
|
|
|
if no not in entry["orderNos"]:
|
|
|
|
|
|
entry["orderNos"].append(no)
|
|
|
|
|
|
if r.get("resourceName") and r["resourceName"] not in entry["resourceNames"]:
|
|
|
|
|
|
entry["resourceNames"].append(r["resourceName"])
|
|
|
|
|
|
degree = r.get("degree") or {}
|
|
|
|
|
|
if degree.get("kind") == "overload_minutes":
|
|
|
|
|
|
entry["totalMinutes"] += float(degree.get("value") or 0.0)
|
|
|
|
|
|
if degree.get("kind") == "hours_late":
|
|
|
|
|
|
entry["hoursLate"] += float(degree.get("value") or 0.0)
|
|
|
|
|
|
if r.get("conflictId") is not None:
|
|
|
|
|
|
entry["evidenceRefs"].append(f"conflict#{r['conflictId']}")
|
|
|
|
|
|
out = []
|
|
|
|
|
|
for cid, e in by_constraint.items():
|
2026-08-26 00:25:46 +08:00
|
|
|
|
diagnostic_lp = (diagnostic_lp_by_constraint or {}).get(cid) or {
|
|
|
|
|
|
"status": "unsupported",
|
|
|
|
|
|
"reason": "当前约束没有诊断 LP 对偶结果",
|
|
|
|
|
|
"localMarginalBenefit": None,
|
|
|
|
|
|
"exactForLpModel": True,
|
|
|
|
|
|
"exactForCpSat": False,
|
|
|
|
|
|
}
|
|
|
|
|
|
if diagnostic_lp.get("status") == "available":
|
|
|
|
|
|
price_copy = (
|
|
|
|
|
|
f"诊断 LP 的局部边际下降率为 {float(diagnostic_lp['localMarginalBenefit']):.3f}:"
|
|
|
|
|
|
"统一小幅放宽该约束时,总残余违反分钟数按该速率下降。该结果为 one-at-a-time "
|
|
|
|
|
|
"反事实,跨约束不可相加,也不是 CP-SAT 对偶值。"
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
price_copy = (
|
|
|
|
|
|
"当前证据无法构造分钟口径的 LP 对偶值;仅保留冲突日志代理,"
|
|
|
|
|
|
"不得解释为真实影子价。"
|
|
|
|
|
|
)
|
2026-08-11 00:54:05 +08:00
|
|
|
|
conclusion = (
|
|
|
|
|
|
f"约束「{e['constraintName']}」是当前方案的紧约束:共 {e['blockingCount']} 条硬违反"
|
|
|
|
|
|
+ (f",涉及订单 {'、'.join(e['orderNos'][:6])}" if e["orderNos"] else "")
|
|
|
|
|
|
+ (f",涉及资源 {'、'.join(e['resourceNames'][:6])}" if e["resourceNames"] else "")
|
|
|
|
|
|
+ (f",累计超载约 {e['totalMinutes']:.0f} 分钟" if e["totalMinutes"] else "")
|
|
|
|
|
|
+ (f",累计延误约 {e['hoursLate']:.1f} 小时" if e["hoursLate"] else "")
|
2026-08-26 00:25:46 +08:00
|
|
|
|
+ "。" + price_copy
|
2026-08-11 00:54:05 +08:00
|
|
|
|
)
|
|
|
|
|
|
out.append({
|
|
|
|
|
|
**e,
|
|
|
|
|
|
"relaxValueMinutes": round(e["totalMinutes"], 1),
|
|
|
|
|
|
"relaxValueLabel": (
|
|
|
|
|
|
f"松弛约 {e['totalMinutes']:.0f} 分钟超载" if e["totalMinutes"]
|
|
|
|
|
|
else f"消除 {e['blockingCount']} 条硬违反"),
|
|
|
|
|
|
"isShadowPriceProxy": True,
|
2026-08-26 00:25:46 +08:00
|
|
|
|
"diagnosticLp": diagnostic_lp,
|
|
|
|
|
|
"hasDiagnosticLp": diagnostic_lp.get("status") == "available",
|
2026-08-11 00:54:05 +08:00
|
|
|
|
"conclusion": conclusion,
|
2026-08-26 00:25:46 +08:00
|
|
|
|
"verificationNote": (
|
|
|
|
|
|
"日志代理对照 evidence 冲突记录;诊断 LP 局部边际率由有限差分重解给出,"
|
|
|
|
|
|
"GLOP cap dual 仅作数值一致性证据。"
|
|
|
|
|
|
),
|
2026-08-11 00:54:05 +08:00
|
|
|
|
})
|
|
|
|
|
|
out.sort(key=lambda e: (-e["blockingCount"], e["constraintId"]))
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 中文归因文案 ----------------
|
|
|
|
|
|
def conflict_copy(r: dict[str, Any]) -> str:
|
|
|
|
|
|
"""单条违反的中文归因文案(现场计划员可读)。"""
|
|
|
|
|
|
name = r.get("constraintName") or r.get("constraintId") or "约束"
|
|
|
|
|
|
plain = r.get("plainLanguage") or name
|
|
|
|
|
|
bits = [f"约束「{name}」({plain})被违反"]
|
|
|
|
|
|
desc = r.get("description")
|
|
|
|
|
|
if desc:
|
|
|
|
|
|
bits.append(str(desc))
|
|
|
|
|
|
orders = r.get("orderNos") or []
|
|
|
|
|
|
if orders:
|
|
|
|
|
|
bits.append("涉及订单 #" + "、#".join(orders[:6]))
|
|
|
|
|
|
if r.get("resourceName"):
|
|
|
|
|
|
bits.append(f"资源/对象:{r['resourceName']}")
|
|
|
|
|
|
t = r.get("conflictTimeStart")
|
|
|
|
|
|
if t:
|
|
|
|
|
|
bits.append(f"时间:{_fmt_date_short(t)}")
|
|
|
|
|
|
degree = r.get("degree") or {}
|
|
|
|
|
|
if degree.get("detail"):
|
|
|
|
|
|
bits.append(f"违反度:{degree['detail']}")
|
|
|
|
|
|
fix = r.get("suggestedSolution")
|
|
|
|
|
|
if fix:
|
|
|
|
|
|
bits.append(f"解开方式:{fix}")
|
|
|
|
|
|
return ";".join(bits) + "。"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def iis_copy(group: dict[str, Any]) -> str:
|
|
|
|
|
|
"""最小冲突组的中文文案:哪些约束互相卡死、涉及谁、怎么解开。"""
|
|
|
|
|
|
names = [f"「{c.get('constraintName') or c.get('constraintId')}」"
|
|
|
|
|
|
for c in group.get("constraints") or []]
|
|
|
|
|
|
if len(names) > 1:
|
|
|
|
|
|
title = "约束" + "与".join(names) + "相互卡死"
|
|
|
|
|
|
else:
|
|
|
|
|
|
title = f"约束{names[0]}卡死方案"
|
|
|
|
|
|
order_nos: list[str] = []
|
|
|
|
|
|
resources: list[str] = []
|
|
|
|
|
|
times: list[str] = []
|
|
|
|
|
|
fixes: list[str] = []
|
|
|
|
|
|
for v in group.get("violations") or []:
|
|
|
|
|
|
for no in v.get("orderNos") or []:
|
|
|
|
|
|
if no not in order_nos:
|
|
|
|
|
|
order_nos.append(no)
|
|
|
|
|
|
if v.get("resourceName") and v["resourceName"] not in resources:
|
|
|
|
|
|
resources.append(v["resourceName"])
|
|
|
|
|
|
if v.get("conflictTimeStart") and v["conflictTimeStart"] not in times:
|
|
|
|
|
|
times.append(v["conflictTimeStart"])
|
|
|
|
|
|
if v.get("suggestedSolution") and v["suggestedSolution"] not in fixes:
|
|
|
|
|
|
fixes.append(v["suggestedSolution"])
|
|
|
|
|
|
parts = [title]
|
|
|
|
|
|
if order_nos:
|
|
|
|
|
|
parts.append("涉及订单 #" + "、#".join(order_nos[:6]))
|
|
|
|
|
|
if resources:
|
|
|
|
|
|
parts.append("共用资源 " + "、".join(resources[:6]))
|
|
|
|
|
|
if times:
|
|
|
|
|
|
parts.append("时间 " + "、".join(_fmt_date_short(t) for t in times[:3]))
|
|
|
|
|
|
if fixes:
|
|
|
|
|
|
parts.append("建议:" + ";".join(fixes[:2]))
|
|
|
|
|
|
else:
|
|
|
|
|
|
parts.append("建议:调整订单时间或资源分配后重排")
|
|
|
|
|
|
return "。".join(parts) + "。"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def master_copy(entry: dict[str, Any]) -> str:
|
|
|
|
|
|
"""主控参数结论的中文文案。"""
|
|
|
|
|
|
return str(entry.get("conclusion") or "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 公开入口 ----------------
|
|
|
|
|
|
def attribution_for_schedule(world: World, version_id: int | None = None,
|
|
|
|
|
|
track: str = "fixed") -> dict[str, Any]:
|
|
|
|
|
|
"""排产版本归因入口:IIS + 逐条归因 + 主控参数结论(未来可接线网关/前端)。"""
|
2026-08-26 00:25:46 +08:00
|
|
|
|
return build_attribution_report(world, version_id=version_id, track=track)
|