611 lines
28 KiB
Python
611 lines
28 KiB
Python
# ============================================================
|
||
# 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=[],
|
||
)
|