aps-agent/tests/golden/test_rush_lns.py

329 lines
13 KiB
Python
Raw Permalink Normal View History

# ============================================================
# OR-04 LNS 插单局部修复与影响半径黄金测试
# 固化:窗口内最小扰动(窗口外订单冻结)/ 超阈值升级全量重排 /
# 局部方案入 DRAFT 走确认 / 与现有 rush.evaluate|apply 兼容
# ============================================================
from __future__ import annotations
import asyncio
import copy
from server.aps_domain import workflow as wf
from server.aps_domain.lns import apply_lns_local, lns_local_repair
from server.aps_domain.rush import (
_engine_params,
_sandbox_counter,
apply_rush,
evaluate_rush,
)
from server.contracts import IntentResult
from server.engines import get_engine
from server.state.seed import seed_world
from server.timeutil import add_minutes, fmt_date, parse_dt, today0
def _next_id_factory(world):
tables = {
"audit": "auditEvents",
"conflict": "conflicts",
"log": "logs",
"productionOrder": "productionOrders",
"salesOrder": "salesOrders",
"scheduleVersion": "scheduleVersions",
"workOrder": "workOrders",
}
counters: dict[str, int] = {}
def next_id(kind: str) -> int:
if kind not in counters:
rows = world.get(tables.get(kind, kind + "s"), [])
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 _scheduled_world():
world = seed_world()
get_engine("RULE").solve(world, _engine_params(world, "DELIVERY_FIRST"), _sandbox_counter())
return world
def _small_rush_payload():
return {
"customerName": "LNS客户",
"customerLevel": "VIP",
"productId": 1,
"quantity": 100,
"deliveryDate": fmt_date(add_minutes(today0(), 4 * 24 * 60)),
"priority": 1,
}
def _window_outside(world, win):
ws, we = parse_dt(win["start"]), parse_dt(win["end"])
return [p for p in world["productionOrders"]
if not (ws <= parse_dt(p["plannedStartDate"]) <= we
or ws <= parse_dt(p["plannedEndDate"]) <= we)]
def test_lns_local_repair_minimal_perturbation_window_outer_frozen():
"""窗口内最小扰动:LOCAL 方案不移动任何窗口外订单,急单落在窗口内,主干零接触。"""
world = _scheduled_world()
snapshot = copy.deepcopy(world)
lns = lns_local_repair(world, _small_rush_payload())
assert lns["status"] == "LOCAL"
assert lns["escalate"] is False
assert lns["escalateReasons"] == []
d = lns["disturbance"]
assert d["movedOrderCount"] <= lns["window"]["maxAffectedOrders"]
assert d["windowOuterMovedCount"] == 0
assert d["shiftedHours"] >= 0
assert "tardinessDelta" in d
# 急单落点必须在固定窗口内
ws, we = parse_dt(lns["window"]["start"]), parse_dt(lns["window"]["end"])
rs, re_ = parse_dt(lns["plan"]["rushStart"]), parse_dt(lns["plan"]["rushEnd"])
assert ws <= rs and re_ <= we
assert lns["plan"]["rushOrder"]["isNew"] is True
# 窗口外订单不被移动:方案移动集 ⊆ 窗口内订单
moved_sids = {m["salesOrderId"] for m in lns["plan"]["movedOrders"]}
assert all(p["salesOrderId"] not in moved_sids for p in _window_outside(snapshot, lns["window"]))
# P1:主干零接触
assert world["productionOrders"] == snapshot["productionOrders"]
assert world["salesOrders"] == snapshot["salesOrders"]
assert world["scheduleVersions"] == snapshot["scheduleVersions"]
def test_lns_escalate_when_move_count_exceeds_threshold():
"""超过影响阈值:受影响订单数 / 扰动率超限 → 升级全量重排信号。"""
world = _scheduled_world()
payload = {
"customerName": "LNS客户",
"customerLevel": "VIP",
"productId": 1,
"quantity": 200,
"deliveryDate": fmt_date(add_minutes(today0(), 2 * 24 * 60)),
"priority": 1,
}
lns = lns_local_repair(world, payload,
window={"timeWindowHours": 48, "maxAffectedOrders": 1,
"disturbanceTolerance": 0.2})
assert lns["status"] == "ESCALATE"
assert lns["escalate"] is True
assert lns["escalation"] == {"mode": "FULL_RESCHEDULE"}
assert lns["escalateReasons"], "超阈值必须给出升级原因"
assert lns["disturbance"]["movedOrderCount"] > 1
assert any("上限" in r for r in lns["escalateReasons"])
def test_lns_escalate_when_duration_exceeds_window():
"""急单工时超过窗口宽度 → 窗口内不可行,升级全量重排。"""
world = _scheduled_world()
lns = lns_local_repair(world, {
"customerName": "LNS客户",
"customerLevel": "VIP",
"productId": 1,
"quantity": 5000,
"deliveryDate": fmt_date(add_minutes(today0(), 2 * 24 * 60)),
"priority": 1,
}, window={"timeWindowHours": 48})
assert lns["status"] == "ESCALATE"
assert lns["escalate"] is True
assert lns["plan"] is None
assert any("窗口" in r for r in lns["escalateReasons"])
def test_lns_apply_local_creates_draft_and_keeps_window_frozen():
"""LNS 成功 → 局部方案入试排草稿(DRAFT):急单入池 + 新版本 + 窗口外冻结。"""
world = _scheduled_world()
snapshot = copy.deepcopy(world)
baseline_versions = len(world["scheduleVersions"])
lns = lns_local_repair(world, _small_rush_payload())
assert lns["status"] == "LOCAL"
applied = apply_lns_local(world, _next_id_factory(world), {
"payload": lns["payload"],
"lns": lns,
"strategy": lns["strategy"],
})
order = applied["order"]
result = applied["result"]
assert order["isRush"] is True
assert order["status"] == "APPROVED"
assert any(so["id"] == order["id"] for so in world["salesOrders"])
assert len(world["scheduleVersions"]) == baseline_versions + 1
ver = world["scheduleVersions"][-1]
assert ver["status"] == "DRAFT"
assert ver["triggerType"] == "LNS_RUSH"
assert ver["versionNo"] == result.versionNo
assert result.solveStatus == "LOCAL_REPAIR"
assert applied["mode"] == "LNS_LOCAL"
# 急单 PO 按计划槽位落地,工单同步
rush_po = next(p for p in world["productionOrders"] if p.get("salesOrderId") == order["id"])
assert rush_po["plannedStartDate"] == lns["plan"]["rushStart"]
assert rush_po["plannedEndDate"] == lns["plan"]["rushEnd"]
rush_wos = [w for w in world["workOrders"] if w["productionOrderId"] == rush_po["id"]]
assert rush_wos
assert min(w["plannedStartTime"] for w in rush_wos) == lns["plan"]["rushStart"]
# 窗口外订单冻结:新旧版本中其计划时间均不变
for p in _window_outside(snapshot, lns["window"]):
copy_new = next((q for q in world["productionOrders"]
if q.get("salesOrderId") == p["salesOrderId"]
and q.get("schedulingVersionId") == ver["id"]), None)
assert copy_new is not None
assert copy_new["plannedStartDate"] == p["plannedStartDate"]
assert copy_new["plannedEndDate"] == p["plannedEndDate"]
# 回滚到采用前快照(模拟检查点恢复)
world.clear()
world.update(copy.deepcopy(snapshot))
assert len(world["salesOrders"]) == len(snapshot["salesOrders"])
assert len(world["scheduleVersions"]) == baseline_versions
def test_lns_existing_order_apply_moves_po_to_plan_slot():
"""已有订单加急(existing 模式):PO 平移到 LNS 计划槽位,版本仍为 LNS_RUSH DRAFT。"""
world = _scheduled_world()
so = world["salesOrders"][3]
lns = lns_local_repair(world, {"orderNo": so["orderNo"], "strategy": "COMPREHENSIVE"})
assert lns["mode"] == "existing"
assert lns["status"] == "LOCAL"
assert lns["plan"]["rushOrder"]["isNew"] is False
baseline_versions = len(world["scheduleVersions"])
applied = apply_lns_local(world, _next_id_factory(world), {
"payload": lns["payload"],
"lns": lns,
"strategy": lns["strategy"],
})
ver = world["scheduleVersions"][-1]
assert ver["status"] == "DRAFT" and ver["triggerType"] == "LNS_RUSH"
assert len(world["scheduleVersions"]) == baseline_versions + 1
po = next(p for p in world["productionOrders"]
if p.get("salesOrderId") == so["id"] and p.get("schedulingVersionId") == ver["id"])
assert po["plannedStartDate"] == lns["plan"]["rushStart"]
assert po["plannedEndDate"] == lns["plan"]["rushEnd"]
def test_lns_escalation_falls_back_to_full_reschedule_compat():
"""升级场景 → 复用 apply_rush 全量重排(走 P2 确认后执行),生成 DRAFT 版本。"""
world = _scheduled_world()
lns = lns_local_repair(world, {
"customerName": "LNS客户",
"customerLevel": "VIP",
"productId": 1,
"quantity": 5000,
"deliveryDate": fmt_date(add_minutes(today0(), 2 * 24 * 60)),
"priority": 1,
}, window={"timeWindowHours": 48})
assert lns["status"] == "ESCALATE"
baseline_versions = len(world["scheduleVersions"])
applied = apply_rush(world, _next_id_factory(world), {
"payload": lns["payload"],
"strategy": lns["strategy"],
"lns": lns,
})
result = applied["result"]
assert len(world["scheduleVersions"]) == baseline_versions + 1
assert world["scheduleVersions"][-1]["status"] == "DRAFT"
assert result.orderCount == 8 # 种子 7 + 急单 1(全量重排口径)
assert result.versionNo == world["scheduleVersions"][-1]["versionNo"]
def test_evaluate_rush_attaches_lns_additively():
"""与现有 rush.evaluate 兼容:lns 字段附加,原有指标不变;可显式关闭。"""
world = _scheduled_world()
n_orders = len(world["salesOrders"])
impact = evaluate_rush(world, _small_rush_payload())
assert "lns" in impact
assert impact["lns"]["status"] == "LOCAL"
assert impact["lns"]["window"]["windowOrderCount"] > 0
# 原有字段不受影响
assert impact["after"]["orderCount"] == impact["baseline"]["orderCount"] + 1
assert "delayDelta" in impact and "conflictDelta" in impact
assert len(world["salesOrders"]) == n_orders
impact2 = evaluate_rush(world, {**_small_rush_payload(), "lns": False})
assert impact2["lns"] is None
# ------------------------------------------------------------
# 工作流接线:rush.evaluate 携带 LNS;rush.apply 按 LNS 状态路由
# ------------------------------------------------------------
class FakeStore:
"""最小化 WorldStore 替身(对齐 tests/golden/test_schedule_wizard.py 风格)。"""
def __init__(self):
self.data = seed_world()
self._counters: dict[str, int] = {}
def next_id(self, kind: str) -> int:
tables = {
"audit": "auditEvents", "conflict": "conflicts", "log": "logs",
"productionOrder": "productionOrders", "salesOrder": "salesOrders",
"scheduleVersion": "scheduleVersions", "workOrder": "workOrders",
}
rows = self.data.get(tables.get(kind, kind + "s"), [])
cur = max((r.get("id", 0) for r in rows if isinstance(r, dict)), default=0)
self._counters[kind] = max(self._counters.get(kind, 0), cur) + 1
return self._counters[kind]
def save(self) -> None:
pass
def _run(intent_name, store, params):
return asyncio.run(wf.handle_intent(store, "s-lns", IntentResult(intent=intent_name, params=params, confidence=0.95), "tester"))
def test_workflow_rush_evaluate_attaches_lns_block():
"""rush.evaluate 接线:回复附带 LNS 摘要与 lns-repair 块。"""
wf._LAST_RUSH_EVAL.clear()
store = FakeStore()
get_engine("RULE").solve(store.data, _engine_params(store.data, "DELIVERY_FIRST"), store.next_id)
reply = _run("rush.evaluate", store, _small_rush_payload())
assert "LNS" in reply.text
kinds = [b.props.get("kind") for b in reply.blocks]
assert "rush-impact" in kinds
assert "lns-repair" in kinds
wf._LAST_RUSH_EVAL.clear()
def test_workflow_rush_apply_local_routes_to_lns_draft():
"""rush.apply 增强:LNS=LOCAL → 局部方案入 DRAFT 草稿(P2 确认后执行)。"""
wf._LAST_RUSH_EVAL.clear()
store = FakeStore()
get_engine("RULE").solve(store.data, _engine_params(store.data, "DELIVERY_FIRST"), store.next_id)
_run("rush.evaluate", store, _small_rush_payload())
assert wf._LAST_RUSH_EVAL.get("lns", {}).get("status") == "LOCAL"
staged = _run("rush.apply", store, {})
confirm_blocks = [b for b in staged.blocks if b.type == "confirm-card"]
assert confirm_blocks, "rush.apply 必须出 P2 确认卡"
confirm_id = confirm_blocks[0].props["confirmId"]
assert any("LNS" in line for line in (confirm_blocks[0].props.get("summary") or []))
baseline_versions = len(store.data["scheduleVersions"])
msg = wf.execute_confirmed(store, confirm_id, approve=True, actor="tester")
assert "LNS 局部插单已采用" in msg
assert len(store.data["scheduleVersions"]) == baseline_versions + 1
assert store.data["scheduleVersions"][-1]["triggerType"] == "LNS_RUSH"
assert store.data["scheduleVersions"][-1]["status"] == "DRAFT"
wf._LAST_RUSH_EVAL.clear()