aps-agent/tests/golden/test_rule_engine.py

205 lines
11 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.

# ============================================================
# RULE 引擎黄金测试(moduleId: golden-rule-engine, 发版门禁 §14.3)
# 断言"结构不变式"而非绝对日期(种子日期相对今天,任意运行日稳定):
# G1 版本/PO/WO 计数关系 G2 工位无双占(硬约束 §3.5)
# G3 工艺先后序不颠倒 G4 工单落在班次日历内(或兜底可解释)
# G5 策略排序生效 G6 冲突结构合法 G7 KPI 值域合法
# ============================================================
from __future__ import annotations # 前向类型引用
import pytest # 测试框架
from server.engines import get_engine # 引擎工厂
from server.engines.base import EngineParams # 引擎入参
from server.engines.queries import get_line_shifts # 班次查询(G4 用)
from server.state.seed import seed_world # 种子数据(每用例全新世界)
from server.timeutil import add_minutes, fmt_date, parse_dt, today0 # 日期工具
def _fresh_world():
"""构造一个全新种子世界(用例间隔离,确定性)。"""
return seed_world() # 直接返回种子
def _next_id_factory():
"""独立发号器(不依赖 WorldStore,保证用例纯内存)。"""
counters: dict[str, int] = {} # 类型 → 当前号
def next_id(kind: str) -> int: # 闭包发号函数
counters[kind] = counters.get(kind, 0) + 1 # 自增
return counters[kind] # 返回新号
return next_id # 返回闭包
def _run(world, strategy="COMPREHENSIVE", engine="RULE"):
"""执行一次排产(默认从明天开始、展望 14 天)。"""
start = fmt_date(add_minutes(today0(), 24 * 60)) # 明天
params = EngineParams(orderIds=[], engineType=engine, strategyTemplate=strategy,
planningHorizonDays=14, startDate=start) # 组装参数
return get_engine(engine).solve(world, params, _next_id_factory()) # 求解
# ---------------- G1:计数关系 ----------------
def test_counts_match_seed_structure():
"""版本计数必须与种子结构一致:7 单皆可排 → 7 个 PO;工单数 = 各产品工艺步骤数之和。"""
world = _fresh_world() # 全新世界
result = _run(world) # 执行排产
assert result.orderCount == 7 # 7 张订单全部参与(无 CANCELLED/COMPLETED)
assert result.poCount == 7 # 每单一项 → 7 个生产订单
# 工单数 = Σ 该订单产品的工艺步骤数(A=5 步,B=4 步,C=4 步;种子订单产品为 1,2,1,3,2,3,2)
expected_wo = 5 + 4 + 5 + 4 + 4 + 4 + 4 # 逐单累加 = 30
assert result.woCount == expected_wo # 工单总数固定
assert len([v for v in world["scheduleVersions"]]) == 1 # 恰生成一个版本
def test_rule_materialization_keeps_legacy_continuous_work_order_contract():
world = _fresh_world()
result = _run(world, engine="RULE")
version = next(row for row in world["scheduleVersions"] if row["id"] == result.versionId)
assert "solverMeta" not in version
cp_only_fields = {
"plannedSegments", "processingMinutes", "elapsedSpanMinutes", "pauseMinutes",
"segmentCount", "cpTimingSource", "cpOrderIndex", "logicalOperationKey",
}
assert world["workOrders"]
assert all(cp_only_fields.isdisjoint(work_order) for work_order in world["workOrders"])
# ---------------- G2:工位无双占(硬约束) ----------------
def test_no_workstation_overlap():
"""同一工位的任意两工单时间区间不得重叠(§3.5 资源硬约束)。"""
world = _fresh_world() # 全新世界
_run(world) # 执行排产
by_ws: dict[int, list] = {} # 工位 → [(start,end)]
for wo in world["workOrders"]: # 收集全部工单区间
by_ws.setdefault(wo["workstationId"], []).append(
(parse_dt(wo["plannedStartTime"]), parse_dt(wo["plannedEndTime"])))
for ws_id, ivs in by_ws.items(): # 逐工位校验
ivs.sort() # 按开始时间排序
for (s1, e1), (s2, e2) in zip(ivs, ivs[1:]): # 相邻两两比较
assert e1 <= s2, f"工位 {ws_id} 存在双占:{e1} > {s2}" # 前段结束不得晚于后段开始
# ---------------- G3:工艺先后序 ----------------
def test_operation_sequence_preserved():
"""同一生产订单内,工序顺序号大的工单开始时间不得早于顺序号小的结束时间。"""
world = _fresh_world() # 全新世界
_run(world) # 执行排产
by_po: dict[int, list] = {} # PO → 工单列表
for wo in world["workOrders"]: # 归组
by_po.setdefault(wo["productionOrderId"], []).append(wo)
for po_id, wos in by_po.items(): # 逐 PO 校验
wos.sort(key=lambda w: w["sequenceNo"]) # 按工艺顺序排列
for prev, nxt in zip(wos, wos[1:]): # 相邻工序比较
assert parse_dt(nxt["plannedStartTime"]) >= parse_dt(prev["plannedEndTime"]), \
f"PO {po_id} 工序颠倒:{nxt['orderNo']} 早于 {prev['orderNo']} 结束" # 后序不得早于前序结束
# ---------------- G4:班次日历约束 ----------------
def test_work_orders_within_shift_calendar():
"""工单开始日应为该产线的工作日(兜底顺延的极端情形除外——种子规模下不应触发)。"""
world = _fresh_world() # 全新世界
_run(world) # 执行排产
for wo in world["workOrders"]: # 逐工单校验
date_str = wo["plannedStartTime"][:10] # 开始日期
shifts = get_line_shifts(world, wo["lineId"], date_str) # 当日班次
# 种子数据 14 天展望内产能充足,不应触发"30 天无槽"兜底 → 必须落在工作日
assert shifts, f"工单 {wo['orderNo']} 落在非工作日 {date_str}"
# ---------------- G5:策略排序生效 ----------------
def test_delivery_first_orders_by_due_date():
"""交期优先策略下,最早交期订单(比亚迪 +4 天)的 PO 应最先开始。"""
world = _fresh_world() # 全新世界
_run(world, strategy="DELIVERY_FIRST") # 交期优先排产
pos = world["productionOrders"] # 全部 PO
earliest = min(pos, key=lambda p: parse_dt(p["plannedStartDate"])) # 最早开始的 PO
so = next(s for s in world["salesOrders"] if s["id"] == earliest["salesOrderId"]) # 对应订单
min_delivery = min(s["deliveryDate"] for s in world["salesOrders"]) # 全局最早交期
assert so["deliveryDate"] == min_delivery # 最早开始的必须是最早交期的订单(EDD)
def test_kitting_first_orders_ready_before_not_ready():
"""齐套优先策略下,备料已完成订单应先进入排产。"""
from server.engines.rule_engine import RuleEngine
world = _fresh_world()
for so in world["salesOrders"]:
so["kitStatus"] = "未完成"
world["salesOrders"][0]["kitStatus"] = "已完成"
start = fmt_date(add_minutes(today0(), 24 * 60))
params = EngineParams(orderIds=[], engineType="RULE", strategyTemplate="KITTING_FIRST",
planningHorizonDays=14, startDate=start)
items, _, _ = RuleEngine().collect_and_order(world, params)
assert items[0]["so"]["id"] == 1
def test_skill_first_orders_high_skill_before_low():
"""技能优先策略下,高技能需求订单应先进入排产。"""
from server.engines.rule_engine import RuleEngine
world = _fresh_world()
for so in world["salesOrders"]:
so["requiredSkillLevel"] = "L2"
world["salesOrders"][0]["requiredSkillLevel"] = "L4"
start = fmt_date(add_minutes(today0(), 24 * 60))
params = EngineParams(orderIds=[], engineType="RULE", strategyTemplate="SKILL_FIRST",
planningHorizonDays=14, startDate=start)
items, _, _ = RuleEngine().collect_and_order(world, params)
assert items[0]["so"]["id"] == 1
# ---------------- G6:冲突结构合法 ----------------
def test_conflicts_are_wellformed():
"""所有冲突必须归属本版本、未解决、类型/级别在枚举内(§3.4 证据可溯的前提)。"""
world = _fresh_world() # 全新世界
result = _run(world) # 执行排产
valid_types = {"NO_LINE", "NO_WORKSTATION", "MATERIAL_SHORTAGE", "DELAY", "CAPACITY", "EQUIPMENT"} # 类型枚举
valid_sev = {"CRITICAL", "MAJOR", "MINOR"} # 级别枚举
assert len(world["conflicts"]) == result.conflictCount # 计数一致
for c in world["conflicts"]: # 逐条校验
assert c["versionId"] == result.versionId # 归属本版本
assert c["isResolved"] is False # 初始未解决
assert c["conflictType"] in valid_types # 类型合法
assert c["severity"] in valid_sev # 级别合法
# ---------------- G7:KPI 值域 ----------------
def test_kpi_ranges():
"""KPI 必须在合法值域:利用率 0~1.5(容差),延迟/成本非负。"""
world = _fresh_world() # 全新世界
result = _run(world) # 执行排产
assert 0 <= result.avgUtilization <= 1.5 # 利用率合理(>1 表示超载但不应爆炸)
assert result.totalTardiness >= 0 # 延迟非负
assert result.totalCost >= 0 # 成本非负
# ---------------- G8:引擎类型标注诚实 ----------------
@pytest.mark.parametrize("requested", ["RULE", "GA"])
def test_engine_type_recorded_for_rule_proxy(requested):
"""GA 仍由 RULE 代跑,但结果必须如实记录请求类型。"""
world = _fresh_world()
result = _run(world, engine=requested)
assert result.engineType == requested
assert world["scheduleVersions"][0]["engineType"] == requested
def test_cp_engine_is_real_not_silent_rule():
"""SC-03:请求 CP 必须走 CpSatEngine,不得静默 RULE 代跑却不留 solverMeta。"""
from server.engines.cp_engine import CpSatEngine
assert isinstance(get_engine("CP"), CpSatEngine)
world = _fresh_world()
result = _run(world, engine="CP")
assert result.engineType == "CP"
assert world["scheduleVersions"][0].get("solverMeta", {}).get("backend") == "OR-Tools CP-SAT"
def test_hybrid_engine_is_real_not_silent_rule():
"""SC-03:HYBRID 必须走 HybridEngine(RULE 热启动 + CP)。"""
from server.engines.cp_engine import HybridEngine
assert isinstance(get_engine("HYBRID"), HybridEngine)
world = _fresh_world()
result = _run(world, engine="HYBRID")
assert result.engineType == "HYBRID"
meta = world["scheduleVersions"][0].get("solverMeta") or {}
assert meta.get("warmStart") == "RULE"