aps-agent/server/aps_domain/conflicts.py

311 lines
14 KiB
Python
Raw Permalink 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-conflicts, 可重生 ✅)
# EX-03:冲突列表 + 可执行建议 + 一键应用(安全修复 P1 / 重排类 P2)
# ============================================================
from __future__ import annotations
from typing import Any
World = dict[str, Any]
# 冲突类型 → 默认修复动作(可被上下文改写)
_FIX_CATALOG: dict[str, list[dict[str, Any]]] = {
"NO_CAPABILITY": [
{"action": "flex.fault.recover", "label": "恢复设备后重排", "power": "P1"},
{"action": "flex.reschedule", "label": "L2 短窗重排", "power": "P2",
"params": {"level": "L2"}},
{"action": "flex.swap", "label": "L1 局部换机(池内备机)", "power": "P1"},
],
"NO_MOLD": [
{"action": "flex.mold.unlock", "label": "解锁同工序模具", "power": "P1"},
{"action": "flex.reschedule", "label": "L2 短窗重排", "power": "P2",
"params": {"level": "L2"}},
],
"MOLD_LIFE": [
{"action": "flex.mold.unlock", "label": "解锁并重置寿命计数", "power": "P1"},
{"action": "flex.reschedule", "label": "L2 短窗重排", "power": "P2",
"params": {"level": "L2"}},
],
"DELAY": [
{"action": "flex.reschedule", "label": "L2 短窗重排", "power": "P2",
"params": {"level": "L2"}},
{"action": "flex.reschedule", "label": "L3 日窗重排", "power": "P2",
"params": {"level": "L3"}},
],
"WINDOW_DEFERRED": [
{"action": "flex.reschedule", "label": "L3 日窗重排(扩窗)", "power": "P2",
"params": {"level": "L3"}},
{"action": "flex.schedule", "label": "中窗重排 mid", "power": "P1",
"params": {"window": "mid"}},
],
"WINDOW_TRUNCATED": [
{"action": "flex.reschedule", "label": "L3 日窗重排", "power": "P2",
"params": {"level": "L3"}},
],
"MATERIAL_SHORTAGE": [
{"action": "note", "label": "建议补料/调库存后重排(请走主数据)", "power": "P0"},
{"action": "flex.reschedule", "label": "L2 短窗重排(延期接受)", "power": "P2",
"params": {"level": "L2"}},
],
"NO_TEAM": [
{"action": "note", "label": "建议增派班组人数后重排", "power": "P0"},
{"action": "flex.reschedule", "label": "L2 短窗重排", "power": "P2",
"params": {"level": "L2"}},
],
"NO_ROUTING": [
{"action": "note", "label": "请补齐工艺路线主数据", "power": "P0"},
],
"EQUIPMENT": [
{"action": "schedule.run", "label": "固定轨试排(避让维保)", "power": "P1"},
],
"CAPACITY": [
{"action": "schedule.run", "label": "固定轨试排(产能均衡)", "power": "P1",
"params": {"strategy": "CAPACITY_BALANCE"}},
],
"DELAY_FIXED": [
{"action": "schedule.run", "label": "交期优先试排", "power": "P1",
"params": {"strategy": "DELIVERY_FIRST"}},
],
}
def list_conflict_center(world: World, scope: str = "flex") -> dict:
"""冲突中心投影:最新版本未解决冲突 + 可执行建议。"""
from server.state.seed import ensure_flex_seed
ensure_flex_seed(world)
rows: list[dict] = []
if scope in ("flex", "all"):
versions = world.get("flexScheduleVersions", [])
latest_id = versions[-1]["id"] if versions else None
for cf in world.get("flexConflicts", []):
if latest_id and cf.get("versionId") != latest_id:
continue
if cf.get("isResolved"):
continue
rows.append(_enrich(cf, track="flex"))
if scope in ("fixed", "all"):
versions = world.get("scheduleVersions", [])
latest_id = versions[-1]["id"] if versions else None
for cf in world.get("conflicts", []):
if latest_id and cf.get("versionId") != latest_id:
continue
if cf.get("isResolved"):
continue
rows.append(_enrich(cf, track="fixed"))
sev = {"CRITICAL": 0, "MAJOR": 1, "MINOR": 2}
rows.sort(key=lambda r: (sev.get(r.get("severity") or "", 9), r.get("id") or 0))
return {
"scope": scope,
"total": len(rows),
"critical": sum(1 for r in rows if r.get("severity") == "CRITICAL"),
"major": sum(1 for r in rows if r.get("severity") == "MAJOR"),
"rows": rows,
}
def _enrich(cf: dict, track: str) -> dict:
ctype = cf.get("conflictType") or ""
# 固定轨 DELAY 与柔性同名,用 catalog 键区分策略时可映射
catalog_key = ctype
if track == "fixed" and ctype == "DELAY":
catalog_key = "DELAY_FIXED"
fixes = []
for raw in _FIX_CATALOG.get(catalog_key, []):
fix = {**raw, "params": {**(raw.get("params") or {})}}
# 注入冲突上下文
if fix["action"] == "flex.swap":
# 从描述/资源名猜设备码
code = _guess_equipment(cf)
if code:
fix["params"]["equipmentCode"] = code
else:
continue # 无设备码则不展示换机
if fix["action"] == "flex.fault.recover":
code = _guess_equipment(cf)
if not code:
continue
fix["params"]["equipmentCode"] = code
if fix["action"] == "flex.mold.unlock":
mold = cf.get("resourceName") if cf.get("resourceType") == "MOLD" else None
fix["params"]["moldCode"] = mold
fix["params"]["operationHint"] = cf.get("operationCode")
fixes.append(fix)
if not fixes:
fixes = [{"action": "note", "label": cf.get("suggestedSolution") or "人工处理",
"power": "P0", "params": {}}]
return {
"id": cf.get("id"),
"track": track,
"conflictType": ctype,
"severity": cf.get("severity"),
"orderNo": cf.get("orderNo"),
"resourceType": cf.get("resourceType"),
"resourceName": cf.get("resourceName"),
"description": cf.get("description"),
"engineSuggestion": cf.get("suggestedSolution"),
"versionId": cf.get("versionId"),
"isResolved": bool(cf.get("isResolved")),
"fixes": fixes,
}
def _guess_equipment(cf: dict) -> str | None:
import re
for src in (cf.get("resourceName"), cf.get("description"), cf.get("suggestedSolution")):
if not src:
continue
m = re.search(r"(PRESS|WELD|CUT|TEST|LEAK|PULL|PKG)-\d+", str(src), re.I)
if m:
return m.group(0).upper()
# 从工序码推断默认单点设备(演示种子)
desc = str(cf.get("description") or "")
m_op = re.search(r"OP-([A-Z]+)", desc, re.I)
if m_op:
op = m_op.group(1).upper()
defaults = {"WELD": "WELD-01", "CRIMP": "PRESS-01", "CUT": "CUT-01",
"TEST": "TEST-01", "LEAK": "LEAK-01"}
return defaults.get(op)
return None
def apply_conflict_fix(store, conflict_id: int, action: str,
params: dict | None = None, actor: str = "web",
session_id: str = "web") -> dict:
"""应用冲突修复。P2 动作返回 stage 确认卡;P1 直接执行并标记冲突已解决。"""
from server.agent_core import harness
from server.agent_core.audit import write_audit
from server.state.seed import ensure_flex_seed
ensure_flex_seed(store.data)
world = store.data
params = dict(params or {})
cf = _find_conflict(world, conflict_id)
if cf is None:
raise ValueError(f"找不到冲突 id={conflict_id}")
if cf.get("isResolved"):
raise ValueError(f"冲突 #{conflict_id} 已解决")
if action == "note":
return {"message": "该建议需人工处理,未改世界状态。", "applied": False}
if action == "flex.reschedule":
from server.aps_domain.flex import preview_reschedule
level = str(params.get("level") or "L2").upper()
title, lines = preview_reschedule(store, level)
lines = [f"针对冲突 #{conflict_id}:{cf.get('description', '')[:80]}", *lines]
block = harness.stage_confirmation(
session_id, "flex.reschedule",
{"level": level, "sortMode": params.get("sortMode"),
"resolveConflictId": conflict_id},
title=f"冲突修复 · {title}", summary_lines=lines)
write_audit(world, store.next_id, actor=actor, category="GATE",
action="conflict.apply.stage",
target={"type": "FLEX_CONFLICT", "id": conflict_id},
power="P2", rationale={"confirmId": block.props["confirmId"],
"action": action, "level": level})
store.save()
return {"message": f"{title} 已进入 P2 确认队列。", "applied": False,
"staged": True, "block": block.model_dump()}
def _apply():
result: dict[str, Any] = {"action": action}
if action == "flex.swap":
from server.aps_domain.flex import local_swap_equipment
code = params.get("equipmentCode") or _guess_equipment(cf)
if not code:
raise ValueError("无法定位故障设备,请手动局部换机")
# local_swap 内部已 guard;此处再包一层仅记账
swap = local_swap_equipment(store, equipment_code=code, mark_fault=True, actor=actor)
result["swap"] = swap
elif action == "flex.fault.recover":
from server.aps_domain.flex import apply_equipment_fault
code = params.get("equipmentCode") or _guess_equipment(cf)
if not code:
raise ValueError("无法定位设备")
result["fault"] = apply_equipment_fault(
store, code, status="RUNNING", reschedule=True, actor=actor)
elif action == "flex.mold.unlock":
from server.aps_domain.flex import patch_flex_resource
mold_code = params.get("moldCode") or (
cf.get("resourceName") if cf.get("resourceType") == "MOLD" else None)
if not mold_code:
# 找同工序已锁模具
mold_code = _find_locked_mold(world, params.get("operationHint"))
if not mold_code:
raise ValueError("找不到可解锁模具")
mold = next(m for m in world["flexMolds"] if m["code"] == mold_code)
life_total = int(mold.get("lifeTotal") or 0)
used = int(mold.get("lifeUsed") or 0)
patch = {"status": "AVAILABLE"}
if life_total and used >= life_total:
patch["lifeUsed"] = max(0, life_total - 1000) # 重置留一点余量演示
result["mold"] = patch_flex_resource(store, "mold", mold_code, patch, actor=actor)
elif action == "flex.schedule":
from server.aps_domain.flex import run_flex_schedule
result["schedule"] = run_flex_schedule(
store, sort_mode="BOTTLENECK", window=params.get("window") or "mid",
actor=actor, name=f"冲突#{conflict_id}修复排产")
elif action == "schedule.run":
from server.engines import get_engine
from server.engines.base import EngineParams
from server.timeutil import add_minutes, fmt_date, today0
strategy = params.get("strategy") or "COMPREHENSIVE"
ep = EngineParams(
orderIds=[], engineType="RULE", strategyTemplate=strategy,
planningHorizonDays=14,
startDate=fmt_date(add_minutes(today0(), 24 * 60)),
)
eng = get_engine("RULE")
result["schedule"] = eng.solve(world, ep, store.next_id)
store.save()
else:
raise ValueError(f"不支持的修复动作:{action}")
cf["isResolved"] = True
cf["resolutionAction"] = action
cf["resolvedBy"] = actor
return result
power = harness.power_of(
"flex.schedule" if action == "flex.schedule"
else "flex.swap" if action == "flex.swap"
else "flex.fault" if action == "flex.fault.recover"
else "flex.resource.patch" if action == "flex.mold.unlock"
else "schedule.run" if action == "schedule.run"
else "flex.reschedule")
if power == "P2":
raise ValueError(f"动作 {action} 为 P2,请走确认卡路径")
# 用 flex.conflict.resolve 统一门禁
out = harness.guard("flex.conflict.resolve",
{"conflictId": conflict_id, "action": action}, _apply)
write_audit(world, store.next_id, actor=actor, category="WORLD_WRITE",
action="conflict.apply",
target={"type": "CONFLICT", "id": conflict_id},
power="P1", rationale={"action": action, "params": params})
store.save()
return {"message": f"冲突 #{conflict_id} 已应用「{action}」。", "applied": True,
"result": out, "conflict": _enrich(cf, track="flex" if conflict_id in
{c.get("id") for c in world.get("flexConflicts", [])} else "fixed")}
def _find_conflict(world: World, conflict_id: int) -> dict | None:
for cf in world.get("flexConflicts", []):
if cf.get("id") == conflict_id:
return cf
for cf in world.get("conflicts", []):
if cf.get("id") == conflict_id:
return cf
return None
def _find_locked_mold(world: World, operation_hint: str | None) -> str | None:
for m in world.get("flexMolds", []):
if m.get("status") != "LOCKED":
continue
if operation_hint and m.get("operationCode") != operation_hint:
continue
return m["code"]
for m in world.get("flexMolds", []):
if m.get("status") == "LOCKED":
return m["code"]
return None