aps-agent/server/aps_domain/lns.py

611 lines
29 KiB
Python
Raw Normal View History

# ============================================================
# LNS 插单局部修复与影响半径(moduleId: domain-lns, OR-04 扩展,可重生 ✅)
# 规则:固定窗口内最小扰动(窗口外订单冻结不动);超过影响阈值
# 自动升级全量重排(ESCALATE → rush.apply 走 P2 人工确认)。
# 依赖:RULE 引擎物化急单工时;经典"插入+右移级联"邻域搜索。
# ============================================================
from __future__ import annotations
import copy
import uuid
from datetime import datetime
from typing import Any
from server.aps_domain.rush import inject_rush_trunk, resolve_rush_payload
from server.contracts import ScheduleResult, UIBlock
from server.engines import get_engine
from server.engines.base import EngineParams
from server.timeutil import add_minutes, fmt_date, fmt_dt, parse_dt, today0
World = dict[str, Any]
# ---- 默认窗口参数(可被 window 覆盖)----
DEFAULT_WINDOW_HOURS = 72.0 # 时间窗宽度:急单锚点前后各半
DEFAULT_MAX_AFFECTED = 5 # 受影响订单数上限(超过 → 升级)
DEFAULT_DISTURBANCE_TOLERANCE = 0.5 # 扰动容忍度阈值(移动数 / 窗口订单数)
def _counter(world: World):
tables = {
"scheduleVersion": "scheduleVersions", "productionOrder": "productionOrders",
"workOrder": "workOrders", "salesOrder": "salesOrders",
"conflict": "conflicts", "audit": "auditEvents", "log": "logs",
}
counters: dict[str, int] = {}
def next_id(kind: str) -> int:
key = tables.get(kind, kind + "s")
rows = world.get(key, [])
if kind not in counters:
counters[kind] = max((r.get("id", 0) for r in rows if isinstance(r, dict)), default=0)
counters[kind] += 1
return counters[kind]
return next_id
def _hours(a: datetime, b: datetime) -> float:
return (b - a).total_seconds() / 3600.0
def _engine_params(world: World, order_ids: list[int], start_date: str | None) -> EngineParams:
"""RULE 引擎入参:可限定订单集(LNS 局部求解用)。"""
from server.aps_domain.constraints import engine_constraint_flags
from server.aps_domain.params import get_schedule_params
sp = get_schedule_params(world)
return EngineParams(
orderIds=order_ids,
engineType="RULE",
strategyTemplate="DELIVERY_FIRST",
planningHorizonDays=int(sp.get("planningHorizonDays") or 14),
startDate=start_date or fmt_date(add_minutes(today0(), 24 * 60)),
constraints=engine_constraint_flags(world),
)
def _due_map(world: World) -> dict[int, str]:
return {so["id"]: so["deliveryDate"] for so in world.get("salesOrders", [])
if so.get("deliveryDate")}
def _po_tardiness(po: dict, due_map: dict[int, str]) -> float:
due = due_map.get(po.get("salesOrderId"))
if not due:
return 0.0
return max(0.0, _hours(parse_dt(due), parse_dt(po["plannedEndDate"])))
def _latest_schedule(world: World) -> dict[str, Any]:
"""当前排产:最新版本的 PO/WO 集合(无版本时回退全部 PO)。"""
versions = world.get("scheduleVersions") or []
if versions:
vid = versions[-1]["id"]
pos = [p for p in world.get("productionOrders", [])
if p.get("schedulingVersionId") == vid]
if pos:
return {"versionId": vid, "productionOrders": pos}
return {"versionId": None, "productionOrders": list(world.get("productionOrders", []))}
def _rush_duration_mini_solve(world: World, rush: dict[str, Any]) -> tuple[float, int] | None:
"""沙盒内只排急单,取自然落点(产线 + 工时)。返回 (时长小时, 产线 id) 或 None。"""
sb = copy.deepcopy(world)
sb["productionOrders"] = []
sb["workOrders"] = []
sb["conflicts"] = []
from server.aps_domain.rush import inject_rush
order = inject_rush(sb, rush)
try:
result = get_engine("RULE").solve(sb, _engine_params(sb, [order["id"]], None), _counter(sb))
except Exception:
return None
pos = [p for p in sb.get("productionOrders", []) if p.get("salesOrderId") == order["id"]]
if not pos or result.poCount <= 0:
return None
po = pos[0]
dur = _hours(parse_dt(po["plannedStartDate"]), parse_dt(po["plannedEndDate"]))
return dur, po.get("lineId")
def _build_window(world: World, rush: dict[str, Any], anchor: datetime,
window_params: dict[str, Any] | None) -> dict[str, Any]:
"""构造固定时间窗(锚点 ± 半窗;可显式指定 windowStart/End 覆盖)。"""
wp = dict(window_params or {})
hours = float(wp.get("timeWindowHours") or DEFAULT_WINDOW_HOURS)
start_dt = parse_dt(wp["windowStart"]) if wp.get("windowStart") else add_minutes(anchor, -hours * 60 / 2)
end_dt = parse_dt(wp["windowEnd"]) if wp.get("windowEnd") else add_minutes(anchor, hours * 60 / 2)
if end_dt <= start_dt:
raise ValueError("LNS 窗口参数非法:windowEnd 必须晚于 windowStart")
return {
"start": fmt_dt(start_dt),
"end": fmt_dt(end_dt),
"anchor": fmt_date(anchor),
"timeWindowHours": hours,
"maxAffectedOrders": int(wp.get("maxAffectedOrders") or DEFAULT_MAX_AFFECTED),
"disturbanceTolerance": float(wp.get("disturbanceTolerance") or DEFAULT_DISTURBANCE_TOLERANCE),
"windowStart": fmt_dt(start_dt),
"windowEnd": fmt_dt(end_dt),
}
def _line_sequence(world: World, line_id: int) -> list[dict]:
return sorted(
[p for p in world.get("productionOrders", [])
if p.get("lineId") == line_id and p.get("plannedStartDate") and p.get("plannedEndDate")],
key=lambda p: (p["plannedStartDate"], p["id"]),
)
def _cascade_insert(seq: list[dict], slot: datetime, rush_dur: float,
window_end: datetime) -> tuple[list[tuple[dict, datetime, datetime, float]], datetime, datetime] | None:
"""经典"插入 + 右移级联":把急单放入 slot,后续重叠订单依次右移。
返回 (moved, rush_start, rush_end);moved 为 [(po, 原start, 新start, 位移小时)]。
若级联需要移动窗口外订单(原 start >= 窗口右沿)→ 返回 None(影响半径越界)。
"""
idx = len(seq)
for i, po in enumerate(seq):
if parse_dt(po["plannedStartDate"]) >= slot:
idx = i
break
cursor = slot
if idx > 0:
cursor = max(cursor, parse_dt(seq[idx - 1]["plannedEndDate"]))
rush_start = cursor
rush_end = add_minutes(cursor, rush_dur * 60)
cursor = rush_end
moved: list[tuple[dict, datetime, datetime, float]] = []
for po in seq[idx:]:
s = parse_dt(po["plannedStartDate"])
e = parse_dt(po["plannedEndDate"])
if s < cursor:
if s >= window_end: # 窗口外订单被波及 → 升级信号
return None
delta = _hours(s, cursor)
moved.append((po, s, cursor, delta))
e = add_minutes(e, delta * 60)
s = cursor
cursor = e
return moved, rush_start, rush_end
def _candidate_slots(seq: list[dict], window: dict[str, Any], rush_dur: float,
due: datetime | None) -> list[datetime]:
w_start = parse_dt(window["start"])
w_end = parse_dt(window["end"])
slots: set[datetime] = {w_start}
for po in seq:
s, e = parse_dt(po["plannedStartDate"]), parse_dt(po["plannedEndDate"])
if (w_start <= s <= w_end) or (w_start <= e <= w_end):
slots.add(e) # 窗口内订单的完工点也是候选插入点
if due is not None:
slots.add(add_minutes(due, -rush_dur * 60)) # 对准交期倒排
slots.add(add_minutes(w_end, -rush_dur * 60)) # 窗口右沿倒排
out = []
for s in slots:
s = max(s, w_start)
if add_minutes(s, rush_dur * 60) <= w_end: # 只保留窗口内能放下的候选
out.append(s)
return sorted(set(out))
def lns_local_repair(world: World, payload: dict[str, Any],
window: dict[str, Any] | None = None) -> dict[str, Any]:
"""LNS 插单局部修复评估(P1,沙盒):窗口内最小扰动 vs 升级全量重排。
Args:
world: 世界状态(含当前排产版本 productionOrders)
payload: 与 evaluate_rush 相同的插单载荷(orderNo 或 productCode/quantity/...)
window: 可选窗口参数覆盖:{timeWindowHours, maxAffectedOrders,
disturbanceTolerance, windowStart, windowEnd}
Returns:
{status: "LOCAL"|"ESCALATE", window, disturbance, plan, escalateReasons, ...}
纯沙盒:不修改传入 world。
"""
sandbox = copy.deepcopy(world) # P1:主干零接触
rush = resolve_rush_payload(sandbox, payload)
strategy = str(payload.get("strategy") or "DELIVERY_FIRST").upper()
due = parse_dt(rush.get("deliveryDate") or fmt_date(add_minutes(today0(), 5 * 24 * 60)))
current = _latest_schedule(sandbox)
due_map = _due_map(sandbox)
# 急单自身信息:新建 → 迷你求解取工时;已有 → 用当前排产中的 PO
rush_po: dict | None = None
for po in current["productionOrders"]:
if po.get("salesOrderId") == rush.get("id"):
rush_po = po
break
if rush_po is not None:
dur = _hours(parse_dt(rush_po["plannedStartDate"]), parse_dt(rush_po["plannedEndDate"]))
line_id = rush_po.get("lineId")
anchor = parse_dt(rush_po["plannedStartDate"])
else:
solved = _rush_duration_mini_solve(sandbox, rush)
if solved is None:
return _escalate_result(rush, strategy, window,
["急单无可用产线/无法物化,需全量重排"])
dur, line_id = solved
anchor = due
win = _build_window(sandbox, rush, anchor, window)
w_start = parse_dt(win["start"])
w_end = parse_dt(win["end"])
if rush_po is None:
seq = [p for p in _line_sequence(sandbox, line_id) if p.get("salesOrderId") != rush.get("id")]
else:
seq = [p for p in _line_sequence(sandbox, line_id) if p.get("id") != rush_po["id"]]
window_order_count = sum(
1 for p in sandbox.get("productionOrders", [])
if (w_start <= parse_dt(p["plannedStartDate"]) <= w_end)
or (w_start <= parse_dt(p["plannedEndDate"]) <= w_end)
)
# 基准扰动指标(全量延迟)
base_tardiness = sum(_po_tardiness(p, due_map) for p in sandbox.get("productionOrders", []))
if dur > _hours(w_start, w_end):
return _escalate_result(rush, strategy, win,
[f"急单工时 {dur:.1f}h 超过窗口宽度 {_hours(w_start, w_end):.1f}h"])
candidates: list[tuple[tuple, list, datetime, datetime]] = []
for slot in _candidate_slots(seq, win, dur, due):
res = _cascade_insert(seq, slot, dur, w_end)
if res is None:
continue # 波及窗口外 → 该候选不可行
moved, rush_start, rush_end = res
if rush_start < w_start or rush_end > w_end:
continue # 急单必须落在窗口内(固定窗口语义)
# 扰动度量
shifted = sum(m[3] for m in moved)
after_tardiness = base_tardiness
for po, s0, s1, delta in moved:
after_tardiness += _po_tardiness({**po, "plannedEndDate": fmt_dt(
add_minutes(parse_dt(po["plannedEndDate"]), delta * 60))}, due_map) - _po_tardiness(po, due_map)
after_tardiness += max(0.0, _hours(due, rush_end))
t_delta = after_tardiness - base_tardiness
score = (len(moved), round(shifted, 2), round(t_delta, 2), rush_start)
candidates.append((score, moved, rush_start, rush_end))
if not candidates:
return _escalate_result(rush, strategy, win,
["窗口内无可行插入点(急单放不下或所有方案均波及窗口外订单)"])
score, moved, rush_start, rush_end = min(candidates, key=lambda c: c[0])
chosen = next((c for c in candidates if c[0] == score), None)
moved_order_count = len(moved)
shifted_hours = round(sum(m[3] for m in moved), 2)
ratio = moved_order_count / max(1, window_order_count)
plan_moves = []
for po, s0, s1, delta in moved:
plan_moves.append({
"salesOrderId": po.get("salesOrderId"),
"orderNo": po.get("orderNo"),
"beforeStart": fmt_dt(s0),
"afterStart": fmt_dt(s1),
"afterEnd": fmt_dt(add_minutes(parse_dt(po["plannedEndDate"]), delta * 60)),
"deltaHours": round(delta, 2),
"isRush": False,
})
plan_moves.sort(key=lambda m: m["afterStart"])
rush_end_gap = round(_hours(due, rush_end), 2) # 负值 = 提前完工
reasons: list[str] = []
if moved_order_count > win["maxAffectedOrders"]:
reasons.append(f"受影响订单 {moved_order_count} 条 > 上限 {win['maxAffectedOrders']} 条")
if ratio > win["disturbanceTolerance"]:
reasons.append(f"扰动率 {ratio:.0%} 超过容忍阈值 {win['disturbanceTolerance']:.0%}")
# 影响半径:窗口尾部积压风险(信息性,不单独升级)
chain_risk = False
chain_reason = None
last_end = rush_end
for m in plan_moves:
last_end = max(last_end, parse_dt(m["afterEnd"]))
if last_end > w_end:
chain_risk = True
chain_reason = f"窗口尾部积压至 {fmt_dt(last_end)},后续订单存在外溢风险"
line_name = next((ln["name"] for ln in sandbox.get("lines", []) if ln["id"] == line_id), None)
plan = {
"rushStart": fmt_dt(rush_start),
"rushEnd": fmt_dt(rush_end),
"lineId": line_id,
"lineName": line_name,
"movedOrders": plan_moves,
"rushOrder": {
"salesOrderId": rush.get("id"),
"orderNo": rush.get("orderNo"),
"productCode": rush.get("productCode"),
"productName": rush.get("productName"),
"quantity": rush.get("quantity"),
"isNew": rush_po is None,
},
}
disturbance = {
"movedOrderCount": moved_order_count,
"shiftedHours": shifted_hours,
"tardinessDelta": round(chosen[0][2], 2) if chosen else 0.0,
"windowOuterMovedCount": 0, # 级联已拒绝所有波及窗口外的候选
"disturbanceRatio": round(ratio, 4),
"rushEndGapHours": rush_end_gap,
}
status = "LOCAL" if not reasons else "ESCALATE"
return {
"lnsId": uuid.uuid4().hex[:10],
"status": status,
"escalate": status == "ESCALATE",
"escalateReasons": reasons,
"escalation": {"mode": "FULL_RESCHEDULE"} if reasons else None,
"strategy": strategy if strategy in ("DELIVERY_FIRST", "CAPACITY_BALANCE", "COMPREHENSIVE", "KITTING_FIRST", "SKILL_FIRST") else "DELIVERY_FIRST",
"mode": rush["mode"],
"window": {**win, "lineId": line_id, "lineName": line_name, "windowOrderCount": window_order_count},
"rushOrder": {
"orderNo": rush.get("orderNo"),
"id": rush.get("id"),
"customerName": rush.get("customerName"),
"productCode": rush.get("productCode"),
"productName": rush.get("productName"),
"quantity": rush.get("quantity"),
"deliveryDate": rush.get("deliveryDate"),
"isRush": True,
},
"disturbance": disturbance,
"plan": plan,
"chainRisk": {"flag": chain_risk, "reason": chain_reason},
"baseline": {"orderCount": len(sandbox.get("salesOrders", [])),
"poCount": len(current["productionOrders"]),
"windowOrderCount": window_order_count},
"payload": rush,
}
def _escalate_result(rush: dict[str, Any], strategy: str, window: dict[str, Any] | None,
reasons: list[str]) -> dict[str, Any]:
return {
"lnsId": uuid.uuid4().hex[:10],
"status": "ESCALATE",
"escalate": True,
"escalateReasons": reasons,
"escalation": {"mode": "FULL_RESCHEDULE"},
"strategy": strategy if strategy in ("DELIVERY_FIRST", "CAPACITY_BALANCE", "COMPREHENSIVE", "KITTING_FIRST", "SKILL_FIRST") else "DELIVERY_FIRST",
"mode": rush["mode"],
"window": window or {},
"rushOrder": {
"orderNo": rush.get("orderNo"), "id": rush.get("id"),
"customerName": rush.get("customerName"), "productCode": rush.get("productCode"),
"productName": rush.get("productName"), "quantity": rush.get("quantity"),
"deliveryDate": rush.get("deliveryDate"), "isRush": True,
},
"disturbance": {"movedOrderCount": 0, "shiftedHours": 0.0, "tardinessDelta": 0.0,
"windowOuterMovedCount": 0, "disturbanceRatio": 0.0, "rushEndGapHours": None},
"plan": None,
"chainRisk": {"flag": False, "reason": None},
"baseline": {},
"payload": rush,
}
def apply_lns_local(world: World, next_id, params: dict[str, Any]) -> dict[str, Any]:
"""LNS 局部方案落主干(P2):急单入池 + 局部移动 + 新 DRAFT 版本(delta 计划)。
调用方负责 checkpoint/审计(复用 rush.apply 的 P2 建档模式)。
仅接受 status == "LOCAL" 的方案;升级场景请走 apply_rush 全量重排。
"""
lns = params.get("lns") or {}
if lns.get("status") != "LOCAL":
raise ValueError("apply_lns_local 仅接受 LOCAL 方案;升级场景应走全量重排 apply_rush")
rush = resolve_rush_payload(world, params.get("payload") or lns.get("payload") or params)
order = inject_rush_trunk(world, next_id, rush)
versions = world["scheduleVersions"]
old_vid = versions[-1]["id"] if versions else None
new_vid = next_id("scheduleVersion")
now = datetime.now()
ver_no = "V" + fmt_date(now).replace("-", "") + f"-{len(versions) + 1:03d}"
# ① 复制上一版本 PO/WO 到新版本(沿用 adjust.py 的调程建档模式)
old_pos = [p for p in world.get("productionOrders", [])
if old_vid is None or p.get("schedulingVersionId") == old_vid]
po_map: dict[int, int] = {}
for po in old_pos:
nid = next_id("productionOrder")
po_map[po["id"]] = nid
np = copy.deepcopy(po)
np["id"] = nid
np["schedulingVersionId"] = new_vid
world["productionOrders"].append(np)
for wo in world.get("workOrders", []):
if wo.get("productionOrderId") not in po_map:
continue
nw = copy.deepcopy(wo)
nid = next_id("workOrder")
nw["id"] = nid
nw["productionOrderId"] = po_map[wo["productionOrderId"]]
world["workOrders"].append(nw)
# ② 应用局部移动(按 salesOrderId 定位新版本 PO;同步平移其工单)
moves = (lns.get("plan") or {}).get("movedOrders") or []
applied_moves = 0
for mv in moves:
sid = mv.get("salesOrderId")
if sid is None or sid == rush.get("id"):
continue # 急单自身由 ③ 物化
np = next((p for p in world["productionOrders"]
if p.get("schedulingVersionId") == new_vid and p.get("salesOrderId") == sid), None)
if np is None:
continue
before = parse_dt(np["plannedStartDate"])
after = parse_dt(mv["afterStart"])
delta = _hours(before, after)
np["plannedStartDate"] = mv["afterStart"]
np["plannedEndDate"] = mv["afterEnd"]
for wo in world.get("workOrders", []):
if wo.get("productionOrderId") == np["id"]:
wo["plannedStartTime"] = fmt_dt(add_minutes(parse_dt(wo["plannedStartTime"]), delta * 60))
wo["plannedEndTime"] = fmt_dt(add_minutes(parse_dt(wo["plannedEndTime"]), delta * 60))
applied_moves += 1
# ③ 物化急单 PO/WO(沙盒迷你求解 → 平移到计划槽位)
plan = lns.get("plan") or {}
rush_start = parse_dt(plan["rushStart"])
if lns.get("mode") == "existing" and (lns.get("plan") or {}).get("rushOrder", {}).get("isNew") is False:
# 已有订单:其 PO 已在 ① 复制,直接平移到计划槽位
existing = next((p for p in world["productionOrders"]
if p.get("schedulingVersionId") == new_vid and p.get("salesOrderId") == rush.get("id")), None)
if existing is not None:
before = parse_dt(existing["plannedStartDate"])
delta = _hours(before, rush_start)
existing["plannedStartDate"] = plan["rushStart"]
existing["plannedEndDate"] = plan["rushEnd"]
for wo in world.get("workOrders", []):
if wo.get("productionOrderId") == existing["id"]:
wo["plannedStartTime"] = fmt_dt(add_minutes(parse_dt(wo["plannedStartTime"]), delta * 60))
wo["plannedEndTime"] = fmt_dt(add_minutes(parse_dt(wo["plannedEndTime"]), delta * 60))
else:
sb = copy.deepcopy(world)
sb["productionOrders"] = []
sb["workOrders"] = []
sb["conflicts"] = []
get_engine("RULE").solve(sb, _engine_params(sb, [order["id"]],
fmt_date(rush_start)), _counter(sb))
rp = next((p for p in sb.get("productionOrders", []) if p.get("salesOrderId") == order["id"]), None)
if rp is None:
raise ValueError("LNS 急单物化失败:迷你求解未生成生产订单")
delta = _hours(parse_dt(rp["plannedStartDate"]), rush_start)
new_po = copy.deepcopy(rp)
new_po["id"] = next_id("productionOrder")
new_po["schedulingVersionId"] = new_vid
new_po["plannedStartDate"] = plan["rushStart"]
new_po["plannedEndDate"] = plan["rushEnd"]
world["productionOrders"].append(new_po)
for wo in sb.get("workOrders", []):
if wo.get("productionOrderId") != rp["id"]:
continue
nw = copy.deepcopy(wo)
nw["id"] = next_id("workOrder")
nw["productionOrderId"] = new_po["id"]
nw["plannedStartTime"] = fmt_dt(add_minutes(parse_dt(wo["plannedStartTime"]), delta * 60))
nw["plannedEndTime"] = fmt_dt(add_minutes(parse_dt(wo["plannedEndTime"]), delta * 60))
world["workOrders"].append(nw)
# ④ (无整体回填:PO 与工单统一平移,保持计划槽位与扰动度量一致)
# ⑤ 指标统计(局部口径:受影响产线窗口内延迟/冲突近似)
due_map = _due_map(world)
new_pos = [p for p in world["productionOrders"] if p.get("schedulingVersionId") == new_vid]
total_tardiness = round(sum(_po_tardiness(p, due_map) for p in new_pos), 2)
line_id = plan.get("lineId")
line_pos = sorted([p for p in new_pos if p.get("lineId") == line_id and p.get("plannedStartDate")],
key=lambda p: p["plannedStartDate"])
overlap = 0
prev_end = None
for p in line_pos:
s = parse_dt(p["plannedStartDate"])
if prev_end is not None and s < prev_end:
overlap += 1
prev_end = max(prev_end, parse_dt(p["plannedEndDate"])) if prev_end is not None else parse_dt(p["plannedEndDate"])
win = lns.get("window") or {}
w_start, w_end = parse_dt(win.get("start") or "2000-01-01 00:00"), parse_dt(win.get("end") or "2999-12-31 00:00")
busy_h = sum(max(0.0, (min(parse_dt(p["plannedEndDate"]), w_end) - max(parse_dt(p["plannedStartDate"]), w_start)).total_seconds() / 3600)
for p in new_pos if p.get("lineId") == line_id and w_end > w_start)
base_ver = versions[-1] if versions else {}
new_ver = {
**{k: v for k, v in base_ver.items()
if k not in ("id", "versionNo", "versionName", "createdAt", "createdBy",
"publishedAt", "parentVersionId")},
"id": new_vid, "versionNo": ver_no,
"versionName": f"LNS 插单局部修复 · {rush.get('orderNo')}",
"triggerType": "LNS_RUSH",
"status": "DRAFT", "parentVersionId": old_vid,
"orderCount": len(world.get("salesOrders", [])),
"poCount": len(new_pos),
"woCount": sum(1 for w in world.get("workOrders", [])
if w.get("productionOrderId") in {p["id"] for p in new_pos}),
"totalTardiness": total_tardiness,
"conflictCount": overlap,
"createdBy": "agent", "createdAt": fmt_dt(now), "publishedAt": None,
"note": (f"LNS 局部修复:移动 {applied_moves} 单 · 位移 {lns.get('disturbance', {}).get('shiftedHours', 0)}h · "
f"急单 {rush.get('orderNo')}(窗口 {win.get('start')}~{win.get('end')})"),
"lns": {
"lnsId": lns.get("lnsId"),
"status": lns.get("status"),
"disturbance": lns.get("disturbance"),
"window": {k: win.get(k) for k in ("start", "end", "timeWindowHours", "maxAffectedOrders", "disturbanceTolerance")},
},
}
versions.append(new_ver)
result = ScheduleResult(
versionId=new_vid, versionNo=ver_no, engineType="RULE", strategy=lns.get("strategy") or "DELIVERY_FIRST",
status="DRAFT", orderCount=new_ver["orderCount"], poCount=new_ver["poCount"],
woCount=new_ver["woCount"], conflictCount=overlap, totalTardiness=total_tardiness,
avgUtilization=round(busy_h / max(1.0, _hours(w_start, w_end)), 3),
evidenceRefs=[f"lns:{lns.get('lnsId')}"],
solveStatus="LOCAL_REPAIR",
)
return {"order": order, "result": result, "mode": "LNS_LOCAL",
"disturbance": lns.get("disturbance"), "lns": lns}
def format_lns_text(lns: dict[str, Any]) -> str:
"""LNS 评估结果中文摘要(对话侧)。"""
rush = lns.get("rushOrder") or {}
win = lns.get("window") or {}
lines = [
f"LNS 插单局部修复(沙盒,未改主干)· 策略 {lns.get('strategy')}",
f"急单:{rush.get('customerName') or rush.get('orderNo')} · {rush.get('productName')} × {rush.get('quantity')} · 交期 {rush.get('deliveryDate')}",
f"时间窗:{win.get('start')} ~ {win.get('end')}(产线 {win.get('lineName') or win.get('lineId')} · 上限 {win.get('maxAffectedOrders')} 单 · 容忍 {win.get('disturbanceTolerance'):.0%})"
if win else "时间窗:默认",
]
if lns.get("status") == "LOCAL":
d = lns.get("disturbance") or {}
plan = lns.get("plan") or {}
lines += [
(f"方案:{plan.get('rushStart')} → {plan.get('rushEnd')}({'提前' if d.get('rushEndGapHours') is not None and d['rushEndGapHours'] <= 0 else '超期'} "
f"{abs(d.get('rushEndGapHours') or 0):.1f}h)"),
f"扰动:移动 {d.get('movedOrderCount')} 单 · 位移 {d.get('shiftedHours')}h · 延迟变化 {d.get('tardinessDelta'):+.1f}h · 窗口外 {d.get('windowOuterMovedCount')} 单",
]
if lns.get("chainRisk", {}).get("flag"):
lines.append(f"提示:{lns['chainRisk']['reason']}")
lines.append("确认后说「采用插单」(LNS 局部方案入 DRAFT 试排草稿,P2 写前自动建档)。")
else:
reasons = lns.get("escalateReasons") or []
lines.append("升级全量重排 ⚠️ " + ";".join(reasons))
lines.append("确认后说「采用插单」将执行全量重排并生成 DRAFT 版本(P2 人工确认)。")
return "\n".join(lines)
def lns_to_block(lns: dict[str, Any]) -> UIBlock:
"""对话侧 LNS 结果块(复用 rush 卡片样式)。"""
rush = lns.get("rushOrder") or {}
d = lns.get("disturbance") or {}
status = lns.get("status")
if status == "LOCAL":
plan = lns.get("plan") or {}
title = "LNS 插单局部修复(窗口内最小扰动)"
text = (
f"急单:{rush.get('orderNo') or '(新建)'} · {rush.get('productName')} × {rush.get('quantity')}\n"
f"落点:{plan.get('rushStart')} → {plan.get('rushEnd')}({plan.get('lineName') or plan.get('lineId')})\n"
f"扰动:移动 {d.get('movedOrderCount')} 单 · 位移 {d.get('shiftedHours')}h · 延迟 {d.get('tardinessDelta'):+.1f}h\n"
f"窗口外受影响:{d.get('windowOuterMovedCount')} 单(冻结 ✅)"
)
else:
title = "LNS 升级全量重排"
text = (f"急单:{rush.get('orderNo') or '(新建)'} · {rush.get('productName')} × {rush.get('quantity')}\n"
f"升级原因:{';'.join(lns.get('escalateReasons') or ['影响半径超过阈值'])}\n"
"确认后将执行全量重排并生成 DRAFT 版本(P2)。")
return UIBlock(
blockId=f"lns-{lns.get('lnsId')}",
type="text",
props={"kind": "lns-repair", "lns": lns, "title": title, "text": text},
actions=[],
)