640 lines
28 KiB
Python
640 lines
28 KiB
Python
# ============================================================
|
||
# 冲突归因与最小冲突集(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(放松其中任一约束即可消除该违反)。
|
||
# - 「松弛收益(近似影子价)」是从求解日志推导的代理量(松绑后可消除的
|
||
# 阻塞冲突数 / 释放的超载分钟数),并非 LP 对偶价格;真实影子价需
|
||
# 对线性化模型重新求解,本模块明确标注为近似值。
|
||
# ============================================================
|
||
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],
|
||
version_id: int | None = None) -> list[str]:
|
||
"""一条违反记录的「解除约束集合」:放松其中任一约束即可消除该违反。
|
||
|
||
优先使用记录自带 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"
|
||
and _capacity_evidence(world, record, version_id)
|
||
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],
|
||
version_id: int | None) -> bool:
|
||
"""同版本中,延误订单所在产线是否存在 CAPACITY 冲突(日志复核证据)。"""
|
||
line_name = _order_line_name(world, record)
|
||
if not line_name:
|
||
return False
|
||
for c in world.get("conflicts") or []:
|
||
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:
|
||
rs = frozenset(relief_constraints(world, r, version_id=version_id))
|
||
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 = {
|
||
"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):
|
||
relief = frozenset(relief_constraints(world, r, version_id=version_id))
|
||
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 :逐违反记录归因(约束/实体/违反度/证据/文案)
|
||
masterControl :主控参数结论(紧约束 + 松弛收益近似影子价)
|
||
evidence :复核索引(conflictId -> 版本 + 日志描述)
|
||
"""
|
||
iis_res = detect_iis(world, version_id=version_id, track=track)
|
||
rows = iis_res.get("violations") or []
|
||
attributions = [_attribution_entry(world, r) for r in rows]
|
||
master = _master_control_entries(world, rows)
|
||
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,
|
||
"evidence": evidence,
|
||
}
|
||
|
||
|
||
def _attribution_entry(world: World, r: dict[str, Any]) -> dict[str, Any]:
|
||
relief = relief_constraints(world, r, version_id=r.get("versionId"))
|
||
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,
|
||
rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||
"""主控参数结论:按约束聚合的紧约束信号(近似影子价,可由日志复核)。"""
|
||
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():
|
||
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 "")
|
||
+ "。松绑该约束可消除上述违反(松弛收益为近似影子价,由求解日志推导)。"
|
||
)
|
||
out.append({
|
||
**e,
|
||
"relaxValueMinutes": round(e["totalMinutes"], 1),
|
||
"relaxValueLabel": (
|
||
f"松弛约 {e['totalMinutes']:.0f} 分钟超载" if e["totalMinutes"]
|
||
else f"消除 {e['blockingCount']} 条硬违反"),
|
||
"isShadowPriceProxy": True,
|
||
"conclusion": conclusion,
|
||
"verificationNote": "结论可由求解日志复核:对照 evidence 中列出的冲突记录(conflictId → description)。",
|
||
})
|
||
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 + 逐条归因 + 主控参数结论(未来可接线网关/前端)。"""
|
||
return build_attribution_report(world, version_id=version_id, track=track) |