# ============================================================ # CP-SAT 排产引擎(moduleId: engines-cp, SC-03 首切片,可重生 ✅) # OR-Tools:工序级 Interval / NoOverlap 模型(方向 D 升级): # - 每订单每道工序一个 OptionalIntervalVar,工艺路线串联(C1) # - 同工位/设备工序 NoOverlap(C2);同线不同工位可并行(流水) # - 换型矩阵在产线工序序列间计入 setup(C10,next-link 路径) # - 冻结窗内已发布/冻结工单作为固定障碍(C11) # - Cumulative 聚合容量(C12):同一班组/工装在时间上并行的工序占用总和 # ≤ 可用容量(AddCumulative;teamId/toolingId 挂在工位上,容量取班组/ # 工装主数据可用数量,personnel/tooling 开关控制启用) # 物化仍走 RuleEngine 班次占槽(诚实留痕 placement=shift-slot)。 # ============================================================ from __future__ import annotations from collections.abc import Callable from typing import Any from server.contracts import ScheduleResult from server.engines.base import EngineParams from server.engines.queries import ( find_product_lines, find_routing_steps, find_workstation_for_operation, ) from server.engines.rule_engine import RuleEngine from server.timeutil import add_minutes, fmt_date, parse_dt, today0 World = dict[str, Any] def _job_duration_min(world: World, item: dict, line: dict) -> int: """连续时间估算:工艺总分钟(含准备与转移/等待;不含跨族矩阵,物化阶段再加)。""" eff = float(line.get("efficiencyFactor") or 1.0) or 1.0 total = 0.0 for step in find_routing_steps(world, item["productId"]): total += float(step["setupTime"]) + (float(item["quantity"]) * float(step["runTimePerUnit"])) / eff total += float(step.get("transferTime") or 0) + float(step.get("waitTime") or 0) return max(1, round(total)) def _operation_duration_min(step: dict, item: dict, eff: float) -> int: """单工序连续分钟:准备 + 数量×单件/效率(不含转移/等待间隙)。""" dur = float(step["setupTime"]) + (float(item["quantity"]) * float(step["runTimePerUnit"])) / eff return max(1, round(dur)) def _operation_gap_min(step: dict) -> int: """工序后置间隙分钟:转移 + 等待(工序串联的间隔)。""" return max(0, round(float(step.get("transferTime") or 0) + float(step.get("waitTime") or 0))) def _line_by_id(world: World, line_id: int) -> dict | None: """取产线对象;缺失返回 None。""" return next((l for l in world["lines"] if l["id"] == line_id), None) def _family_of_product(world: World, product_id: int) -> str | None: """产品族(换型矩阵键)。""" from server.aps_domain.changeover import family_of_product return family_of_product(world, product_id) def _changeover_setup_min(world: World, from_family: str | None, to_family: str | None) -> int: """换型矩阵分钟:同族 / 无前序为 0;缺矩阵条目走默认跨族分钟。""" from server.aps_domain.changeover import lookup_setup_minutes return max(0, round(lookup_setup_minutes(world, from_family, to_family))) def _candidate_options(world: World, item: dict, line: dict) -> list[dict] | None: """某产品在指定产线上逐工序展开:绑定工位并估算工时。 任一工序在该线缺可用工位 → 返回 None(CP 不选此线;物化阶段报 NO_WORKSTATION)。 无工艺路线 → None(走哑元,物化阶段报 NO_LINE/NO_ROUTING)。 """ steps = find_routing_steps(world, item["productId"]) if not steps: return None eff = float(line.get("efficiencyFactor") or 1.0) or 1.0 specs: list[dict] = [] for step in steps: ws = find_workstation_for_operation(world, int(line["id"]), int(step["operationId"])) if ws is None: return None specs.append({ "step": step, "operationId": int(step["operationId"]), "workstationId": int(ws["id"]), "teamId": ws.get("teamId"), # C12:工位所属班组(累计资源) "toolingId": ws.get("toolingId"), # C12:工位占用工装(累计资源) "dur": _operation_duration_min(step, item, eff), "gap": _operation_gap_min(step), "setupMin": round(float(step["setupTime"])), }) return specs def _team_capacity_by_id(world: World) -> dict[int, int]: """固定轨班组主数据 → 班组可用人数(C12_team 累计资源容量)。 可用数量取 availableCount,缺省回退 memberCount;<=0 视为未接线(不建约束)。 """ caps: dict[int, int] = {} for team in world.get("teams") or []: cap = int(team.get("availableCount") or team.get("memberCount") or 0) if cap > 0: caps[int(team["id"])] = cap return caps def _tooling_capacity_by_id(world: World) -> dict[int, int]: """固定轨工装主数据 → 工装可用数量(C12_tooling 累计资源容量)。 可用数量取 availableCount,缺省回退 quantity/count;<=0 视为未接线。 """ caps: dict[int, int] = {} for tooling in world.get("toolings") or []: cap = int(tooling.get("availableCount") or tooling.get("quantity") or tooling.get("count") or 0) if cap > 0: caps[int(tooling["id"])] = cap return caps def _resource_code(world: World, kind: str, resource_id: int) -> str | None: """班组/工装主数据编码(solverMeta 展示用)。""" table = world.get("teams" if kind == "team" else "toolings") or [] for row in table: if int(row.get("id", -1)) == resource_id: return str(row.get("code") or "") return None def _peak_overlap(intervals: list[tuple[int, int]]) -> int: """区间集 [start, end) 的最大同时重叠数(端点相切不算重叠)。""" if not intervals: return 0 events: list[tuple[int, int]] = [] for s, e in intervals: events.append((s, 1)) events.append((e, -1)) events.sort(key=lambda ev: (ev[0], ev[1])) # 同点先出后进 → 相切不算重叠 cur = peak = 0 for _, d in events: cur += d peak = max(peak, cur) return peak def _collect_frozen_obstacles(world: World, anchor, freeze_min: int, horizon: int) -> list[dict]: """冻结窗 [anchor, anchor+freeze_min] 内已发布/已冻结工单 → 固定障碍区间。 返回区间以 anchor 为 0 点的分钟(裁剪到 [0, horizon]);窗内工单不可重排, CP 只把它们当作不可重叠的固定障碍。 """ published = {v["id"] for v in world["scheduleVersions"] if v.get("status") == "PUBLISHED"} out: list[dict] = [] for wo in world["workOrders"]: frozen = bool(wo.get("isFrozen")) or (wo.get("schedulingVersionId") in published) if not frozen or wo.get("workstationId") is None: continue try: ws_start = parse_dt(wo["plannedStartTime"]) ws_end = parse_dt(wo["plannedEndTime"]) except (KeyError, TypeError, ValueError): continue s_min = (ws_start - anchor).total_seconds() / 60.0 e_min = (ws_end - anchor).total_seconds() / 60.0 if e_min <= 0 or s_min >= freeze_min: continue # 完全落在窗外 s_int = max(0, round(s_min)) e_int = min(horizon, max(s_int + 1, round(e_min))) if e_int <= s_int: continue out.append({ "orderNo": wo.get("orderNo") or "", "workstationId": int(wo["workstationId"]), "lineId": wo.get("lineId"), "startMin": s_int, "endMin": e_int, "durationMin": e_int - s_int, }) return out def _due_minutes(so: dict, base_start, due_buffer: float) -> int: due = parse_dt(so["deliveryDate"] + " 18:00") if due_buffer < 1.0 - 1e-9: span = (due - base_start).total_seconds() / 60.0 if span > 0: due = add_minutes(base_start, span * due_buffer) mins = int((due - base_start).total_seconds() / 60.0) return max(0, mins) def build_rule_warm_start( world: World, entries: list[dict], params: EngineParams, ) -> list[dict[str, int]]: """按 RULE 启发式选线,构造连续时间上的贪心起止(供 CP AddHint)。""" _ = params # 与 CP 共用签名;当前 hint 不依赖缓冲/冻结(物化阶段再应用) cursor_by_line: dict[int, int] = {} hints: list[dict[str, int]] = [] for entry in entries: item = entry["item"] opts = find_product_lines(world, item["productId"]) if not opts: hints.append({"lineId": -1, "start": 0, "end": 1}) continue # 与 RULE 默认一致:优先级最高的产线 line_id = int(opts[0]["lineId"]) line = next(l for l in world["lines"] if l["id"] == line_id) dur = _job_duration_min(world, item, line) start = cursor_by_line.get(line_id, 0) end = start + dur hints.append({"lineId": line_id, "start": start, "end": end}) cursor_by_line[line_id] = end return hints def optimize_line_assignment( world: World, entries: list[dict], params: EngineParams, *, warm_start: list[dict[str, int]] | None = None, pipeline_label: str | None = None, ) -> tuple[list[dict], dict[str, Any]]: """ CP-SAT 工序级模型:每单一条可行产线;逐工序 IntervalVar 按工艺路线串联(C1), 同工位/设备 NoOverlap(C2),换型矩阵 setup 计入产线工序序列(C10), 冻结窗内已排工单固定为障碍(C11);最小化加权延期。 返回:带 forcedLineId 的重排序条目 + solverMeta(含 operationSlots)。 warm_start:可选 RULE 初解 hint(lineId/start/end)。 """ try: from ortools.sat.python import cp_model except ImportError as exc: # pragma: no cover raise RuntimeError( "未安装 ortools,无法运行 CP-SAT。请执行:pip install ortools" ) from exc from server.aps_domain.params import level_weight as _level_w n = len(entries) meta: dict[str, Any] = { "backend": "OR-Tools CP-SAT", "placement": "shift-slot", "model": "operation-level+no-overlap+changeover+freeze+cumulative", } if pipeline_label: meta["pipeline"] = pipeline_label if warm_start: meta["warmStart"] = "RULE" if n == 0: meta.update({"status": "TRIVIAL", "wallTimeSec": 0.0, "gap": 0.0, "objective": 0}) return entries, meta freeze_hours = float(params.freezeWindowHours) if params.freezeWindowHours is not None else 0.0 freeze_min = max(0, int(freeze_hours * 60)) anchor = ( parse_dt(params.startDate + " 08:00") if params.startDate else add_minutes(today0(), 24 * 60) ) base_start = add_minutes(anchor, freeze_min) if freeze_min > 0 else anchor due_buffer = ( 1.0 if params.deliveryBufferRatio is None else max(0.5, min(1.0, float(params.deliveryBufferRatio))) ) horizon_days = int(params.planningHorizonDays or 14) # 连续时间上界:展望期×每日分钟 + 余量(忽略班次空隙的乐观模型) horizon = max(horizon_days * 24 * 60 * 2, 7 * 24 * 60) changeover_enabled = bool(params.constraints.get("changeover", True)) # C12 聚合容量开关:params.constraints 为旧接口(personnel/tooling), # 世界约束剖面 C12_team/C12_tooling 为权威开关(两者都开才启用) from server.aps_domain.constraints import is_enabled as _constraint_enabled team_cum_enabled = bool(params.constraints.get("personnel", True)) and _constraint_enabled(world, "C12_team") tooling_cum_enabled = bool(params.constraints.get("tooling", True)) and _constraint_enabled(world, "C12_tooling") team_caps = _team_capacity_by_id(world) if team_cum_enabled else {} tooling_caps = _tooling_capacity_by_id(world) if tooling_cum_enabled else {} # ---- 预计算工序级候选:每单每条产线 = 工序序列(工位/工时/间隙) ---- job_opts: list[list[dict]] = [] # [j][k] = {lineId, specs, family} dues: list[int] = [] weights: list[int] = [] for entry in entries: so, item = entry["so"], entry["item"] family = _family_of_product(world, item["productId"]) opts: list[dict] = [] for lp in find_product_lines(world, item["productId"]): line = _line_by_id(world, int(lp["lineId"])) if line is None: continue specs = _candidate_options(world, item, line) if specs is None: continue # 缺工位/无工艺:该线不作为候选(物化阶段报 NO_WORKSTATION/NO_ROUTING) opts.append({"lineId": int(lp["lineId"]), "specs": specs, "family": family}) if not opts: # 无合格产线:仍参与序,物化阶段报 NO_LINE/NO_WORKSTATION dur = _job_duration_min(world, item, {"efficiencyFactor": 1.0}) opts = [{"lineId": -1, "specs": [{ "operationId": None, "workstationId": None, "dur": dur, "gap": 0, "setupMin": 0, }], "family": family}] job_opts.append(opts) dues.append(_due_minutes(so, base_start, due_buffer)) w = round(_level_w(world, so.get("customerLevel")) * 100) if so.get("isRush"): w += 200 if so.get("isForecast"): w = max(1, w // 2) weights.append(max(1, w)) # ---- 建模 ---- model = cp_model.CpModel() intervals_by_ws: dict[int, list] = {} cum_ivs: dict[tuple[str, int], list[dict[str, Any]]] = {} # (kind,id) -> 工序区间 unwired: dict[tuple[str, int], int] = {} # (kind,id) -> 引用次数 job_start: list = [] job_end: list = [] chosen_line_vars: list[list] = [] pres_by_line: list[dict[int, Any]] = [] # [j][lineId] -> pres bool var first_start_var: list[dict[int, Any]] = [] # [j][lineId] -> 首工序 start first_end_var: list[dict[int, Any]] = [] # [j][lineId] -> 首工序 end last_end_var: list[dict[int, Any]] = [] # [j][lineId] -> 末工序 end op_start_by_line: list[dict[int, list]] = [] # [j][lineId] -> [start...] op_end_by_line: list[dict[int, list]] = [] op_meta_by_line: list[dict[int, list]] = [] families: list[str | None] = [] for j in range(n): pres_j: list[Any] = [] line_ids_j: list[int] = [] js = model.NewIntVar(0, horizon, f"js{j}") je = model.NewIntVar(0, horizon, f"je{j}") fsj, fej, lej, plj = {}, {}, {}, {} oss, oes, oms = {}, {}, {} for k, opt in enumerate(job_opts[j]): pres = model.NewBoolVar(f"j{j}_k{k}") pres_j.append(pres) lid = int(opt["lineId"]) line_ids_j.append(lid) plj[lid] = pres specs = opt["specs"] if lid < 0: # 哑元:零冲突占位 st = model.NewIntVar(0, horizon, f"s{j}_{k}") en = model.NewIntVar(0, horizon, f"e{j}_{k}") model.Add(st == 0).OnlyEnforceIf(pres) model.Add(en == specs[0]["dur"]).OnlyEnforceIf(pres) fsj[lid], fej[lid], lej[lid] = st, en, en oss[lid], oes[lid] = [st], [en] oms[lid] = [{"sequenceNo": 0, "operationId": None, "workstationId": None, "setupMin": 0, "durMin": specs[0]["dur"], "gapMin": 0}] else: sts: list[Any] = [] ens: list[Any] = [] metas: list[dict] = [] for s, spec in enumerate(specs): st = model.NewIntVar(0, horizon, f"s{j}_{k}_{s}") en = model.NewIntVar(0, horizon, f"e{j}_{k}_{s}") iv = model.NewOptionalIntervalVar(st, spec["dur"], en, pres, f"iv{j}_{k}_{s}") if s > 0: # C1 工艺先后序:前道完成 + 转移/等待 才能开下一道 model.Add(st >= ens[s - 1] + specs[s - 1]["gap"]).OnlyEnforceIf(pres) sts.append(st) ens.append(en) metas.append({ "sequenceNo": int(spec["step"]["sequenceNo"]), "operationId": spec["operationId"], "workstationId": spec["workstationId"], "teamId": spec.get("teamId"), "toolingId": spec.get("toolingId"), "setupMin": spec["setupMin"], "durMin": spec["dur"], "gapMin": spec["gap"], }) # C2 同工位/设备工序 NoOverlap(跨订单聚合) intervals_by_ws.setdefault(spec["workstationId"], []).append(iv) # C12 Cumulative 聚合容量:班组/工装共享容量,并行占用总和 ≤ 可用容量 if team_cum_enabled: _tid = spec.get("teamId") if _tid is not None: _tid = int(_tid) if _tid in team_caps: cum_ivs.setdefault(("team", _tid), []).append({ "iv": iv, "st": st, "en": en, "pres": pres, "j": j, "op": spec["operationId"], }) else: unwired[("team", _tid)] = unwired.get(("team", _tid), 0) + 1 if tooling_cum_enabled: _toid = spec.get("toolingId") if _toid is not None: _toid = int(_toid) if _toid in tooling_caps: cum_ivs.setdefault(("tooling", _toid), []).append({ "iv": iv, "st": st, "en": en, "pres": pres, "j": j, "op": spec["operationId"], }) else: unwired[("tooling", _toid)] = unwired.get(("tooling", _toid), 0) + 1 fsj[lid], fej[lid], lej[lid] = sts[0], ens[0], ens[-1] oss[lid], oes[lid], oms[lid] = sts, ens, metas model.Add(js == fsj[lid]).OnlyEnforceIf(pres) model.Add(je == lej[lid]).OnlyEnforceIf(pres) model.Add(sum(pres_j) == 1) job_start.append(js) job_end.append(je) chosen_line_vars.append(list(zip(pres_j, line_ids_j))) pres_by_line.append(plj) first_start_var.append(fsj) first_end_var.append(fej) last_end_var.append(lej) op_start_by_line.append(oss) op_end_by_line.append(oes) op_meta_by_line.append(oms) families.append(job_opts[j][0]["family"]) # ---- C11 冻结窗:新排订单首工序不得早于冻结窗末端 ---- if freeze_min > 0: for j in range(n): for lid in pres_by_line[j]: if lid >= 0: model.Add(first_start_var[j][lid] >= freeze_min).OnlyEnforceIf(pres_by_line[j][lid]) # ---- C2 同工位/设备 NoOverlap ---- for ivs in intervals_by_ws.values(): if len(ivs) >= 2: model.AddNoOverlap(ivs) # ---- C12 Cumulative 聚合容量:班组/工装并行占用总和 ≤ 可用容量 ---- cum_res: list[dict[str, Any]] = [] cum_res_by_key: dict[tuple[str, int], dict[str, Any]] = {} for (kind, rid), ivs in cum_ivs.items(): cap = team_caps[rid] if kind == "team" else tooling_caps[rid] if len(ivs) >= 2: # 可选区间未排中(pres=0)不消耗资源;demand=1(每工序占用 1 单位) model.AddCumulative([x["iv"] for x in ivs], [1] * len(ivs), cap) entry = { "kind": kind, "id": rid, "code": _resource_code(world, kind, rid), "capacity": cap, "intervalCount": len(ivs), "peakConcurrent": None, } cum_res.append(entry) cum_res_by_key[(kind, rid)] = entry meta["cumulative"] = { "enabled": {"team": team_cum_enabled, "tooling": tooling_cum_enabled}, "capacity": {"team": dict(team_caps), "tooling": dict(tooling_caps)}, "resources": cum_res, "unwired": [{"kind": k, "id": i} for (k, i) in sorted(unwired)], } # ---- C11 冻结窗:窗内已排工单固定为障碍 ---- frozen = _collect_frozen_obstacles(world, anchor, freeze_min, horizon) if freeze_min > 0 else [] for obs in frozen: fiv = model.NewFixedSizeIntervalVar( obs["startMin"], obs["durationMin"], f"frozen_{obs['orderNo']}") intervals_by_ws.setdefault(obs["workstationId"], []).append(fiv) for ivs in intervals_by_ws.values(): if len(ivs) >= 2: model.AddNoOverlap(ivs) # ---- C10 换型矩阵:产线工序序列 next-link 路径,相邻订单首工序间计入 setup ---- next_vars_by_line: dict[int, dict[tuple[int, int], Any]] = {} candidates_by_line: dict[int, list[int]] = {} for j in range(n): for lid in pres_by_line[j]: if lid >= 0: candidates_by_line.setdefault(lid, []).append(j) if changeover_enabled: for lid, j_list in candidates_by_line.items(): if len(j_list) < 2: continue next_bools: dict[tuple[int, int], Any] = {} for a in j_list: for b in j_list: if a != b: next_bools[(a, b)] = model.NewBoolVar(f"next_{a}_{b}_l{lid}") next_vars_by_line[lid] = next_bools first_bools = {j: model.NewBoolVar(f"first_{j}_l{lid}") for j in j_list} any_on_line = model.NewBoolVar(f"any_l{lid}") model.AddMaxEquality(any_on_line, [pres_by_line[j][lid] for j in j_list]) for j in j_list: pres = pres_by_line[j][lid] incoming = sum(next_bools[(a, j)] for a in j_list if a != j) outgoing = sum(next_bools[(j, b)] for b in j_list if b != j) # 每个在线上订单恰有一个前驱(含"首个") model.Add(first_bools[j] + incoming == 1).OnlyEnforceIf(pres) model.Add(first_bools[j] + incoming == 0).OnlyEnforceIf(pres.Not()) # 至多一个后继(单链路径) model.Add(outgoing <= 1).OnlyEnforceIf(pres) model.Add(outgoing == 0).OnlyEnforceIf(pres.Not()) # 单链:整条线至多一个"首个" model.Add(sum(first_bools.values()) == any_on_line) # 换型时间:相邻首工序 start >= 前序首工序 end + setup(from→to) for (a, b), nv in next_bools.items(): setup = _changeover_setup_min(world, families[a], families[b]) if setup <= 0: continue model.Add( first_start_var[b][lid] >= first_end_var[a][lid] + setup ).OnlyEnforceIf(nv) # 加权延期 obj_terms = [] for j in range(n): late = model.NewIntVar(0, horizon, f"late{j}") model.Add(late >= job_end[j] - dues[j]) model.Add(late >= 0) obj_terms.append(late * weights[j]) model.Minimize(sum(obj_terms)) # RULE 热启动:仅 hint 选线(不 hint 起止,避免部分版本 fixed_search 崩溃) if warm_start and len(warm_start) == n: for j, hint in enumerate(warm_start): want = int(hint.get("lineId", -1)) for pres, lid in chosen_line_vars[j]: model.AddHint(pres, 1 if lid == want else 0) solver = cp_model.CpSolver() limit = float(params.timeLimitSeconds) if params.timeLimitSeconds is not None else 8.0 solver.parameters.max_time_in_seconds = max(0.5, limit) # 有 hint 时单线程更稳(部分 ortools 在多 worker + hint 下会 check-fail) solver.parameters.num_search_workers = 1 if warm_start else 4 status = solver.Solve(model) status_name = { cp_model.OPTIMAL: "OPTIMAL", cp_model.FEASIBLE: "FEASIBLE", cp_model.INFEASIBLE: "INFEASIBLE", cp_model.MODEL_INVALID: "MODEL_INVALID", cp_model.UNKNOWN: "UNKNOWN", }.get(status, str(status)) wall = round(float(solver.WallTime()), 4) meta["status"] = status_name meta["wallTimeSec"] = wall meta["timeLimitSec"] = limit meta["frozenCount"] = len(frozen) if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE): # gap=None 语义:仅可行性(UNKNOWN/INFEASIBLE)时无 objective/bound, # 无法计算 obj-vs-bound gap,诚实置 None 并保留启发式序(fallback) meta["gap"] = None meta["objective"] = None meta["fallback"] = "heuristic-order" # 诚实:无可行解时保留启发式序;若有 warm_start 则带上强制线 if warm_start and len(warm_start) == n: out = [] for entry, hint in zip(entries, warm_start): e = dict(entry) if int(hint.get("lineId", -1)) >= 0: e["forcedLineId"] = int(hint["lineId"]) out.append(e) return out, meta return entries, meta obj = float(solver.ObjectiveValue()) bound = float(solver.BestObjectiveBound()) meta["objective"] = obj meta["bestBound"] = bound if obj > 1e-9: meta["gap"] = round(abs(obj - bound) / abs(obj), 6) else: meta["gap"] = 0.0 # ---- 读解:选线 + 工序槽位 + 换型来源 + 按首工序开始排序 ---- slots: list[dict] = [] total_changeover = 0.0 enriched: list[tuple[float, int, dict]] = [] for j, entry in enumerate(entries): so, item = entry["so"], entry["item"] lid = None for pres, lid_c in chosen_line_vars[j]: if solver.Value(pres) == 1: lid = lid_c if lid_c >= 0 else None break e = dict(entry) if lid is not None: e["forcedLineId"] = lid # 换型前序(next-link 链上的直接前驱) pred_family: str | None = None if lid is not None and changeover_enabled and lid in next_vars_by_line: for (a, b), nv in next_vars_by_line[lid].items(): if b == j and solver.Value(nv) == 1: pred_family = families[a] break chg = _changeover_setup_min(world, pred_family, families[j]) if lid is not None else 0 if chg > 0: total_changeover += chg if lid is None: js_val = int(solver.Value(job_start[j])) je_val = int(solver.Value(job_end[j])) slots.append({ "orderIndex": j, "orderNo": so["orderNo"], "productId": item["productId"], "lineId": None, "sequenceNo": 0, "operationId": None, "workstationId": None, "startMin": js_val, "endMin": je_val, "durationMin": max(1, je_val - js_val), "setupMin": 0, "changeoverMin": 0, "isFrozen": False, }) enriched.append((float(js_val), j, e)) continue for s, meta_s in enumerate(op_meta_by_line[j][lid]): st_v = int(solver.Value(op_start_by_line[j][lid][s])) en_v = int(solver.Value(op_end_by_line[j][lid][s])) slots.append({ "orderIndex": j, "orderNo": so["orderNo"], "productId": item["productId"], "lineId": lid, "sequenceNo": meta_s["sequenceNo"], "operationId": meta_s["operationId"], "workstationId": meta_s["workstationId"], "startMin": st_v, "endMin": en_v, "durationMin": max(1, en_v - st_v), "setupMin": meta_s["setupMin"], "changeoverMin": chg if s == 0 else 0, "isFrozen": False, }) enriched.append((float(solver.Value(job_start[j])), j, e)) for obs in frozen: slots.append({ "orderIndex": -1, "orderNo": obs["orderNo"], "productId": None, "lineId": obs["lineId"], "sequenceNo": 0, "operationId": None, "workstationId": obs["workstationId"], "startMin": obs["startMin"], "endMin": obs["endMin"], "durationMin": obs["durationMin"], "setupMin": 0, "changeoverMin": 0, "isFrozen": True, }) for (kind, rid), ivs in cum_ivs.items(): present = [] for x in ivs: if solver.Value(x["pres"]) == 1: present.append((int(solver.Value(x["st"])), int(solver.Value(x["en"])))) entry = cum_res_by_key.get((kind, rid)) if entry is not None: entry["peakConcurrent"] = _peak_overlap(present) meta["operationSlots"] = slots meta["totalChangeoverMin"] = round(total_changeover, 1) enriched.sort(key=lambda t: (t[0], t[1])) return [e for _, _, e in enriched], meta def _unavailable_result( world: World, params: EngineParams, next_id: Callable[[str], int], campaign_meta: dict[str, Any], source_count: int, message: str, *, engine_type: str, error_code: str, ) -> ScheduleResult: """Materialize one explicit non-publishable blocker without business work artifacts.""" from datetime import datetime from server.aps_domain.constraints import profile_snapshot from server.timeutil import fmt_dt now = datetime.now() # noqa: DTZ005 version_id = next_id("scheduleVersion") version = { "id": version_id, "versionNo": "V" + fmt_date(now).replace("-", "") + f"-{len(world['scheduleVersions']) + 1:03d}", "versionName": params.name or (f"{engine_type} 求解器阻断 " + fmt_dt(now)), "triggerType": params.triggerType, "engineType": engine_type, "status": "DRAFT", "parentVersionId": world["scheduleVersions"][-1]["id"] if world["scheduleVersions"] else None, "orderCount": source_count, "poCount": 0, "woCount": 0, "totalTardiness": 0.0, "totalCost": 0.0, "avgUtilization": 0.0, "conflictCount": 1, "resolvedCount": 0, "createdBy": "agent", "createdAt": fmt_dt(now), "publishedAt": None, "publishReady": False, "dispatchReady": False, "note": f"solver={engine_type};status=UNAVAILABLE;errorCode={error_code}", "constraintProfile": profile_snapshot(world), "campaign": campaign_meta, "solverMeta": { "backend": "OR-Tools CP-SAT subprocess", "status": "UNAVAILABLE", "wallTimeSec": 0.0, "gap": None, "errorCode": error_code, "error": message, }, } world["scheduleVersions"].append(version) world["conflicts"].append({ "id": next_id("conflict"), "versionId": version_id, "conflictType": "ENGINE_UNAVAILABLE", "severity": "CRITICAL", "isResolved": False, "resolutionAction": "", "resourceType": "ENGINE", "description": f"{error_code}: {message}", "suggestedSolution": "检查求解器隔离运行时、子进程协议与超时配置后重试", }) return ScheduleResult( versionId=version_id, versionNo=version["versionNo"], engineType=engine_type, strategy=params.strategyTemplate, status="DRAFT", orderCount=source_count, poCount=0, woCount=0, conflictCount=1, totalTardiness=0.0, avgUtilization=0.0, totalCost=0.0, evidenceRefs=[ f"run:{version['versionNo']}", "solver:UNAVAILABLE", f"solver-error:{error_code}", ], solveStatus="UNAVAILABLE", solveTimeSec=0.0, optimalityGap=None, ) class CpSatEngine(RuleEngine): """SC-03:CP-SAT 工序级排产(选线+排序),再班次占槽物化。""" name = "CP" supports_anytime = True def __init__(self) -> None: super().__init__(requested_type="CP") def solve(self, world: World, params: EngineParams, next_id: Callable[[str], int]) -> ScheduleResult: entries, campaign_meta, source_count = self.collect_and_order(world, params) pipeline_label = "CP-SAT→shift-slot" try: from server.engines.solver_process import ( SolverProcessError, run_cp_assignment, ) except ImportError as exc: return self._unavailable( world, params, next_id, campaign_meta, source_count, str(exc), error_code="SOLVER_RUNTIME_UNSAFE", ) try: ordered, solver_meta = run_cp_assignment( world=world, entries=entries, params=params, pipeline_label=pipeline_label, ) except SolverProcessError as exc: error_code = str( getattr(exc, "code", None) or getattr(exc, "error_code", None) or "SOLVER_PROCESS_EXITED" ) return self._unavailable( world, params, next_id, campaign_meta, source_count, str(exc), error_code=error_code, ) # pipeline 是父进程已知请求字段;由父进程回填,避免 Windows 文本传输替换非 ASCII 箭头。 solver_meta = dict(solver_meta) solver_meta["pipeline"] = pipeline_label return self.materialize_schedule( world, params, next_id, ordered, campaign_meta, source_count, solver_meta=solver_meta, ) def _unavailable( self, world: World, params: EngineParams, next_id: Callable[[str], int], campaign_meta: dict[str, Any], source_count: int, message: str, *, error_code: str = "SOLVER_PROCESS_EXITED", ) -> ScheduleResult: engine_type = "HYBRID" if self.name == "HYBRID" else "CP" return _unavailable_result( world, params, next_id, campaign_meta, source_count, message, engine_type=engine_type, error_code=error_code, ) class HybridEngine(RuleEngine): """SC-03:HYBRID = RULE 策略序/选线热启动 → CP-SAT 改良 → 班次占槽。""" name = "HYBRID" supports_anytime = True def __init__(self) -> None: super().__init__(requested_type="HYBRID") def solve(self, world: World, params: EngineParams, next_id: Callable[[str], int]) -> ScheduleResult: entries, campaign_meta, source_count = self.collect_and_order(world, params) warm = build_rule_warm_start(world, entries, params) pipeline_label = "RULE→CP-SAT→shift-slot" try: from server.engines.solver_process import ( SolverProcessError, run_cp_assignment, ) except ImportError as exc: return _unavailable_result( world, params, next_id, campaign_meta, source_count, str(exc), engine_type="HYBRID", error_code="SOLVER_RUNTIME_UNSAFE", ) try: ordered, solver_meta = run_cp_assignment( world=world, entries=entries, params=params, warm_start=warm, pipeline_label=pipeline_label, ) except SolverProcessError as exc: error_code = str( getattr(exc, "code", None) or getattr(exc, "error_code", None) or "SOLVER_PROCESS_EXITED" ) # HYBRID 默认同样失败关闭;禁止把 native/runtime/protocol 故障伪装成 RULE 成功。 return _unavailable_result( world, params, next_id, campaign_meta, source_count, str(exc), engine_type="HYBRID", error_code=error_code, ) solver_meta = dict(solver_meta) solver_meta["pipeline"] = pipeline_label return self.materialize_schedule( world, params, next_id, ordered, campaign_meta, source_count, solver_meta=solver_meta, )