# ============================================================ # 冲突归因与最小冲突集(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)