# ============================================================ # SC-12 滚动窗 / DY-01 分级重排 / SC-11 班组约束 # ============================================================ from __future__ import annotations from server.agent_core.intent import parse_fast from server.aps_domain.flex import reschedule_by_level, run_flex_schedule from server.engines.pool_engine import PoolEngine, parse_duration, resolve_window_minutes from server.state.seed import seed_world from server.timeutil import add_minutes, fmt_date, today0 class _MemStore: def __init__(self, data): self.data = data def next_id(self, kind: str) -> int: key = f"_c_{kind}" self.data[key] = self.data.get(key, 1000) + 1 return self.data[key] def save(self): pass def test_parse_duration_and_window(): assert parse_duration("2h") == 120 assert parse_duration("2d") == 2 * 24 * 60 assert parse_duration("60m") == 60 assert resolve_window_minutes({"rollingWindows": {"short": "2h"}}, "short") == 120 assert resolve_window_minutes({}, "full") is None assert resolve_window_minutes({}, "realtime") == 60 from server.engines.pool_engine import window_step_minutes assert window_step_minutes("realtime") == 1 assert window_step_minutes("short") == 15 def test_short_window_defers_or_truncates(): world = seed_world() start = fmt_date(add_minutes(today0(), 24 * 60)) r = PoolEngine().solve(world, _MemStore(world).next_id, sort_mode="BOTTLENECK", start_date=start, window="short") # 短窗 2h 不可能排完全部 10 单全工序 → 应有延期或截断冲突,或 wo 明显少于全量 full = seed_world() r_full = PoolEngine().solve(full, _MemStore(full).next_id, sort_mode="BOTTLENECK", start_date=start, window="full") assert r["woCount"] <= r_full["woCount"] assert r.get("window") == "short" assert r["stepMinutes"] == 15 def test_team_constraint_assigns_team_code(): world = seed_world() start = fmt_date(add_minutes(today0(), 24 * 60)) PoolEngine().solve(world, _MemStore(world).next_id, sort_mode="BOTTLENECK", start_date=start, enforce_teams=True) wos = world["flexWorkOrders"] assert wos assert any(w.get("teamCode") for w in wos) def test_l2_reschedule_freezes_outside(): store = _MemStore(seed_world()) run_flex_schedule(store, sort_mode="BOTTLENECK", actor="test", window="full") before_wo = len([w for w in store.data["flexWorkOrders"] if w["versionId"] == store.data["flexScheduleVersions"][-1]["id"]]) assert before_wo > 0 r = reschedule_by_level(store, level="L2", actor="test") assert r["level"] == "L2" assert r["schedule"]["versionNo"] # 新版本含冻结标记工单(若有窗外)或至少产出版本 latest = store.data["flexScheduleVersions"][-1]["id"] frozen = [w for w in store.data["flexWorkOrders"] if w["versionId"] == latest and w.get("frozen")] assert r.get("frozenWo", 0) == len(frozen) or r["schedule"]["woCount"] >= 0 def test_intent_rolling_and_reschedule(): world = seed_world() assert parse_fast("短窗重排", world).intent == "flex.reschedule" assert parse_fast("短窗重排", world).params.get("level") == "L2" assert parse_fast("日窗重排", world).params.get("level") == "L3" hit = parse_fast("滚动排产", world) assert hit.intent == "flex.schedule" assert hit.params.get("window") == "short" rt = parse_fast("实时排产", world) assert rt.intent == "flex.schedule" assert rt.params.get("window") == "realtime" def test_realtime_window_step_one_minute(): world = seed_world() start = fmt_date(add_minutes(today0(), 24 * 60)) r = PoolEngine().solve(world, _MemStore(world).next_id, sort_mode="BOTTLENECK", start_date=start, window="realtime") assert r.get("window") == "realtime" assert r["stepMinutes"] == 1 assert r.get("windowMinutes") == 60 def test_l1_local_swap_keeps_others(): """L1:PRESS-01 工单换备机;他单设备与时段不变;不新建版本。""" from server.aps_domain.flex import local_swap_equipment store = _MemStore(seed_world()) run_flex_schedule(store, sort_mode="BOTTLENECK", actor="test", window="full") latest = store.data["flexScheduleVersions"][-1]["id"] before_ver_n = len(store.data["flexScheduleVersions"]) wos = [w for w in store.data["flexWorkOrders"] if w["versionId"] == latest] press_wos = [w for w in wos if w.get("equipmentCode") == "PRESS-01"] assert press_wos, "种子排产后应有 PRESS-01 工单" snapshot = { w["id"]: (w["equipmentCode"], w["plannedStartTime"], w["plannedEndTime"]) for w in wos if w["id"] not in {x["id"] for x in press_wos} } r = local_swap_equipment(store, equipment_code="PRESS-01", mark_fault=True, actor="test") assert r["swapped"] assert all(s["from"] == "PRESS-01" and s["to"] != "PRESS-01" for s in r["swapped"]) assert r.get("faulted") == "PRESS-01" assert len(store.data["flexScheduleVersions"]) == before_ver_n for wid, (code, start, end) in snapshot.items(): wo = next(w for w in store.data["flexWorkOrders"] if w["id"] == wid) assert wo["equipmentCode"] == code assert wo["plannedStartTime"] == start assert wo["plannedEndTime"] == end def test_reschedule_is_p2_and_preview(): from server.agent_core import harness from server.aps_domain.flex import preview_reschedule assert harness.power_of("flex.reschedule") == "P2" assert harness.power_of("flex.swap") == "P1" store = _MemStore(seed_world()) run_flex_schedule(store, sort_mode="BOTTLENECK", actor="test") title, lines = preview_reschedule(store, "L2") assert "L2" in title assert lines # stage 不出执行:只登记确认卡 block = harness.stage_confirmation("test", "flex.reschedule", {"level": "L2"}, title=title, summary_lines=lines) assert block.props["confirmId"] assert harness.power_of("flex.reschedule") == "P2" pending = harness.take_confirmation(block.props["confirmId"]) assert pending["action"] == "flex.reschedule" # 取出后世界版本数不变(未执行) assert len(store.data["flexScheduleVersions"]) == 1