aps-agent/server/aps_domain/attribution.py

676 lines
30 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-attribution, 可重生 ✅)
# M5 第 89 项:紧约束/影子价、IIS 与冲突归因
# ------------------------------------------------------------
# 解决的问题:
# 1) 对不可行排产版本产出「最小冲突集(IIS,不可约不可行子集)」:
# 冲突组内的每条约束都是必要的——删除其中任一约束后,该冲突组
# 恢复可行(其余冲突组不受影响时整体可行)。
# 2) 从求解日志(rule/CP 引擎写入 world["conflicts"] 的约束违反记录)
# 提取可复核结论:哪个约束、哪些实体(订单/资源/物料)、违反度,
# 每条结论携带 evidence(冲突 ID + 版本 + 日志描述),可对照复核。
# 3) 生成现场计划员可读的中文归因文案(避免纯数学术语)。
# ------------------------------------------------------------
# 设计说明(与现有模块的关系):
# - 复用 constraints.hard_blocking_conflicts 的检测结果作为输入证据,
# 不修改任何现有接口;本模块为只读消费者(P0)。
# - 约束违反记录默认只归属引擎映射的单一约束(CONFLICT_TO_CONSTRAINT);
# 当日志/数据中存在联合成因证据时(例如「订单延误的根源是产能不足」),
# 自动扩充解除约束集合 relief(放松其中任一约束即可消除该违反)。
# - 原「松弛收益」仍是求解日志代理量;Round 79 另做 GLOP 诊断线性松弛
# 重解,有限差分为主、cap dual 为数值校验。该结果不是原 CP-SAT 对偶,
# 也不可跨约束相加。
# ============================================================
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,
track: str = "fixed") -> 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, track)
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, track: str) -> bool:
"""同版本中,延误订单所在产线是否存在 CAPACITY 冲突(日志复核证据)。"""
line_name = _order_line_name(world, record)
if not line_name:
return False
conflict_key = "flexConflicts" if track == "flex" else "conflicts"
for c in world.get(conflict_key) 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, track=track))
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 = {
"iisId": "IIS-" + "-".join(sorted(S)),
"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, track=track))
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 :主控参数结论(日志代理 + 诊断 LP 局部边际率)
diagnosticLpAnalysis:诊断线性松弛模型与 CP-SAT 边界
evidence :复核索引(conflictId -> 版本 + 日志描述)
"""
iis_res = detect_iis(world, version_id=version_id, track=track)
rows = iis_res.get("violations") or []
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"],
)
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,
"diagnosticLpAnalysis": shadow_analysis,
"evidence": evidence,
}
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)
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]],
*,
diagnostic_lp_by_constraint: dict[str, dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
"""主控参数结论:日志代理和诊断 LP 对偶值并列,避免口径混淆。"""
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():
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 对偶值;仅保留冲突日志代理,"
"不得解释为真实影子价。"
)
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 "")
+ "。" + price_copy
)
out.append({
**e,
"relaxValueMinutes": round(e["totalMinutes"], 1),
"relaxValueLabel": (
f"松弛约 {e['totalMinutes']:.0f} 分钟超载" if e["totalMinutes"]
else f"消除 {e['blockingCount']} 条硬违反"),
"isShadowPriceProxy": True,
"diagnosticLp": diagnostic_lp,
"hasDiagnosticLp": diagnostic_lp.get("status") == "available",
"conclusion": conclusion,
"verificationNote": (
"日志代理对照 evidence 冲突记录;诊断 LP 局部边际率由有限差分重解给出,"
"GLOP cap dual 仅作数值一致性证据。"
),
})
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)