# ============================================================ # 柔性能力池引擎黄金测试(moduleId: golden-pool-engine, 发版门禁 §14.3) # 断言柔性排产的结构不变式(吸收 demand/ 康尼芜湖方案): # F1 版本/虚拟产线/工单计数关系 F2 设备无双占(能力池硬约束) # F3 工序 precedence 不颠倒 F4 压接工序必占适配模具 # F5 瓶颈锚模式含瓶颈订单优先 F6 缺能力/缺料冲突结构合法 # F7 可移动设备产生移动耗时痕迹 F8 设备利用率值域合法 # ============================================================ from __future__ import annotations # 前向类型引用 from server.engines import PoolEngine # 柔性引擎 from server.state.seed import seed_world # 种子(含 flex* 数据) from server.timeutil import add_minutes, fmt_date, parse_dt, today0 # 日期工具 def _fresh_world(): """全新种子世界(含柔性层)。""" return seed_world() def _next_id_factory(): """独立发号器(纯内存)。""" 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, sort_mode="BOTTLENECK"): """执行一次柔性排产(明天起)。""" start = fmt_date(add_minutes(today0(), 24 * 60)) return PoolEngine().solve(world, _next_id_factory(), sort_mode=sort_mode, start_date=start) # ---------------- F1:计数关系 ---------------- def test_counts_match_flex_seed(): """10 单(8 RELEASED + 2 CREATED)全部参与;每单一条虚拟产线;工单数=Σ工艺步骤。""" world = _fresh_world() result = _run(world) assert result["orderCount"] == 10 # flexOrders 全部未完成 assert result["vlCount"] == 10 # 每单一条虚拟产线(无致命冲突时) # HV-HARNESS 6步×3 + PDU-UNIT 6步×3 + CHARGE-GUN 6步×2 + HV-CONN 4步×2 = 56 assert result["woCount"] == 6 * 3 + 6 * 3 + 6 * 2 + 4 * 2 assert len(world["flexScheduleVersions"]) == 1 # ---------------- F2:设备无双占 ---------------- def test_no_equipment_overlap(): """同一设备的任意两工单时间区间不得重叠(能力池资源硬约束)。""" world = _fresh_world() _run(world) by_eq: dict[int, list] = {} for wo in world["flexWorkOrders"]: by_eq.setdefault(wo["equipmentId"], []).append( (parse_dt(wo["plannedStartTime"]), parse_dt(wo["plannedEndTime"]))) for eq_id, ivs in by_eq.items(): ivs.sort() for (s1, e1), (s2, e2) in zip(ivs, ivs[1:]): assert e1 <= s2, f"设备 {eq_id} 双占:{e1} > {s2}" # ---------------- F3:工序 precedence ---------------- def test_operation_sequence_preserved(): """同一虚拟产线内工序顺序号大的不得早于前序结束。""" world = _fresh_world() _run(world) by_vl: dict[int, list] = {} for wo in world["flexWorkOrders"]: by_vl.setdefault(wo["vlId"], []).append(wo) for vl_id, wos in by_vl.items(): wos.sort(key=lambda w: w["seq"]) for prev, nxt in zip(wos, wos[1:]): assert parse_dt(nxt["plannedStartTime"]) >= parse_dt(prev["plannedEndTime"]), \ f"VL {vl_id} 工序颠倒" # ---------------- F4:压接工序必占适配模具 ---------------- def test_crimp_requires_adaptable_mold(): """OP-CRIMP 工单必须挂一副模具,且该模具适配所选设备。""" world = _fresh_world() _run(world) molds = {m["code"]: m for m in world["flexMolds"]} eqs = {e["id"]: e for e in world["flexEquipment"]} crimp = [w for w in world["flexWorkOrders"] if w["operationCode"] == "OP-CRIMP"] assert crimp, "应存在压接工单" for w in crimp: assert w["moldCode"] is not None, f"{w['orderNo']} 压接未占模具" mold = molds[w["moldCode"]] assert eqs[w["equipmentId"]]["code"] in mold["adaptableEquipment"], "模具与设备不适配" # ---------------- F5:瓶颈锚模式含瓶颈订单优先 ---------------- def test_bottleneck_mode_orders_bottleneck_first(): """瓶颈锚模式下,版本记录瓶颈工序清单;含瓶颈产品的首单不晚于任一订单开始。""" world = _fresh_world() result = _run(world, sort_mode="BOTTLENECK") assert result["bottleneck"], "应输出瓶颈工序摘要" codes = {b["operationCode"] for b in result["bottleneck"]} assert "OP-CRIMP" in codes and "OP-WELD" in codes # ---------------- F6:冲突结构合法 ---------------- def test_conflicts_wellformed(): """缺料/缺能力/缺模具/延期冲突:归属本版本、未解决、类型合法。""" world = _fresh_world() result = _run(world) valid = {"NO_ROUTING", "NO_CAPABILITY", "NO_MOLD", "MATERIAL_SHORTAGE", "DELAY"} assert len(world["flexConflicts"]) == result["conflictCount"] for c in world["flexConflicts"]: assert c["versionId"] == result["versionId"] assert c["isResolved"] is False assert c["conflictType"] in valid # ---------------- F7:可移动设备的移动耗时痕迹 ---------------- def test_movable_equipment_move_time(): """压接机可移动 → 其工单 moveMin 字段被记录(≥0,且可移动设备可>0)。""" world = _fresh_world() _run(world) crimp = [w for w in world["flexWorkOrders"] if w["operationCode"] == "OP-CRIMP"] for w in crimp: assert "moveMin" in w and w["moveMin"] >= 0 # ---------------- F8:利用率值域 ---------------- def test_utilization_range(): """设备平均利用率在 0~1.5,延迟非负。""" world = _fresh_world() result = _run(world) assert 0 <= result["avgUtilization"] <= 1.5 assert result["totalTardiness"] >= 0 # ---------------- F9:正排/倒排模式可运行且计数一致 ---------------- def test_sort_modes_run(): """三种排产模式均能产出等量虚拟产线(排序影响时间不影响结构完整性)。""" for mode in ("ASC", "DESC", "BOTTLENECK"): world = _fresh_world() result = _run(world, sort_mode=mode) assert result["vlCount"] == 10, f"{mode} 模式虚拟产线数异常" assert result["sortMode"] == mode assert result["makespan"] is not None # 最晚完工可计算 assert 0 <= result["onTimeCount"] <= 10 # 准时单数值域 # ---------------- F10:瓶颈产能法(限制性瓶颈=激光焊接单机池) ---------------- def test_capacity_analysis_bottleneck(): """瓶颈产能法:各池日产能可计算;限制性瓶颈应为单机激光焊接池(OP-WELD)。""" from server.aps_domain.flex import capacity_analysis world = _fresh_world() _run(world) cap = capacity_analysis(world) assert cap["pools"], "应有能力池" for p in cap["pools"]: assert p["dailyCapacity"] > 0 # 日产能为正 assert p["equipmentCount"] >= 1 assert cap["bottleneckPool"]["operationCode"] == "OP-WELD" # 单机焊接=限制性瓶颈 # 压接池 4 台并行,日产能应远高于单机焊接池 crimp = next(p for p in cap["pools"] if p["operationCode"] == "OP-CRIMP") weld = next(p for p in cap["pools"] if p["operationCode"] == "OP-WELD") assert crimp["dailyCapacity"] > weld["dailyCapacity"] # ---------------- F11:设备故障 → 能力池实时收缩 ---------------- def test_equipment_failure_shrinks_pool(): """标记设备为 MAINTENANCE 后,其能力池设备数下降(动态调度的数据基础)。""" from server.aps_domain.flex import capacity_analysis world = _fresh_world() before = next(p for p in capacity_analysis(world)["pools"] if p["operationCode"] == "OP-CRIMP") next(e for e in world["flexEquipment"] if e["code"] == "PRESS-01")["status"] = "MAINTENANCE" after = next(p for p in capacity_analysis(world)["pools"] if p["operationCode"] == "OP-CRIMP") assert after["equipmentCount"] == before["equipmentCount"] - 1 # 池收缩一台 assert after["dailyCapacity"] < before["dailyCapacity"] # 日产能下降 # ---------------- F12:柔性口令进对话意图管线(回归护栏) ---------------- # 防止「柔性排产只有 REST、聊天里降级乱答」再次发生:口令须命中 flex.* 意图。 def _recognize(text): import asyncio from server.agent_core.intent import recognize world = _fresh_world() return asyncio.run(recognize(text, world)) def test_intent_flex_schedule_and_modes(): """柔性/全流程/正倒排/瓶颈锚 口令 → flex.schedule,且模式解析正确。""" cases = { "走一个全流程排产": "BOTTLENECK", "跑一版柔性排产 正排": "ASC", "倒排排一版": "DESC", "瓶颈锚排一版": "BOTTLENECK", "按能力池排产": "BOTTLENECK", } for text, mode in cases.items(): r = _recognize(text) assert r.intent == "flex.schedule", f"{text} 应命中 flex.schedule,实得 {r.intent}" assert r.params.get("sortMode") == mode, f"{text} 模式应为 {mode}" def test_intent_flex_capacity_and_due(): """瓶颈产能 → flex.capacity;带产品+数量的交期问句 → flex.simulate_due 并抽出槽位。""" assert _recognize("瓶颈产能怎么样").intent == "flex.capacity" r = _recognize("PDU 500套什么时候能交") assert r.intent == "flex.simulate_due" assert r.params.get("productCode") == "PDU-UNIT" assert r.params.get("quantity") == 500 r2 = _recognize("高压线束 800件多久能做完") assert r2.intent == "flex.simulate_due" assert r2.params.get("productCode") == "HV-HARNESS" assert r2.params.get("quantity") == 800 def test_intent_fixed_line_not_captured_by_flex(): """固定产线试排/知识库口令不被柔性误吞(隔离性护栏)。""" assert _recognize("跑一版排产").intent == "schedule.run" assert _recognize("试排一版交期优先").intent == "schedule.run" assert _recognize("换线有什么规定").intent == "knowledge.query"