# ============================================================ # 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") 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") 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=[], )