aps-agent/server/aps_domain/lns.py

641 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.

# ============================================================
# 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"急单局部调整 · {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"急单局部调整:{applied_moves} 张订单顺延,合计 {lns.get('disturbance', {}).get('shiftedHours', 0)} 小时;"
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:
"""插单安排评估的计划员摘要(对话侧)。"""
rush = lns.get("rushOrder") or {}
win = lns.get("window") or {}
line_label = win.get("lineName") or win.get("lineId") or "可安排产线"
lines = [
"插单安排评估完成。本次只做试算,现有计划没有改动。",
f"急单:{rush.get('customerName') or rush.get('orderNo')} · {rush.get('productName')} × {rush.get('quantity')} · 交期 {rush.get('deliveryDate')}",
f"可调整范围:{win.get('start')} 至 {win.get('end')},优先安排到 {line_label}。"
if win else "可调整范围:按当前生产安排。",
]
if lns.get("status") == "LOCAL":
d = lns.get("disturbance") or {}
plan = lns.get("plan") or {}
before_due = "比交期提前" if (d.get("rushEndGapHours") or 0) <= 0 else "比交期晚"
lines += [
f"可安排时间:{plan.get('rushStart')} 至 {plan.get('rushEnd')},{before_due} "
f"{abs(d.get('rushEndGapHours') or 0):.1f} 小时。",
f"调整后:{d.get('movedOrderCount')} 张订单需要顺延,合计顺延 {d.get('shiftedHours')} 小时,"
f"总延期变化 {d.get('tardinessDelta'):+.1f} 小时;调整范围外的订单保持不变。",
]
if lns.get("chainRisk", {}).get("flag"):
lines.append(f"提示:{lns['chainRisk']['reason']}")
lines.append("确认后,我会生成一版待发布的调整方案;现有计划不会直接改变。")
else:
reasons = lns.get("escalateReasons") or []
lines.append(
"这张急单不能只调整少数订单,需要重新编排整版计划。"
+ ("原因:" + ";".join(_planner_lns_reasons(reasons)) if reasons else "")
)
lines.append("确认后,我会生成一版待发布的整版调整方案;现有计划不会直接改变。")
return "\n".join(lines)
def _planner_lns_reasons(reasons: list[str]) -> list[str]:
"""把求解器阈值原因翻成计划员能判断的影响描述。"""
out: list[str] = []
for reason in reasons:
text = str(reason or "").strip()
if not text:
continue
if text.startswith("受影响订单"):
out.append("需要顺延的订单已经超过可接受范围")
elif text.startswith("扰动率"):
out.append("局部调整会打乱太多现有订单")
elif "无可用产线" in text or "无法物化" in text:
out.append("按当前设备、工艺和日历,找不到能安排这张急单的生产线或时间段")
elif "超过窗口宽度" in text:
out.append("这张急单需要的生产时间超过可局部调整的时间范围")
elif "无可行插入点" in text:
out.append("在不对其他订单造成过大影响的前提下,找不到合适的生产时段")
else:
out.append(text)
return out
def lns_to_block(lns: dict[str, Any]) -> UIBlock:
"""对话侧插单安排结果块(复用 rush 卡片样式)。"""
rush = lns.get("rushOrder") or {}
d = lns.get("disturbance") or {}
status = lns.get("status")
if status == "LOCAL":
plan = lns.get("plan") or {}
title = "插单安排评估"
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')} 小时,"
f"总延期变化 {d.get('tardinessDelta'):+.1f} 小时\n"
"调整范围外的订单保持不变;确认后只生成待发布的调整方案。"
)
else:
title = "插单需要整版调整"
reasons = _planner_lns_reasons(lns.get("escalateReasons") or [])
text = (f"急单:{rush.get('orderNo') or '(新建)'} · {rush.get('productName')} × {rush.get('quantity')}\n"
f"需要整版调整的原因:{';'.join(reasons or ['按当前生产安排无法只调整少数订单'])}\n"
"确认后只生成一版待发布的整版调整方案,现有计划不会直接改变。")
return UIBlock(
blockId=f"lns-{lns.get('lnsId')}",
type="text",
props={"kind": "lns-repair", "lns": lns, "title": title, "text": text},
actions=[],
)