# ============================================================ # RULE 规则排产引擎(moduleId: engines-rule, 可重生 ✅, 黄金测试 tests/golden) # 从 legacy aps-frontend/js/common.js runScheduling/placeWorkOrder/findSlot 按行移植。 # 算法定位(plan.md §9.5.5):构造启发式(EDD/优先级派工 + 顺排占槽),毫秒级, # 作为 HYBRID 管线的初始解生成器;CP-SAT/GA 在 M5 接入。 # 与 legacy 的两处刻意偏差(修正其跨版本污染问题,已在黄金测试固化): # 1) 容量/维保冲突检测只扫"本次版本"新生成的工单(legacy 扫全表会误报历史版本) # 2) 利用率统计只按本次版本工单计算 # ============================================================ from __future__ import annotations # 前向类型引用 from datetime import datetime # 时间类型 from typing import Any, Callable # 类型标注 from server.contracts import ScheduleResult # 输出契约 from server.engines.base import EngineParams, ISchedulingEngine # 接口与入参 from server.engines.queries import ( # 主数据查询辅助(P0 只读) find_bom_items, find_product_lines, find_routing_steps, find_workstation_for_operation, get_available_minutes, get_line_shifts, ) from server.aps_domain.constraints import material_shortage_severity, profile_snapshot from server.timeutil import add_minutes, fmt_date, fmt_dt, parse_dt, today0 # 日期工具 # 世界状态类型别名 World = dict[str, Any] def _kit_ready(so: dict[str, Any]) -> int: s = str(so.get("kitStatus") or "") return 0 if ("已完成" in s or "齐套" in s or s.upper() == "READY") else 1 def _skill_rank(level: Any) -> int: lvl = str(level or "L3").upper() return {"L4": 0, "L3": 1, "L2": 2, "L1": 3}.get(lvl, 4) def _processing_minutes(work_order: dict[str, Any]) -> float: value = work_order.get("processingMinutes") if isinstance(value, (int, float)) and not isinstance(value, bool) and float(value) > 0: return float(value) return ( parse_dt(work_order["plannedEndTime"]) - parse_dt(work_order["plannedStartTime"]) ).total_seconds() / 60.0 def _processing_intervals(work_order: dict[str, Any]) -> list[tuple[datetime, datetime]]: segments = work_order.get("plannedSegments") if isinstance(segments, list) and segments: intervals = [] for segment in segments: if not isinstance(segment, dict): break try: start = parse_dt(str(segment["startTime"])) end = parse_dt(str(segment["endTime"])) except (KeyError, TypeError, ValueError): break if start >= end: break intervals.append((start, end)) else: return intervals return [( parse_dt(work_order["plannedStartTime"]), parse_dt(work_order["plannedEndTime"]), )] def _preflight_direct_timing(world: World, all_items: list[dict]) -> bool: for entry in all_items: timing = entry.get("_cpOperationTiming") if not isinstance(timing, list) or not timing: raise ValueError("经验证的 CP timing 缺少工序槽位") item = entry.get("item") or {} steps = find_routing_steps(world, int(item.get("productId") or -1)) expected = [(int(step["sequenceNo"]), int(step["id"])) for step in steps] actual = [ (int(slot.get("sequenceNo") or -1), int(slot.get("routingStepId") or -1)) for slot in timing ] if actual != expected: raise ValueError("经验证的 CP timing 与工艺路线不一致") line_ids = {slot.get("lineId") for slot in timing} if len(line_ids) != 1 or entry.get("forcedLineId") != next(iter(line_ids)): raise ValueError("经验证的 CP timing 产线身份不一致") for slot, step in zip(timing, steps): workstation = find_workstation_for_operation( world, int(slot["lineId"]), int(step["operationId"]), ) if workstation is None or workstation.get("id") != slot.get("workstationId"): raise ValueError("经验证的 CP timing 工位身份不一致") segments = slot.get("plannedSegments") if not isinstance(segments, list) or not segments: raise ValueError("经验证的 CP timing 缺少 plannedSegments") start = parse_dt(str(slot.get("plannedStartTime"))) end = parse_dt(str(slot.get("plannedEndTime"))) if start >= end: raise ValueError("经验证的 CP timing 包络时间非法") return True class RuleEngine(ISchedulingEngine): """规则引擎:策略排序 → 选线 → 逐工序占槽 → 冲突检测 → KPI(P1:只改内存 world)。""" # 引擎标识 name = "RULE" # 规则引擎毫秒级完成,无需 anytime supports_anytime = False def __init__(self, requested_type: str = "RULE") -> None: """记录用户请求的引擎类型(CP/GA/HYBRID 由 RULE 代跑时留痕,M5 前的诚实标注)。""" self.requested_type = requested_type if requested_type in ("RULE", "CP", "GA", "HYBRID") else "RULE" # 合法化 # ---------------- 主入口 ---------------- def solve(self, world: World, params: EngineParams, next_id: Callable[[str], int]) -> ScheduleResult: """执行一次排产(移植 runScheduling;写入 world 的版本/PO/WO/冲突/日志)。""" all_items, campaign_meta, source_order_count = self.collect_and_order(world, params) return self.materialize_schedule( world, params, next_id, all_items, campaign_meta, source_order_count) def collect_and_order( self, world: World, params: EngineParams, ) -> tuple[list[dict], dict[str, Any], int]: """收集待排项并按策略排序(SC-03 CP 可在此后重排/强制选线)。""" # ---- ① 收集待排订单项(OR-03:默认仅 APPROVED/CONFIRMED)---- from server.aps_domain.orders import is_schedulable all_items: list[dict] = [] # 待排项集合 [{so, item}] for so in world["salesOrders"]: # 遍历销售订单 if params.orderIds and so["id"] not in params.orderIds: # 指定订单集时跳过未选中 continue if so["status"] in ("CANCELLED", "COMPLETED"): # 已取消/完成不排 continue if not is_schedulable(so["status"], include_unapproved=params.includeUnapproved): continue for item in so["items"]: # 遍历订单项 if item["status"] == "COMPLETED": # 已完成项不排 continue all_items.append({"so": so, "item": item}) # 加入待排集 # OR-05:可选纳入 ACTIVE 预测(伪销售订单,排在确定订单之后) if params.includeForecast: from server.aps_domain.forecast import forecasts_as_schedule_entries all_items.extend(forecasts_as_schedule_entries(world)) # ---- ② 策略排序(调度规则的最优性依据见 plan.md §9.5.5;OR-02 掺入客户等级权重)---- strategy = params.strategyTemplate # 策略模板名 from server.aps_domain.params import level_weight as _level_w source_order_count = len(all_items) # 合并前订单项数(SC-08 留痕) campaign_meta: dict[str, Any] = {"enabled": False, "windowDays": 7, "poSaved": 0} if strategy == "CAMPAIGN": # SC-08:同产品交期窗口内战役合并,再按换型最小化排战役序 from server.aps_domain.campaign import DEFAULT_CAMPAIGN_WINDOW_DAYS, merge_entries_to_campaigns from server.aps_domain.changeover import sort_entries_changeover_min window = DEFAULT_CAMPAIGN_WINDOW_DAYS all_items = merge_entries_to_campaigns(world, all_items, window_days=window) all_items = sort_entries_changeover_min(world, all_items, level_weight_fn=_level_w) campaign_meta = { "enabled": True, "windowDays": window, "poSaved": max(0, source_order_count - len(all_items)), "campaignCount": len(all_items), "mergedCount": sum(1 for e in all_items if e.get("isCampaign")), } elif strategy in ("CHANGEOVER_MIN", "COST_FIRST"): # SC-07:换型最小化(贪心最近邻);COST_FIRST 以换型耗时为成本代理 from server.aps_domain.changeover import sort_entries_changeover_min all_items = sort_entries_changeover_min(world, all_items, level_weight_fn=_level_w) else: def sort_key(entry: dict): # 排序键工厂(Python 稳定排序;数值越小越先排) so = entry["so"] # 所属订单 lw = -_level_w(world, so.get("customerLevel")) # 等级权重越高越靠前 rush = 0 if so.get("isRush") else 1 # 插单略优先 forecast = 1 if so.get("isForecast") else 0 # 预测垫后 if strategy == "DELIVERY_FIRST": # 交期优先 ≈ EDD(Jackson 定理) return (forecast, so["deliveryDate"], lw, so["priority"], rush) if strategy == "FIFO": # 先来先服务 return (forecast, so["orderDate"], lw, so["priority"]) if strategy == "CAPACITY_BALANCE": # 均衡:等级+优先级 return (forecast, lw, so["priority"], so["deliveryDate"], rush) if strategy == "KITTING_FIRST": # 齐套优先:备料已完成先排 return (forecast, _kit_ready(so), so["deliveryDate"], lw, so["priority"], rush) if strategy == "SKILL_FIRST": # 技能优先:高技能需求先排 return (forecast, _skill_rank(so.get("requiredSkillLevel")), so["deliveryDate"], lw, so["priority"], rush) return (forecast, lw, so["priority"], so["deliveryDate"], rush) # 综合 all_items.sort(key=sort_key) # 应用排序 return all_items, campaign_meta, source_order_count def materialize_schedule( self, world: World, params: EngineParams, next_id: Callable[[str], int], all_items: list[dict], campaign_meta: dict[str, Any], source_order_count: int, solver_meta: dict[str, Any] | None = None, ) -> ScheduleResult: """将已排序条目物化为版本/PO/WO(班次占槽);entry.forcedLineId 可强制选线。""" strategy = params.strategyTemplate direct_cp_timing = bool( solver_meta and solver_meta.get("status") in {"OPTIMAL", "FEASIBLE"} and solver_meta.get("directlyConsumedByMaterializer") is True and solver_meta.get("operationTimingValidation", {}).get("passed") is True ) if direct_cp_timing: _preflight_direct_timing(world, all_items) # ---- ③ 创建版本对象(版本链:parent 指向上一版本)---- now = datetime.now() # 当前时间(版本号与留痕) version_id = next_id("scheduleVersion") # 发号 version: dict[str, Any] = { "id": version_id, "versionNo": "V" + fmt_date(now).replace("-", "") + f"-{len(world['scheduleVersions']) + 1:03d}", # V+日期+序号 "versionName": params.name or ("手动排产 " + fmt_dt(now)), # 版本名 "triggerType": params.triggerType, # 触发类型 "engineType": self.requested_type, # 如实记录请求引擎 "status": "DRAFT", # 新版本为草稿(发布走 P2 门禁) "parentVersionId": world["scheduleVersions"][-1]["id"] if world["scheduleVersions"] else None, # 版本链 "orderCount": source_order_count, "poCount": 0, "woCount": 0, "totalTardiness": 0.0, "totalCost": 0.0, "avgUtilization": 0.0, "conflictCount": 0, "resolvedCount": 0, "createdBy": "agent", "createdAt": fmt_dt(now), "publishedAt": None, "note": f"strategy={strategy}", # 策略留痕 "constraintProfile": profile_snapshot(world), "campaign": campaign_meta, } if solver_meta: version["solverMeta"] = solver_meta world["scheduleVersions"].append(version) # 入库(内存) # ---- ④ 排产准备:基准开始时间与占用登记表 ---- horizon = params.planningHorizonDays or 14 # 展望期 base_start = (parse_dt(params.startDate + " 08:00") if params.startDate # 指定起始日 08:00 else add_minutes(today0(), 24 * 60)) # 默认明天零点 # SC-06:冻结窗口 → 起点后移(仅当 EngineParams 显式传入) if params.freezeWindowHours is not None and float(params.freezeWindowHours) > 0: base_start = add_minutes(base_start, int(float(params.freezeWindowHours) * 60)) used_workstations: dict[int, list[tuple[datetime, datetime]]] = {} # 工位占用区间表 used_line_minutes: dict[str, float] = {} # 产线-日期占用分钟(均衡策略选线用) conflicts: list[dict] = [] # 本次冲突收集器 new_wo_ids: list[int] = [] # 本次生成的工单 ID(版本内冲突检测范围) line_last_family: dict[int, str | None] = {} # MD-06:产线 → 上一订单产品族 total_changeover = 0.0 # SC-06:交期缓冲(None=1.0 即不提前) due_buffer = 1.0 if params.deliveryBufferRatio is None else max(0.5, min(1.0, float(params.deliveryBufferRatio))) # ---- ⑤ 逐订单项排产 ---- for entry in all_items: # 遍历待排项 so, item = entry["so"], entry["item"] # 解构 entry_timing = entry.get("_cpOperationTiming") if direct_cp_timing else None product = next(m for m in world["materials"] if m["id"] == item["productId"]) # 产品主数据 from server.aps_domain.changeover import extra_setup_minutes, product_family cur_family = product_family(product) line_options = find_product_lines(world, item["productId"]) # 可选产线 if not line_options: # 无可用产线 → 致命冲突并跳过 conflicts.append({"conflictType": "NO_LINE", "severity": "CRITICAL", "productionOrderId": None, "resourceType": "LINE", "description": f"产品 {product['name']} 无可用产线配置", "suggestedSolution": "维护产线产品配置"}) continue # 选线:CP 强制 → 均衡 → 换型亲和 → 默认最高优先 forced = entry.get("forcedLineId") option_ids = {lp["lineId"] for lp in line_options} if forced is not None and int(forced) in option_ids: chosen_line_id = int(forced) else: chosen_line_id = line_options[0]["lineId"] # 默认最高优先 if strategy == "CAPACITY_BALANCE": # 产能均衡:按累计占用分钟升序 chosen_line_id = sorted(line_options, key=lambda lp: sum( v for k, v in used_line_minutes.items() if k.startswith(f"{lp['lineId']}_")))[0]["lineId"] elif strategy in ("CHANGEOVER_MIN", "COST_FIRST", "CAMPAIGN"): def _line_score(lp: dict) -> tuple: lid = lp["lineId"] last = line_last_family.get(lid) load = sum(v for k, v in used_line_minutes.items() if k.startswith(f"{lid}_")) # 同族续排最优;空线次之;跨族最差 affinity = 0 if last == cur_family else (1 if last is None else 2) return (affinity, lp.get("priority", 99), load) chosen_line_id = sorted(line_options, key=_line_score)[0]["lineId"] line = next(l for l in world["lines"] if l["id"] == chosen_line_id) # 产线对象 # MD-06 / C10:同线跨产品族时,首道工序叠加换型矩阵分钟 matrix_extra = 0.0 if params.constraints.get("changeover", True): prev_fam = line_last_family.get(chosen_line_id) matrix_extra = extra_setup_minutes(world, prev_fam, cur_family) if direct_cp_timing: assert isinstance(entry_timing, list) matrix_extra = float(entry_timing[0].get("changeoverMin") or 0) # 展开工艺步骤并绑定工位;缺工位 → 致命冲突并跳过 ws_list = [{"step": step, "ws": find_workstation_for_operation(world, chosen_line_id, step["operationId"])} for step in find_routing_steps(world, item["productId"])] # 步骤×工位 if any(x["ws"] is None for x in ws_list): # 任一步骤无工位 conflicts.append({"conflictType": "NO_WORKSTATION", "severity": "CRITICAL", "productionOrderId": None, "resourceType": "WORKSTATION", "description": f"产品 {product['name']} 在产线 {line['name']} 缺少可用工位", "suggestedSolution": "维护工位工序配置"}) continue # 物料齐套:库存不足但在途可补 → 齐套时间推迟 3 天;全缺 → 缺料冲突(SC-04 可关/软硬) material_ready = base_start # 默认立即齐套 kit_sev = material_shortage_severity(world) if params.constraints.get("materialKit", True) and kit_sev: # 齐套约束开关 for bi in find_bom_items(world, item["productId"]): # 遍历 BOM 明细 mat = next(m for m in world["materials"] if m["id"] == bi["materialId"]) # 物料 need = bi["quantity"] * item["quantity"] # 总需求 if mat["stock"] < need and mat["stock"] + mat["inTransit"] >= need: # 在途可补 eta = add_minutes(today0(), 3 * 24 * 60) # 到货预计 +3 天 if eta > material_ready: # 取最晚齐套时刻 material_ready = eta elif mat["stock"] + mat["inTransit"] < need: # 在途也不够 → 缺料 conflicts.append({"conflictType": "MATERIAL_SHORTAGE", "severity": kit_sev, "resourceType": "MATERIAL", "resourceName": mat["name"], "description": f"{so['orderNo']} {mat['name']} 缺料 " f"{int(need - mat['stock'] - mat['inTransit'] + 0.999)} {mat['unit']}", "suggestedSolution": "紧急采购或启用替代物料"}) # 生产订单壳(时间在工序排完后回填) cursor = max(base_start, material_ready) # 排产游标:基准与齐套取晚者 if direct_cp_timing: assert isinstance(entry_timing, list) cursor = parse_dt(str(entry_timing[0]["plannedStartTime"])) if cursor < material_ready: conflicts.append({ "conflictType": "MATERIAL_SHORTAGE", "severity": kit_sev or "CRITICAL", "resourceType": "MATERIAL", "description": ( f"{so['orderNo']} 的 CP 开工时间早于物料齐套时间 {fmt_dt(material_ready)}" ), "suggestedSolution": "补齐物料后重新求解,禁止平移已验证的 CP 时间", }) po_start, po_end = cursor, cursor # PO 起止(回填) po_id = next_id("productionOrder") # 发号 camp = entry.get("campaign") or {} camp_sources = camp.get("sourceOrderNos") or [so["orderNo"]] is_camp = bool(entry.get("isCampaign")) production_order: dict[str, Any] = { "id": po_id, "orderNo": "PO" + so["orderNo"].replace("SO", "", 1), # PO 号继承 SO 号 "salesOrderId": so["id"], "salesOrderNo": so["orderNo"], "salesOrderItemId": item["id"], "productId": item["productId"], "productName": product["name"], "productCode": product["code"], "quantity": item["quantity"], "unit": item["unit"], "plannedStartDate": None, "plannedEndDate": None, "lineId": line["id"], "lineName": line["name"], "status": "DRAFT", "priority": so["priority"], "schedulingEngine": self.requested_type, "schedulingVersionId": version_id, "materialKitStatus": "PASSED", "constraintCheckStatus": "PASSED", "conflictCount": 0, "isRushOrder": so["isRush"], "rushStrategy": so.get("rushStrategy"), "productFamily": cur_family, "changeoverMin": matrix_extra, "isCampaign": is_camp, "campaignMemberCount": camp.get("memberCount") or 1, "campaignSourceOrderNos": camp_sources, "campaignEarliestDue": camp.get("earliestDue") or so.get("deliveryDate"), "campaignLatestDue": camp.get("latestDue") or so.get("deliveryDate"), } if is_camp: production_order["orderNo"] = f"PO-CAMP-{po_id:04d}" production_order["note"] = ( f"战役合并 {camp.get('memberCount')} 单({'/'.join(str(x) for x in camp_sources)})" f" · 交期窗 {camp.get('earliestDue')}~{camp.get('latestDue')}" ) world["productionOrders"].append(production_order) # 入库 version["poCount"] += 1 # 版本计数 if matrix_extra > 0: total_changeover += matrix_extra # ---- 逐工序占槽(核心:placeWorkOrder + findSlot 的移植)---- for idx, pair in enumerate(ws_list): # 按工艺顺序 step, ws = pair["step"], pair["ws"] # 步骤与工位 op = next(o for o in world["operations"] if o["id"] == step["operationId"]) # 工序 # 工时 = 准备时间 +(首道跨族换型)+ 数量×单件时间/产线效率 setup = step["setupTime"] + (matrix_extra if idx == 0 else 0) duration_min = setup + (item["quantity"] * step["runTimePerUnit"]) / (line.get("efficiencyFactor") or 1) cp_slot = entry_timing[idx] if direct_cp_timing else None if cp_slot is not None: placed = ( parse_dt(str(cp_slot["plannedStartTime"])), parse_dt(str(cp_slot["plannedEndTime"])), ) else: placed = self._place_work_order( world, cursor, chosen_line_id, ws, duration_min, used_workstations, used_line_minutes, ) wo_id = next_id("workOrder") # 工单发号 wo: dict[str, Any] = { "id": wo_id, "orderNo": production_order["orderNo"] + f"-{idx + 1:02d}", # 工单号=PO号-序号 "productionOrderId": po_id, "productionOrderNo": production_order["orderNo"], "operationId": op["id"], "operationName": op["name"], "sequenceNo": step["sequenceNo"], "productId": item["productId"], "productName": product["name"], "quantity": item["quantity"], "unit": item["unit"], "completedQuantity": 0, "lineId": line["id"], "lineName": line["name"], "workstationId": ws["id"], "workstationName": ws["name"], "plannedStartTime": fmt_dt(placed[0]), "plannedEndTime": fmt_dt(placed[1]), "status": "PENDING", "teamId": None, "teamName": None, "requiredMaterials": [], "priority": so["priority"], "isFrozen": False, "kitStatus": "PASSED", "progressPercent": 0, "conflictCount": 0, "note": "", "changeoverMin": matrix_extra if idx == 0 else 0, "schedulingVersionId": version_id, } if cp_slot is not None: wo.update({ "teamId": cp_slot.get("teamId"), "toolingId": cp_slot.get("toolingId"), "routingStepId": cp_slot.get("routingStepId"), "logicalOperationKey": cp_slot.get("logicalOperationKey"), "cpOrderIndex": cp_slot.get("orderIndex"), "plannedSegments": cp_slot["plannedSegments"], "processingMinutes": cp_slot["processingMinutes"], "elapsedSpanMinutes": cp_slot["elapsedSpanMinutes"], "pauseMinutes": cp_slot["pauseMinutes"], "segmentCount": cp_slot["segmentCount"], "cpTimingSource": "operationSlots", "changeoverMin": cp_slot.get("changeoverMin") or 0, "setupMin": cp_slot.get("setupMin") or 0, }) if idx == 0 and matrix_extra > 0: wo["note"] = f"跨族换型 +{matrix_extra:.0f} 分({line_last_family.get(chosen_line_id)}→{cur_family})" # IND-02 / C13:SOP 换型宵禁(默认 16:00 后不安排换线) from server.aps_domain.constraints import is_enabled as _c_on pol = world.get("changeoverPolicy") or {} curfew = str(pol.get("noChangeoverAfter") or "") if _c_on(world, "C13_sop") and curfew and ":" in curfew: try: ch, cm = map(int, curfew.split(":")[:2]) if placed[0].hour > ch or (placed[0].hour == ch and placed[0].minute >= cm): conflicts.append({ "conflictType": "SOP_CHANGEOVER_CURFEW", "severity": "MINOR", "productionOrderId": po_id, "workOrderId": wo_id, "resourceType": "SOP", "description": ( f"{production_order['orderNo']} 换型开始于 {fmt_dt(placed[0])}," f"晚于 SOP 宵禁 {curfew}" ), "suggestedSolution": "提前换型或改同族连续排程", }) except ValueError: pass # 该工序的物料需求与齐套状态(PASSED/PARTIAL/FAILED) for bi in [b for b in find_bom_items(world, item["productId"]) if b["operationId"] == op["id"]]: mat = next(m for m in world["materials"] if m["id"] == bi["materialId"]) # 物料 need = bi["quantity"] * item["quantity"] # 需求量 ks = "PASSED" # 默认齐套 if mat["stock"] < need: # 库存不足 ks = "PARTIAL" if mat["stock"] + mat["inTransit"] >= need else "FAILED" # 在途可补=部分 if ks != "PASSED" and wo["kitStatus"] == "PASSED": # 首个非齐套降级 wo["kitStatus"] = ks elif ks == "FAILED": # 任一 FAILED 直接定级 wo["kitStatus"] = "FAILED" wo["requiredMaterials"].append({"materialId": mat["id"], "name": mat["name"], "required": need, "allocated": min(need, mat["stock"]), "available": mat["stock"], "status": ks}) if wo["kitStatus"] != "PASSED": # 工单齐套问题上卷到 PO production_order["materialKitStatus"] = wo["kitStatus"] world["workOrders"].append(wo) # 工单入库 new_wo_ids.append(wo_id) # 记入本版本工单集 version["woCount"] += 1 # 版本计数 cursor = add_minutes(placed[1], step["transferTime"] + step["waitTime"]) # 游标后移(转移+等待) po_end = placed[1] # 更新 PO 结束 # 回填 PO 起止时间与交期延迟冲突 production_order["plannedStartDate"] = fmt_dt(po_start) # PO 开始 production_order["plannedEndDate"] = fmt_dt(po_end) # PO 结束 line_last_family[chosen_line_id] = cur_family # MD-06 记本线末族 due = parse_dt(so["deliveryDate"] + " 18:00") # 交期基准 18:00 # SC-06:缓冲比 <1 → 有效交期按「起点→交期」跨度比例提前 if due_buffer < 1.0 - 1e-9: span_min = (due - base_start).total_seconds() / 60.0 if span_min > 0: due = add_minutes(base_start, span_min * due_buffer) if po_end > due: # 晚于交期 → 延迟冲突 hours_late = (po_end - due).total_seconds() / 3600 # 延迟小时 version["totalTardiness"] += hours_late # 累计延迟 if params.constraints.get("dueDate", True): conflicts.append({"conflictType": "DELAY", "severity": "MAJOR", "productionOrderId": po_id, "resourceType": "TIME", "description": f"{production_order['orderNo']} 完成时间晚于交期 {hours_late:.1f} 小时", "suggestedSolution": "启用加班/替代产线/压缩准备时间"}) # ---- ⑥ 容量冲突检测(仅扫本版本工单,修正 legacy 跨版本误报)---- if params.constraints.get("capacity", True): new_wos = [w for w in world["workOrders"] if w["id"] in set(new_wo_ids)] # 本版本工单集 for line in world["lines"]: # 逐产线 loads: dict[str, float] = {} # 日期 → 占用分钟 for wo in new_wos: # 累计当日负荷 if wo["lineId"] != line["id"]: # 非本线跳过 continue d = wo["plannedStartTime"][:10] # 开始日期 dur = _processing_minutes(wo) # C7:开工日归集 processing,不计暂停包络 loads[d] = loads.get(d, 0) + dur # 聚合 for d, minutes in loads.items(): # 逐日校验 avail = get_available_minutes(world, line["id"], d) # 当日可用 if minutes > avail * 1.05: # 超出 5% 容差 → 容量冲突 conflicts.append({"conflictType": "CAPACITY", "severity": "CRITICAL", "resourceType": "LINE", "resourceName": line["name"], "conflictTimeStart": d + " 08:00", "description": f"{line['name']} {d} 负荷 {round(minutes)} 分钟,超出可用 {round(avail)} 分钟", "suggestedSolution": "分流至替代产线或启用加班"}) # ---- ⑦ 设备维保冲突检测(仅扫本版本工单;已取消维保不算窗口 MD-03)---- new_wos = [w for w in world["workOrders"] if w["id"] in set(new_wo_ids)] # 本版本工单集 if params.constraints.get("equipment", True): for m in world["maintenance"]: # 逐条维保计划 if m.get("status", "PLANNED") not in ("PLANNED", "IN_PROGRESS"): # 取消/完成的维保不避让 continue ms, me = parse_dt(m["plannedStart"]), parse_dt(m["plannedEnd"]) # 维保时段 for wo in new_wos: # 逐工单比对 eq = next((e for e in world["equipment"] if e["workstationId"] == wo["workstationId"]), None) # 工位设备 if not eq or eq["id"] != m["equipmentId"]: # 非该设备跳过 continue if any(ws_t < me and we_t > ms for ws_t, we_t in _processing_intervals(wo)): conflicts.append({"conflictType": "EQUIPMENT", "severity": "CRITICAL", "workOrderId": wo["id"], "resourceType": "EQUIPMENT", "resourceName": eq["name"], "description": f"{wo['orderNo']} 与设备 {eq['name']} 维保时间冲突", "suggestedSolution": "调整工单时间或启用替代设备"}) # ---- ⑧ 冲突入库并回填计数 ---- for cf in conflicts: # 逐条补全元数据 cf["id"] = next_id("conflict") # 发号 cf["versionId"] = version_id # 归属版本 cf["isResolved"] = False # 初始未解决 cf["resolutionAction"] = "" # 解决动作留空 world["conflicts"].append(cf) # 入库 version["conflictCount"] = len(conflicts) # 版本冲突数 # PO 冲突计数与约束检查状态回填(legacy 逻辑保留) wo_by_id = {w["id"]: w for w in new_wos} # 工单索引 for po in [p for p in world["productionOrders"] if p["schedulingVersionId"] == version_id]: # 本版本 PO po["conflictCount"] = sum( # 关联冲突数 = 直接挂 PO + 经工单挂 PO 1 for c in conflicts if c.get("productionOrderId") == po["id"] or (c.get("workOrderId") in wo_by_id and wo_by_id[c["workOrderId"]]["productionOrderId"] == po["id"])) if po["conflictCount"] > 0: # 有冲突 → 检查失败 po["constraintCheckStatus"] = "FAILED" elif po["materialKitStatus"] != "PASSED": # 无冲突但缺料 → 告警 po["constraintCheckStatus"] = "WARNING" # ---- ⑨ 利用率与成本统计(仅本版本工单,见模块头偏差说明 2)---- line_util: dict[int, dict[str, float]] = {} # 产线 → {used, avail} for wo in new_wos: # 累计占用 dur = _processing_minutes(wo) line_util.setdefault(wo["lineId"], {"used": 0.0, "avail": 0.0})["used"] += dur # 聚合占用 for line in world["lines"]: # 累计可用(展望期内逐日) for i in range(horizon): # 展望期每一天 d = fmt_date(add_minutes(base_start, i * 24 * 60)) # 第 i 天 line_util.setdefault(line["id"], {"used": 0.0, "avail": 0.0})["avail"] += \ get_available_minutes(world, line["id"], d) # 累加可用分钟 utils = [(x["used"] / x["avail"]) if x["avail"] else 0.0 for x in line_util.values()] # 各线利用率 version["avgUtilization"] = sum(utils) / len(utils) if utils else 0.0 # 平均利用率 version["totalCost"] = version["woCount"] * 120 + version["totalTardiness"] * 50 # 成本(演示口径) version["totalChangeoverMin"] = round(total_changeover, 1) note_bits = [f"strategy={strategy}", f"changeoverMin={version['totalChangeoverMin']}"] if campaign_meta.get("enabled"): note_bits.append(f"campaignSaved={campaign_meta.get('poSaved', 0)}") if solver_meta: note_bits.append(f"solver={solver_meta.get('backend', 'CP')}") note_bits.append(f"status={solver_meta.get('status')}") if solver_meta.get("gap") is not None: note_bits.append(f"gap={solver_meta.get('gap')}") if solver_meta.get("wallTimeSec") is not None: note_bits.append(f"t={solver_meta.get('wallTimeSec')}s") version["note"] = ";".join(note_bits) # ---- ⑩ 排产日志(审计的业务侧留痕)---- world["logs"].insert(0, {"id": next_id("log"), "type": "INFO", "category": "SCHEDULE", "operationType": "SCHEDULE", "targetType": "SCHEDULE_VERSION", "targetId": version_id, "action": "执行排产", "description": f"版本 {version['versionNo']} 生成 {version['poCount']} 个生产订单 / " f"{version['woCount']} 个工单,冲突 {version['conflictCount']}", "operator": "agent", "createdAt": fmt_dt(now)}) # ---- ⑪ 输出结果摘要(契约校验后返回)---- gap = None status = None wall = None if solver_meta: status = solver_meta.get("status") wall = solver_meta.get("wallTimeSec") gap = solver_meta.get("gap") refs = [f"run:{version['versionNo']}"] if solver_meta and solver_meta.get("backend"): refs.append(f"solver:{solver_meta['backend']}") return ScheduleResult( versionId=version_id, versionNo=version["versionNo"], engineType=self.requested_type, strategy=strategy, status="DRAFT", orderCount=version["orderCount"], poCount=version["poCount"], woCount=version["woCount"], conflictCount=version["conflictCount"], totalTardiness=version["totalTardiness"], avgUtilization=version["avgUtilization"], totalCost=version["totalCost"], evidenceRefs=refs, solveStatus=status, solveTimeSec=wall, optimalityGap=gap, ) # ---------------- 占槽(placeWorkOrder 移植 + 长工序修正)---------------- def _place_work_order(self, world: World, cursor: datetime, line_id: int, ws: dict, duration_min: float, used_ws: dict[int, list[tuple[datetime, datetime]]], used_line_minutes: dict[str, float]) -> tuple[datetime, datetime]: """在班次日历内为工序找一个不与已占区间重叠的时段。 对 legacy 的修正(黄金测试 G2/G4 固化):工时超过单班次长度的"长工序", legacy 会静默兜底硬排(导致周末开工与工位双占);本实现改为"长工序模式"—— 起点仍必须落在工作班次内,允许其跨班次/跨夜连续运行(M1 简化:视为连续生产, 正式拆分逻辑属 M5 工单拆分能力)。 """ max_days = 30 # 最多向后找 30 天(legacy 同值;种子日历已覆盖 30 天) for day_offset in range(max_days): # 逐日尝试 day = add_minutes(cursor, day_offset * 24 * 60) # 目标日 date_str = fmt_date(day) # 日期字符串 shifts = get_line_shifts(world, line_id, date_str) # 当日工作班次 if not shifts: # 无班次(周末)→ 下一天 continue for shift in shifts: # 逐班次尝试 sh, sm = map(int, shift["startTime"].split(":")) # 班次开始时分 candidate = parse_dt(f"{date_str} {sh:02d}:{sm:02d}") # 候选开始=班次开始 if candidate < cursor and day_offset == 0: # 当天且早于游标 → 从游标起 candidate = cursor eh, em = map(int, shift["endTime"].split(":")) # 班次结束时分 shift_end = parse_dt(f"{date_str} {eh:02d}:{em:02d}") # 班次结束时刻 if candidate >= shift_end: # 游标已越过本班次 → 下一班次 continue # 判断是否"长工序":从候选点起到班次结束都放不下完整工时 fits_in_shift = duration_min <= (shift_end - candidate).total_seconds() / 60 if fits_in_shift: # —— 普通模式:完整落在本班次内 —— start = self._find_slot(candidate, shift_end, ws["id"], duration_min, used_ws) # 班次内找空槽 if start is not None: # 找到候选 end = add_minutes(start, duration_min) # 推算结束 if end > shift_end: # 越过班次结束 → 换下一班次 continue used_ws.setdefault(ws["id"], []).append((start, end)) # 登记占用区间 key = f"{line_id}_{date_str}" # 产线-日期键 used_line_minutes[key] = used_line_minutes.get(key, 0) + duration_min # 登记产线占用 return (start, end) # 占槽成功 else: # —— 长工序模式:起点在班次内,允许跨班次连续运行 —— start = self._find_slot(candidate, shift_end, ws["id"], duration_min, used_ws) # 起点扫描范围仍限本班次 if start is not None: # 找到不与任何已占区间重叠的起点 end = add_minutes(start, duration_min) # 结束可越过班次(连续生产) used_ws.setdefault(ws["id"], []).append((start, end)) # 登记占用区间 key = f"{line_id}_{date_str}" # 占用记账仍按开工日归集 used_line_minutes[key] = used_line_minutes.get(key, 0) + duration_min return (start, end) # 占槽成功 # 兜底:30 天内无槽(极端过载)→ 从游标硬排并留待容量冲突暴露(legacy 语义保留) return (cursor, add_minutes(cursor, duration_min)) # ---------------- 找槽(findSlot 移植)---------------- def _find_slot(self, t_from: datetime, t_end: datetime, ws_id: int, duration_min: float, used_ws: dict[int, list[tuple[datetime, datetime]]]) -> datetime | None: """从 t_from 起以 15 分钟步长扫描,返回首个与已占区间不重叠的开始时刻。""" t = t_from # 扫描游标 intervals = used_ws.get(ws_id, []) # 该工位已占区间 while t <= t_end: # 未越界 end = add_minutes(t, duration_min) # 候选结束 overlap = any(t < iv_end and end > iv_start for iv_start, iv_end in intervals) # 区间重叠判定 if not overlap: # 无重叠 → 命中 return t t = add_minutes(t, 15) # 后移 15 分钟(排产粒度) return None # 本班次内无槽