# ============================================================ # 柔性排产引擎 PoolEngine(moduleId: engines-pool, 可重生 ✅, 黄金测试 tests/golden/test_pool_engine.py) # 吸收 demand/ 康尼芜湖方案:设备能力池 + 虚拟产线 + 瓶颈锚 / 正排 / 倒排。 # # 与固定产线 RuleEngine 的本质区别: # RuleEngine:订单 → 选一条固定产线 → 工序绑该线工位 → 占工位槽 # PoolEngine:订单 → 每道工序从"能力池"(拥有该工序能力的设备集合)动态取设备 # → 组装一条"虚拟产线"(VL)→ 占设备槽(含模具换型与设备移动耗时) # # 数据来源:world 的 flex* 键(见 server/state/seed.py 与 docs/product/demand-data-intake.md)。 # 产物:写入 flexScheduleVersions / flexVirtualLines / flexWorkOrders / flexConflicts。 # 权力等级 P1:只改内存 world,落盘由调用方决定。 # ============================================================ from __future__ import annotations # 前向类型引用 from datetime import datetime # 时间类型 from typing import Any, Callable # 类型标注 from server.timeutil import add_minutes, fmt_date, fmt_dt, parse_dt, today0 # 日期工具 World = dict[str, Any] # 世界状态类型别名 # 排产模式 SORT_ASC = "ASC" # 正排:自开始日向后 SORT_DESC = "DESC" # 倒排:自交期向前(本切片用交期升序近似) SORT_BOTTLENECK = "BOTTLENECK" # 瓶颈锚:瓶颈工序优先保障 SORT_KITTING_FIRST = "KITTING_FIRST" # 齐套优先:备料完成订单先排 SORT_SKILL_FIRST = "SKILL_FIRST" # 技能优先:高技能需求订单先排 # 滚动窗口别名 → 默认时长(可被 flexParams.rollingWindows 覆盖) WINDOW_DEFAULTS = {"realtime": "60m", "short": "2h", "mid": "2d", "long": "7d", "full": "30d"} def parse_duration(spec: str | None) -> int: """解析 '2h'/'2d'/'7d'/'120m' → 分钟数。""" if not spec: return 0 s = str(spec).strip().lower() if s.endswith("d"): return int(float(s[:-1]) * 24 * 60) if s.endswith("h"): return int(float(s[:-1]) * 60) if s.endswith("m"): return int(float(s[:-1])) return int(float(s)) def resolve_window_minutes(params: dict, window: str | None) -> int | None: """按 realtime/short/mid/long/full 解析滚动时域分钟;None=不限制(等同 full)。""" if not window or window.upper() in ("FULL", "ALL", "L4"): return None key = window.lower() if key in ("l2",): key = "short" elif key in ("l3", "day"): key = "mid" elif key in ("live", "rt", "minute", "min"): key = "realtime" rw = (params or {}).get("rollingWindows") or {} spec = rw.get(key) or WINDOW_DEFAULTS.get(key) mins = parse_duration(spec) return mins or None def window_step_minutes(window: str | None) -> int: """滚动层级对应的排产步长:实时 1min / 短窗 15min / 中窗 60min / 长窗 4h。""" if not window: return 15 w = window.lower() if w in ("realtime", "live", "rt", "minute", "min"): return 1 if w in ("short", "l2"): return 15 if w in ("mid", "l3", "day"): return 60 if w in ("long",): return 240 return 15 def _kit_ready(order: dict[str, Any]) -> int: status = str(order.get("kitStatus") or "") return 0 if ("已完成" in status or "齐套" in status or status.upper() == "READY") else 1 def _skill_rank(level: Any) -> int: return {"L4": 0, "L3": 1, "L2": 2, "L1": 3}.get(str(level or "L3").upper(), 4) class PoolEngine: """柔性能力池排产引擎:能力池选设备 + 虚拟产线组装 + 瓶颈感知排序。""" name = "FLEX" # 引擎标识 def solve(self, world: World, next_id: Callable[[str], int], sort_mode: str | None = None, order_ids: list[int] | None = None, start_date: str | None = None, name: str | None = None, window: str | None = None, seed_busy: dict[int, list[tuple[datetime, datetime]]] | None = None, enforce_teams: bool | None = None) -> dict[str, Any]: """执行一次柔性排产,返回结果摘要 dict。 Args: world: 世界状态(读 flex* 主数据,写 flex* 产物表) next_id: 发号函数 next_id(kind)->int sort_mode: ASC/DESC/BOTTLENECK/KITTING_FIRST/SKILL_FIRST (None 时取 flexParams.sortMode) order_ids: 目标订单 ID(None/空=全部 RELEASED/CREATED) start_date: 起始日 YYYY-MM-DD(None=明天) name: 版本名 window: 滚动窗 short/mid/long/full(SC-12);限制精排时域与步长 seed_busy: 预占用设备区间(窗外冻结工单,DY-01 L2/L3) enforce_teams: 是否启用班组人力并发约束(SC-11;默认有 flexTeams 则启用) """ params = world.get("flexParams", {}) # 柔性参数 mode = (sort_mode or params.get("sortMode") or SORT_BOTTLENECK).upper() # 排产模式 if mode not in ( SORT_ASC, SORT_DESC, SORT_BOTTLENECK, SORT_KITTING_FIRST, SORT_SKILL_FIRST, ): mode = SORT_BOTTLENECK # 非法回落瓶颈锚 horizon_min = resolve_window_minutes(params, window) step_min = window_step_minutes(window) use_teams = enforce_teams if enforce_teams is not None else bool(world.get("flexTeams")) from server.aps_domain.constraints import is_enabled, material_shortage_severity, profile_snapshot if enforce_teams is None: use_teams = use_teams and is_enabled(world, "C12_team") kit_sev = material_shortage_severity(world) use_tooling = is_enabled(world, "C12_tooling") use_freeze = is_enabled(world, "C11_freeze") # ---- ① 索引主数据 ---- ops_by_code = {o["code"]: o for o in world.get("flexOperations", [])} # 工序索引 equipment = world.get("flexEquipment", []) # 设备清单 molds = world.get("flexMolds", []) # 模具清单 routings = world.get("flexRoutings", []) # 工艺路线 mats_by_code = {m["code"]: m for m in world.get("flexMaterials", [])} # 物料索引 bom = world.get("flexBom", []) # BOM team_cap = self._team_capacity(world) if use_teams else {} # 工序→可用人数 # ---- ② 收集待排订单 ---- orders = [] for o in world.get("flexOrders", []): if order_ids and o["id"] not in order_ids: # 指定集过滤 continue if o["status"] not in ("RELEASED", "CREATED"): # 仅未完成 continue orders.append(o) # ---- ③ 派工排序(吸收排产逻辑 PPT:正排 EDD / 倒排最晚优先 / 瓶颈锚)---- if mode == SORT_DESC: # 倒排:交期最晚的订单先占资源(自交期向前的派工近似) orders.sort(key=lambda o: (o["dueDate"], o["priority"]), reverse=True) elif mode == SORT_BOTTLENECK: # 瓶颈锚:含瓶颈工序的产品订单优先(保障瓶颈资源不被非瓶颈单挤占),再 EDD orders.sort(key=lambda o: (0 if self._has_bottleneck(o["productCode"], routings, ops_by_code) else 1, o["dueDate"], o["priority"])) elif mode == SORT_KITTING_FIRST: orders.sort(key=lambda o: (_kit_ready(o), o["dueDate"], o["priority"])) elif mode == SORT_SKILL_FIRST: orders.sort(key=lambda o: (_skill_rank(o.get("requiredSkillLevel")), o["dueDate"], o["priority"])) else: # 正排:EDD(最早交期优先)+ 优先级 orders.sort(key=lambda o: (o["dueDate"], o["priority"])) # ---- ④ 版本对象 ---- wall_now = datetime.now() business_day = parse_dt(start_date + " 00:00") if start_date else today0() now = business_day.replace( hour=wall_now.hour, minute=wall_now.minute, second=wall_now.second, microsecond=wall_now.microsecond, ) vid = next_id("flexScheduleVersion") base_start = (parse_dt(start_date + " 08:00") if start_date else add_minutes(today0(), 24 * 60)) # 默认明天 horizon_end = add_minutes(base_start, horizon_min) if horizon_min else None version = { "id": vid, "versionNo": "FV" + fmt_date(now).replace("-", "") + f"-{len(world['flexScheduleVersions']) + 1:03d}", "versionName": name or f"柔性排产 {fmt_dt(now)}", "sortMode": mode, "engineType": "FLEX", "status": "DRAFT", "window": window or "full", "windowMinutes": horizon_min, "stepMinutes": step_min, "enforceTeams": use_teams, "orderCount": len(orders), "vlCount": 0, "woCount": 0, "totalTardiness": 0.0, "avgUtilization": 0.0, "conflictCount": 0, "bottleneck": self._bottleneck_summary(routings, ops_by_code, equipment), "constraintProfile": profile_snapshot(world), "createdBy": "agent", "createdAt": fmt_dt(now), } world["flexScheduleVersions"].append(version) # ---- ⑤ 占用登记(可注入窗外冻结占槽)---- eq_busy: dict[int, list[tuple[datetime, datetime]]] = { k: list(v) for k, v in (seed_busy or {}).items() } eq_last_mold: dict[int, str | None] = {} # 设备 → 上次装的模具(判换型) eq_used_min: dict[int, float] = { k: sum((e - s).total_seconds() / 60 for s, e in ivs) for k, ivs in eq_busy.items() } team_busy: dict[str, list[tuple[datetime, datetime]]] = {} # 工序 → 人力占用区间 conflicts: list[dict] = [] new_wo_ids: list[int] = [] deferred = 0 # ---- ⑥ 逐订单组装虚拟产线 ---- for o in orders: product_steps = self._routing_of(o["productCode"], routings) # 该产品工艺步骤(有序) if not product_steps: conflicts.append({"conflictType": "NO_ROUTING", "severity": "CRITICAL", "resourceType": "ROUTING", "orderNo": o["orderNo"], "description": f"{o['orderNo']} 产品 {o['productCode']} 无工艺路线", "suggestedSolution": "维护柔性工艺路线"}) continue # 物料齐套检查(SC-04:可关/软硬) material_ready = base_start if kit_sev: for b in [x for x in bom if x["productCode"] == o["productCode"]]: mat = mats_by_code.get(b["materialCode"]) if not mat: continue need = b["quantity"] * o["quantity"] if mat["stock"] < need and mat["stock"] + mat["inTransit"] >= need: eta = add_minutes(today0(), 3 * 24 * 60) # 在途补货 +3 天 material_ready = max(material_ready, eta) elif mat["stock"] + mat["inTransit"] < need: conflicts.append({"conflictType": "MATERIAL_SHORTAGE", "severity": kit_sev, "resourceType": "MATERIAL", "resourceName": mat["name"], "orderNo": o["orderNo"], "description": f"{o['orderNo']} {mat['name']} 缺料 " f"{int(need - mat['stock'] - mat['inTransit'] + 0.999)} {mat['unit']}", "suggestedSolution": "紧急采购或启用替代物料"}) # SC-12:滚动短/中窗 — 首道工序若已超出时域则整单延期到下一滚动周期 if use_freeze and horizon_end and material_ready >= horizon_end: deferred += 1 conflicts.append({"conflictType": "WINDOW_DEFERRED", "severity": "MINOR", "resourceType": "WINDOW", "orderNo": o["orderNo"], "description": f"{o['orderNo']} 超出滚动窗" f"({window or 'custom'}={horizon_min}min),本轮不排", "suggestedSolution": "扩大滚动窗或等待下一滚动周期"}) continue # Per-order atomic boundary: roll back all artifacts and resource state until the full routing succeeds. order_wo_start = len(world["flexWorkOrders"]) order_vl_start = len(world["flexVirtualLines"]) version_wo_before = version["woCount"] version_vl_before = version["vlCount"] eq_busy_before = {key: list(intervals) for key, intervals in eq_busy.items()} eq_last_mold_before = dict(eq_last_mold) eq_used_min_before = dict(eq_used_min) team_busy_before = {key: list(intervals) for key, intervals in team_busy.items()} mold_before = [(mold, mold.get("lifeUsed"), mold.get("status")) for mold in molds] conflict_boundary = len(conflicts) vl_id = next_id("flexVirtualLine") vl = { "id": vl_id, "vlNo": f"VL-{o['orderNo']}", "versionId": vid, "orderNo": o["orderNo"], "productCode": o["productCode"], "quantity": o["quantity"], "wbs": o.get("wbs"), "productionController": o.get("productionController"), "assignments": [], "plannedStart": None, "plannedEnd": None, } cursor = max(base_start, material_ready) # 排产游标 vl_start = None vl_end = cursor ok = True for step in product_steps: # 逐工序占设备 op = ops_by_code.get(step["operationCode"]) need_mold = bool(step.get("requireMold", False)) and use_tooling op_code = step["operationCode"] # 能力池:拥有该工序能力且状态可用的设备 pool = [e for e in equipment if op_code in e.get("capabilities", []) and e.get("status") == "RUNNING"] if not pool: conflicts.append({"conflictType": "NO_CAPABILITY", "severity": "CRITICAL", "resourceType": "EQUIPMENT", "orderNo": o["orderNo"], "description": f"{o['orderNo']} 工序 {op_code} 无能力设备", "suggestedSolution": "为设备登记该工序能力或采购设备"}) ok = False break # SC-11:无班组覆盖该工序 → 冲突(有班组数据时) if use_teams and team_cap and op_code not in team_cap: conflicts.append({"conflictType": "NO_TEAM", "severity": "CRITICAL", "resourceType": "TEAM", "orderNo": o["orderNo"], "description": f"{o['orderNo']} 工序 {op_code} 无班组资格覆盖", "suggestedSolution": "为班组登记 supportOps 或外包该工序"}) ok = False break # 选模具(若需要):按工序取可用且有适配设备的模具 chosen_mold = None if need_mold: cands = [m for m in molds if m["operationCode"] == op_code and m.get("status") == "AVAILABLE" and m["lifeUsed"] < m["lifeTotal"]] if not cands: conflicts.append({"conflictType": "NO_MOLD", "severity": "CRITICAL", "resourceType": "MOLD", "orderNo": o["orderNo"], "description": f"{o['orderNo']} 工序 {op_code} 无可用模具", "suggestedSolution": "更换/采购模具或解除锁定"}) ok = False break chosen_mold = cands[0] # MVP:取第一副可用模具 # 池收敛到能装该模具的设备 pool = [e for e in pool if e["code"] in chosen_mold["adaptableEquipment"]] or pool # 选设备:能力池内取累计占用最少者(负载均衡 → 虚拟产线动态组装) chosen = min(pool, key=lambda e: eq_used_min.get(e["id"], 0.0)) # 工时 = 单件工时 × 数量 / 可动率;单件工时优先取路线覆盖,否则设备默认 per = step.get("stdTimePerUnit") or chosen.get("opStdTime", {}).get(op_code, 1) run_min = (per * o["quantity"]) / (chosen.get("availabilityRate") or 1) # 换型:换模具或工序自身换型时间 changeover = op.get("changeoverMin", 0) if op else 0 if need_mold and eq_last_mold.get(chosen["id"]) != chosen_mold["code"]: changeover = max(changeover, chosen_mold.get("changeoverMin", 0)) # 设备移动:可移动设备加一次移动耗时 move_min = chosen.get("moveTimeMin", 0) if chosen.get("movable") else 0 duration = changeover + move_min + run_min start, end = self._place( cursor, duration, chosen["id"], eq_busy, world, step_min=step_min, horizon_end=horizon_end, team_op=op_code if use_teams else None, team_busy=team_busy, team_capacity=team_cap.get(op_code, 0) if use_teams else 0, ) # A routing that crosses the rolling window fails as a whole; no partial work orders survive. if use_freeze and horizon_end and start >= horizon_end: conflicts.append({"conflictType": "WINDOW_TRUNCATED", "severity": "MINOR", "resourceType": "WINDOW", "orderNo": o["orderNo"], "description": f"{o['orderNo']} 工序 {op_code} 越出滚动窗,后续步骤本轮不排", "suggestedSolution": "下一滚动周期继续排或升窗到 mid/long"}) deferred += 1 ok = False break wo_id = next_id("flexWorkOrder") wo = { "id": wo_id, "orderNo": f"{o['orderNo']}-{step['seq']}", "versionId": vid, "vlId": vl_id, "flexOrderNo": o["orderNo"], "productCode": o["productCode"], "quantity": o["quantity"], "operationCode": op_code, "operationName": op["name"] if op else op_code, "seq": step["seq"], "equipmentId": chosen["id"], "equipmentCode": chosen["code"], "equipmentName": chosen["name"], "moldCode": chosen_mold["code"] if chosen_mold else None, "zone": chosen.get("zone"), "teamCode": self._pick_team(world, op_code), "changeoverMin": round(changeover, 1), "moveMin": round(move_min, 1), "runMin": round(run_min, 1), "plannedStartTime": fmt_dt(start), "plannedEndTime": fmt_dt(end), "isBottleneck": bool(op and op.get("isBottleneck")), "status": "PENDING", "frozen": False, } world["flexWorkOrders"].append(wo) new_wo_ids.append(wo_id) vl["assignments"].append({ "seq": step["seq"], "operationCode": op_code, "equipmentCode": chosen["code"], "moldCode": wo["moldCode"], "start": wo["plannedStartTime"], "end": wo["plannedEndTime"], }) # 登记占用/负载/模具/人力/游标 eq_busy.setdefault(chosen["id"], []).append((start, end)) eq_used_min[chosen["id"]] = eq_used_min.get(chosen["id"], 0.0) + (end - start).total_seconds() / 60 if use_teams and op_code: team_busy.setdefault(op_code, []).append((start, end)) if chosen_mold: eq_last_mold[chosen["id"]] = chosen_mold["code"] # SC-10 / MD-07:排产占用消耗模具寿命;到限自动锁定 chosen_mold["lifeUsed"] = int(chosen_mold.get("lifeUsed", 0)) + int(o["quantity"]) if chosen_mold["lifeUsed"] >= int(chosen_mold.get("lifeTotal") or 0): chosen_mold["status"] = "LOCKED" conflicts.append({ "conflictType": "MOLD_LIFE", "severity": "MAJOR", "resourceType": "MOLD", "resourceName": chosen_mold["code"], "orderNo": o["orderNo"], "description": f"模具 {chosen_mold['code']} 寿命已用尽" f"({chosen_mold['lifeUsed']}/{chosen_mold['lifeTotal']}),已锁定", "suggestedSolution": "更换模具或重置寿命后解除锁定", }) cursor = end # 下一工序自本工序结束起(工序 precedence) vl_start = vl_start or start vl_end = end version["woCount"] += 1 if not ok: # Roll back candidate artifacts and mutable capacity/tooling state for this order. del world["flexWorkOrders"][order_wo_start:] del world["flexVirtualLines"][order_vl_start:] version["woCount"] = version_wo_before version["vlCount"] = version_vl_before eq_busy = eq_busy_before eq_last_mold = eq_last_mold_before eq_used_min = eq_used_min_before team_busy = team_busy_before for mold, life_used, status in mold_before: mold["lifeUsed"] = life_used mold["status"] = status conflicts[conflict_boundary:] = [ conflict for conflict in conflicts[conflict_boundary:] if conflict.get("conflictType") != "MOLD_LIFE" ] continue if not vl["assignments"]: # Defensive rollback: an empty candidate can never become a production/MES object. del world["flexWorkOrders"][order_wo_start:] version["woCount"] = version_wo_before eq_busy = eq_busy_before eq_last_mold = eq_last_mold_before eq_used_min = eq_used_min_before team_busy = team_busy_before for mold, life_used, status in mold_before: mold["lifeUsed"] = life_used mold["status"] = status continue vl["plannedStart"] = fmt_dt(vl_start) if vl_start else None vl["plannedEnd"] = fmt_dt(vl_end) world["flexVirtualLines"].append(vl) version["vlCount"] += 1 # 交期延迟冲突 due = parse_dt(o["dueDate"] + " 18:00") if vl_end > due: hours = (vl_end - due).total_seconds() / 3600 version["totalTardiness"] += hours conflicts.append({"conflictType": "DELAY", "severity": "MAJOR", "resourceType": "TIME", "orderNo": o["orderNo"], "description": f"{o['orderNo']} 预计完成晚于交期 {hours:.1f} 小时", "suggestedSolution": "提高优先级/增派瓶颈设备/分批"}) # ---- ⑦ 冲突入库 ---- for cf in conflicts: cf["id"] = next_id("flexConflict") cf["versionId"] = vid cf["isResolved"] = False world["flexConflicts"].append(cf) version["conflictCount"] = len(conflicts) version["deferredCount"] = deferred # Recompute counters from committed rows so failed candidates can never drift version metadata. version["vlCount"] = sum( 1 for vl in world["flexVirtualLines"] if vl.get("versionId") == vid ) version["woCount"] = sum( 1 for wo in world["flexWorkOrders"] if wo.get("versionId") == vid ) # ---- ⑧ 设备利用率(瓶颈产能法的观测基础)---- version["avgUtilization"] = self._utilization(world, vid, base_start, params.get("afterDays", 30)) # ---- ⑨ makespan(最晚完工)与准时单数 ---- my_vls = [v for v in world["flexVirtualLines"] if v["versionId"] == vid] makespan = max((v["plannedEnd"] for v in my_vls), default=None) on_time = 0 for v in my_vls: o = next((x for x in world["flexOrders"] if x["orderNo"] == v["orderNo"]), None) if o and v["plannedEnd"] and parse_dt(v["plannedEnd"]) <= parse_dt(o["dueDate"] + " 18:00"): on_time += 1 version["makespan"] = makespan version["onTimeCount"] = on_time return { "versionId": vid, "versionNo": version["versionNo"], "engineType": "FLEX", "sortMode": mode, "status": "DRAFT", "window": version["window"], "windowMinutes": horizon_min, "stepMinutes": step_min, "enforceTeams": use_teams, "deferredCount": deferred, "orderCount": version["orderCount"], "vlCount": version["vlCount"], "woCount": version["woCount"], "conflictCount": version["conflictCount"], "totalTardiness": version["totalTardiness"], "avgUtilization": version["avgUtilization"], "bottleneck": version["bottleneck"], "makespan": makespan, "onTimeCount": on_time, } # ---------------- 占槽:设备级 + 可选班组并发(SC-11) ---------------- def _place(self, cursor: datetime, duration_min: float, eq_id: int, eq_busy: dict[int, list[tuple[datetime, datetime]]], world: World, step_min: int = 15, horizon_end: datetime | None = None, team_op: str | None = None, team_busy: dict[str, list[tuple[datetime, datetime]]] | None = None, team_capacity: int = 0) -> tuple[datetime, datetime]: """在设备时间线上找首个不与已占区间重叠的起点;可选班组并发上限。""" shifts = world.get("flexCalendar", []) workdays = shifts[0].get("workdays", [1, 2, 3, 4, 5]) if shifts else [1, 2, 3, 4, 5] intervals = eq_busy.get(eq_id, []) t = cursor max_steps = max(30 * 24 * 4, int((30 * 24 * 60) / max(step_min, 1))) for _ in range(max_steps): if horizon_end and t >= horizon_end: break if (t.isoweekday() in workdays) and self._in_shift(t, shifts): end = add_minutes(t, duration_min) overlap = any(t < iv_end and end > iv_start for iv_start, iv_end in intervals) team_ok = True if team_op and team_capacity > 0 and team_busy is not None: concurrent = sum(1 for s, e in team_busy.get(team_op, []) if t < e and end > s) team_ok = concurrent < team_capacity if not overlap and team_ok: return (t, end) t = add_minutes(t, step_min) return (cursor, add_minutes(cursor, duration_min)) # 兜底硬排 @staticmethod def _team_capacity(world: World) -> dict[str, int]: """工序 → 可用班组人数合计(supportOps 覆盖)。""" cap: dict[str, int] = {} for team in world.get("flexTeams", []) or []: n = int(team.get("memberCount") or 0) for op in team.get("supportOps") or []: cap[op] = cap.get(op, 0) + n return cap @staticmethod def _pick_team(world: World, op_code: str) -> str | None: """为工序挑一个覆盖班组编码(展示用)。""" for team in world.get("flexTeams", []) or []: if op_code in (team.get("supportOps") or []): return team.get("code") return None @staticmethod def _in_shift(t: datetime, shifts: list[dict]) -> bool: """t 是否落在任一班次工作时段内(扣除休息段)。""" if not shifts: return True for sh in shifts: s = datetime.strptime(sh["startTime"], "%H:%M").time() e = datetime.strptime(sh["endTime"], "%H:%M").time() if s <= t.time() < e: for bp in sh.get("breaks", []): bs = datetime.strptime(bp["start"], "%H:%M").time() be = datetime.strptime(bp["end"], "%H:%M").time() if bs <= t.time() < be: return False # 落在休息段 return True return False # ---------------- 辅助 ---------------- @staticmethod def _routing_of(product_code: str, routings: list[dict]) -> list[dict]: """取产品工艺步骤(按 seq 升序)。""" steps = [r for r in routings if r["productCode"] == product_code] return sorted(steps, key=lambda r: r["seq"]) def _has_bottleneck(self, product_code: str, routings: list[dict], ops_by_code: dict) -> bool: """产品是否含瓶颈工序。""" for s in self._routing_of(product_code, routings): op = ops_by_code.get(s["operationCode"]) if op and op.get("isBottleneck"): return True return False @staticmethod def _bottleneck_summary(routings: list[dict], ops_by_code: dict, equipment: list[dict]) -> list[dict]: """瓶颈工序清单及其能力池设备数(瓶颈产能法的观测口径)。""" out = [] seen = set() for r in routings: code = r["operationCode"] if code in seen: continue op = ops_by_code.get(code) if op and op.get("isBottleneck"): seen.add(code) pool_n = sum(1 for e in equipment if code in e.get("capabilities", [])) out.append({"operationCode": code, "operationName": op["name"], "poolEquipmentCount": pool_n}) return out @staticmethod def _utilization(world: World, vid: int, base_start: datetime, horizon_days: int) -> float: """本版本设备平均利用率(占用分钟 ÷ 展望期内单班可用分钟)。""" wos = [w for w in world["flexWorkOrders"] if w["versionId"] == vid] if not wos: return 0.0 used: dict[int, float] = {} for w in wos: dur = (parse_dt(w["plannedEndTime"]) - parse_dt(w["plannedStartTime"])).total_seconds() / 60 used[w["equipmentId"]] = used.get(w["equipmentId"], 0.0) + dur # 单班有效分钟 ≈ 8h(480min),工作日按展望期内 5/7 估算 avail_per_eq = 480 * max(1, int(horizon_days * 5 / 7)) utils = [min(u / avail_per_eq, 1.5) for u in used.values()] return round(sum(utils) / len(utils), 4) if utils else 0.0