# ============================================================ # 柔性排产引擎 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 # 前向类型引用 import copy 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, trial: bool = False) -> 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 则启用) trial: 试排草稿。不虚构任何值,但把「尚未登记/尚未确认」的资料 事实降级为显式“试排假设”(ASSUMPTION 冲突 + 不占人员), 使草稿仍能排出;正式排产(False)保持硬阻断。 """ from server.aps_domain.masterdata_consumption import ( as_datetime, bom_requirement, execution_reservations, execution_people_reservations, frozen_assignments, order_execution_issues, place_masterdata_slot, planning_bounds, uses_masterdata_constraints, ) strict_inputs = uses_masterdata_constraints(world) 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) if strict_inputs: planning_start, planning_end, freeze_end = planning_bounds(world, start_date) plan_minutes = int((planning_end - planning_start).total_seconds() / 60) horizon_min = min(horizon_min, plan_minutes) if horizon_min else plan_minutes 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)) # 默认明天 if strict_inputs: base_start = planning_start horizon_end = add_minutes(base_start, horizon_min) if horizon_min else None frozen_rows = frozen_assignments(world, base_start, freeze_end) if strict_inputs else [] 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), } if strict_inputs: version.update({ "trialOnly": True, "productionReady": False, "planStart": fmt_dt(base_start), "horizonEnd": fmt_dt(horizon_end), "freezeUntil": fmt_dt(freeze_end), "inputSnapshot": copy.deepcopy({key: world.get(key) for key in ( "planningContext", "flexMaterials", "flexBom", "flexRoutings", "flexEquipment", "flexPersonnel", "flexCalendar", "flexCalendarOverrides", "flexMaintenance", "flexWip", "flexOrders")}), "executionFacts": copy.deepcopy(world.get("flexWip") or []), "capacityPolicy": "执行中任务未提供人员时,保守预留该工序合格人员池至预计结束;不猜测具体执行人", }) world["flexScheduleVersions"].append(version) # ---- ⑤ 占用登记(可注入窗外冻结占槽)---- eq_busy: dict[int, list[tuple[datetime, datetime]]] = { k: list(v) for k, v in (seed_busy or {}).items() } if strict_inputs: for eid, intervals in execution_reservations(world, base_start, horizon_end).items(): eq_busy.setdefault(eid, []).extend(intervals) for frozen_row in frozen_rows: eq_busy.setdefault(frozen_row["equipmentId"], []).append(( as_datetime(frozen_row["plannedStartTime"]), as_datetime(frozen_row["plannedEndTime"]))) people_busy: dict[str, list[tuple[datetime, datetime]]] = ( execution_people_reservations(world, base_start, horizon_end) if strict_inputs else {}) for frozen_row in frozen_rows: if frozen_row.get("personCode"): people_busy.setdefault(frozen_row["personCode"], []).append(( as_datetime(frozen_row["plannedStartTime"]), as_datetime(frozen_row["plannedEndTime"]))) material_reserved: dict[str, float] = {} 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 assumption_count = 0 # 试排假设条数(只统计,不改数据) # ---- ⑥ 逐订单组装虚拟产线 ---- for o in orders: admission_issues = order_execution_issues(world, o, trial=trial) if strict_inputs else [] blocking_issues = [issue for issue in admission_issues if issue.get("severity") != "assumption"] assumed_wip = False for issue in admission_issues: if issue.get("severity") != "assumption": continue assumption_count += 1 assumed_wip = assumed_wip or str(issue["type"]).startswith("WIP_") conflicts.append({"conflictType": issue["type"], "severity": "ASSUMPTION", "resourceType": "MASTERDATA", "orderNo": o["orderNo"], "description": "试排假设:" + issue["detail"], "suggestedSolution": "正式排产前补齐该资料;本版仅作试排草稿", "sourceRef": issue.get("sourceRef")}) if blocking_issues: conflicts.extend({"conflictType": issue["type"], "severity": "CRITICAL", "resourceType": "MASTERDATA", "orderNo": o["orderNo"], "description": issue["detail"], "suggestedSolution": "核对并补充排产资料", "sourceRef": issue.get("sourceRef")} for issue in blocking_issues) continue # 在制进度被假设过时不能当依据:整单重排,避免把未确认完成量当作已完成 execution = {} if assumed_wip else ({ r.get("operationCode"): r for r in world.get("flexWip", []) if r.get("orderNo") == o["orderNo"]} if strict_inputs else {}) 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 material_failed = False material_for_order: dict[str, float] = {} 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: if strict_inputs: material_failed = True conflicts.append({"conflictType": "MATERIAL_MASTER_MISSING", "severity": "CRITICAL", "resourceType": "MATERIAL", "orderNo": o["orderNo"], "description": f"物料 {b['materialCode']} 缺少档案,无法核对到料", "suggestedSolution": "补充物料档案及可用量"}) continue need = bom_requirement(b, o["quantity"]) if strict_inputs: code = b["materialCode"] reserved = material_reserved.get(code, 0) + material_for_order.get(code, 0) stock = float(mat.get("stock") or 0) - reserved transit = float(mat.get("inTransit") or 0) material_for_order[code] = material_for_order.get(code, 0) + need if stock < need: eta = as_datetime(mat.get("expectedArrivalDate")) if stock + transit < need or eta is None: material_failed = True conflicts.append({"conflictType": "MATERIAL_SHORTAGE" if stock + transit < need else "MATERIAL_ETA_UNKNOWN", "severity": "CRITICAL", "resourceType": "MATERIAL", "orderNo": o["orderNo"], "resourceName": mat["name"], "description": f"{o['orderNo']} {mat['name']} 需求 {need:g},剩余库存 {max(0, stock):g},在途 {transit:g};请核对到料", "requiredQuantity": need, "availableQuantity": max(0, stock + transit), "suggestedSolution": "确认库存、到货数量及日期"}) else: material_ready = max(material_ready, eta) continue 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": "紧急采购或启用替代物料"}) if material_failed: continue # 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()} people_busy_before = {key: list(intervals) for key, intervals in people_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"] progress = execution.get(op_code) or {} if progress.get("status") in ("DONE", "RUNNING"): finished = as_datetime(progress.get("expectedEnd") or progress.get("completionTime")) if finished: cursor = max(cursor, finished) continue if any(r.get("predecessorsConfirmed") and next((s.get("seq", 0) for s in product_steps if s["operationCode"] == r.get("operationCode")), 0) > step.get("seq", 0) for r in execution.values()): continue frozen_row = next((row for row in frozen_rows if row.get("flexOrderNo") == o["orderNo"] and row.get("seq") == step["seq"]), None) if frozen_row: frozen_copy = copy.deepcopy(frozen_row) frozen_copy.update({"id": next_id("flexWorkOrder"), "versionId": vid, "vlId": vl_id, "frozen": True, "inheritedFromVersionId": frozen_row["versionId"]}) world["flexWorkOrders"].append(frozen_copy) vl["assignments"].append({"seq": step["seq"], "operationCode": op_code, "equipmentCode": frozen_copy["equipmentCode"], "start": frozen_copy["plannedStartTime"], "end": frozen_copy["plannedEndTime"], "frozen": True}) frozen_start, frozen_finish = as_datetime(frozen_copy["plannedStartTime"]), as_datetime(frozen_copy["plannedEndTime"]) vl_start = vl_start or frozen_start cursor, vl_end = max(cursor, frozen_finish), max(vl_end, frozen_finish) version["woCount"] += 1 continue step_quantity = max(0, float(o["quantity"]) - float(progress.get("completedQuantity") or 0)) if step_quantity == 0: continue # 能力池:拥有该工序能力且状态可用的设备 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 "flexPersonnel" not in world 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 * step_quantity) / (chosen.get("availabilityRate") or 1) # 换型:换模具或工序自身换型时间 changeover = step.get("setupTime") if step.get("setupTime") is not None else (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 slot = None if strict_inputs: # Evaluate actual available calendars; the least-used machine may be unavailable. candidates = [] for candidate in pool: candidate_per = step.get("stdTimePerUnit") or candidate.get("opStdTime", {}).get(op_code) if not candidate_per or float(candidate_per) <= 0: continue candidate_run = float(candidate_per) * step_quantity / (candidate.get("availabilityRate") or 1) candidate_move = candidate.get("moveTimeMin", 0) if candidate.get("movable") else 0 candidate_slot = place_masterdata_slot(world, candidate, op_code, cursor, changeover + candidate_move + candidate_run, horizon_end, eq_busy, people_busy, step.get("requiredSkillLevel"), allow_unassigned_person=trial) if candidate_slot: candidates.append((candidate_slot, candidate, candidate_run, candidate_move)) if not candidates: conflicts.append({"conflictType": "NO_FEASIBLE_SLOT", "severity": "CRITICAL", "resourceType": "CAPACITY", "orderNo": o["orderNo"], "description": f"{o['orderNo']} 的 {op_code} 在计划周期内无合适设备/人员工作时间", "suggestedSolution": "核对工时、班次、人员和设备维护安排"}) ok = False break slot, chosen, run_min, move_min = min(candidates, key=lambda item: (item[0]["end"], item[1]["id"])) start, end = slot["start"], slot["end"] else: 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": step_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, } if slot: wo.update({"personCode": slot["personCode"], "workSegments": [{"start": fmt_dt(a), "end": fmt_dt(b)} for a, b in slot["segments"]], "stdTimeSource": step.get("stdTimeSource"), "sourceRef": copy.deepcopy(step.get("sourceRef"))}) if slot["personCode"]: people_busy.setdefault(slot["personCode"], []).append((start, end)) 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 people_busy = people_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 people_busy = people_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) for code, amount in material_for_order.items(): material_reserved[code] = material_reserved.get(code, 0) + amount 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 version["assumptionCount"] = assumption_count solve_status = None if strict_inputs: solve_status = ("BLOCKED" if not version["woCount"] else ("PARTIAL" if version["vlCount"] < len(orders) else "FEASIBLE")) if assumption_count and solve_status != "BLOCKED": solve_status = f"{solve_status}_WITH_ASSUMPTIONS" 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, **({"trialOnly": True, "productionReady": False, "planStart": fmt_dt(base_start), "horizonEnd": fmt_dt(horizon_end), "freezeUntil": fmt_dt(freeze_end), "blockedOrderCount": len({c.get("orderNo") for c in conflicts if c.get("severity") == "CRITICAL"}), "assumptionCount": assumption_count, "solveStatus": solve_status} if strict_inputs else {}), } # ---------------- 占槽:设备级 + 可选班组并发(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