aps-agent/tests/golden/test_rush_lns.py

337 lines
14 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.

# ============================================================
# 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": "华东装备客户",
"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": "华东装备客户",
"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": "华东装备客户",
"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"])
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": "华东装备客户",
"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 接线:回复附带计划员可读的插单安排摘要与结果块。"""
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 "插单安排评估完成" in reply.text
assert "本次只做试算,现有计划没有改动" in reply.text
assert "LNS" not in reply.text
assert "沙盒" not in reply.text
assert "DRAFT" not 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("局部调整" in line for line in (confirm_blocks[0].props.get("summary") or []))
assert not any("LNS" in line or "DRAFT" in line or "P2" 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 "插单方案已采用" in msg
assert "LNS" not in msg
assert "DRAFT" not 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()