365 lines
28 KiB
Python
365 lines
28 KiB
Python
# ============================================================
|
||
# 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.timeutil import add_minutes, fmt_date, fmt_dt, parse_dt, today0 # 日期工具
|
||
|
||
# 世界状态类型别名
|
||
World = dict[str, Any]
|
||
|
||
|
||
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/冲突/日志)。"""
|
||
# ---- ① 收集待排订单项(状态过滤与 legacy 一致)----
|
||
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
|
||
for item in so["items"]: # 遍历订单项
|
||
if item["status"] == "COMPLETED": # 已完成项不排
|
||
continue
|
||
all_items.append({"so": so, "item": item}) # 加入待排集
|
||
|
||
# ---- ② 策略排序(调度规则的最优性依据见 plan.md §9.5.5)----
|
||
strategy = params.strategyTemplate # 策略模板名
|
||
def sort_key(entry: dict): # 排序键工厂(Python 稳定排序)
|
||
so = entry["so"] # 所属订单
|
||
if strategy == "DELIVERY_FIRST": # 交期优先 ≈ EDD(Jackson 定理)
|
||
return (so["deliveryDate"], so["priority"])
|
||
if strategy == "FIFO": # 先来先服务
|
||
return (so["orderDate"],)
|
||
if strategy in ("CAPACITY_BALANCE", "COST_FIRST"): # 均衡/成本:先按优先级
|
||
return (so["priority"],)
|
||
return (so["priority"], so["deliveryDate"]) # 综合:优先级+交期
|
||
all_items.sort(key=sort_key) # 应用排序
|
||
|
||
# ---- ③ 创建版本对象(版本链: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, # 如实记录请求引擎(M1 实际由 RULE 执行)
|
||
"status": "DRAFT", # 新版本为草稿(发布走 P2 门禁)
|
||
"parentVersionId": world["scheduleVersions"][-1]["id"] if world["scheduleVersions"] else None, # 版本链
|
||
"orderCount": len(all_items), "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}", # 策略留痕
|
||
}
|
||
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)) # 默认明天零点
|
||
used_workstations: dict[int, list[tuple[datetime, datetime]]] = {} # 工位占用区间表
|
||
used_line_minutes: dict[str, float] = {} # 产线-日期占用分钟(均衡策略选线用)
|
||
conflicts: list[dict] = [] # 本次冲突收集器
|
||
new_wo_ids: list[int] = [] # 本次生成的工单 ID(版本内冲突检测范围)
|
||
|
||
# ---- ⑤ 逐订单项排产 ----
|
||
for entry in all_items: # 遍历待排项
|
||
so, item = entry["so"], entry["item"] # 解构
|
||
product = next(m for m in world["materials"] if m["id"] == item["productId"]) # 产品主数据
|
||
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
|
||
|
||
# 选线:均衡策略选当前占用最少的产线,否则取优先级最高
|
||
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"]
|
||
line = next(l for l in world["lines"] if l["id"] == chosen_line_id) # 产线对象
|
||
|
||
# 展开工艺步骤并绑定工位;缺工位 → 致命冲突并跳过
|
||
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 天;全缺 → 缺料冲突
|
||
material_ready = base_start # 默认立即齐套
|
||
if params.constraints.get("materialKit", True): # 齐套约束开关
|
||
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": "MAJOR",
|
||
"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) # 排产游标:基准与齐套取晚者
|
||
po_start, po_end = cursor, cursor # PO 起止(回填)
|
||
po_id = next_id("productionOrder") # 发号
|
||
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["rushStrategy"],
|
||
}
|
||
world["productionOrders"].append(production_order) # 入库
|
||
version["poCount"] += 1 # 版本计数
|
||
|
||
# ---- 逐工序占槽(核心: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"]) # 工序
|
||
# 工时 = 准备时间 + 数量×单件时间/产线效率(legacy 公式原样)
|
||
duration_min = step["setupTime"] + (item["quantity"] * step["runTimePerUnit"]) / (line.get("efficiencyFactor") or 1)
|
||
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": "",
|
||
}
|
||
# 该工序的物料需求与齐套状态(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 结束
|
||
due = parse_dt(so["deliveryDate"] + " 18:00") # 交期基准 18:00
|
||
if po_end > due: # 晚于交期 → 延迟冲突
|
||
hours_late = (po_end - due).total_seconds() / 3600 # 延迟小时
|
||
version["totalTardiness"] += hours_late # 累计延迟
|
||
conflicts.append({"conflictType": "DELAY", "severity": "MAJOR", "productionOrderId": po_id,
|
||
"resourceType": "TIME",
|
||
"description": f"{production_order['orderNo']} 完成时间晚于交期 {hours_late:.1f} 小时",
|
||
"suggestedSolution": "启用加班/替代产线/压缩准备时间"})
|
||
|
||
# ---- ⑥ 容量冲突检测(仅扫本版本工单,修正 legacy 跨版本误报)----
|
||
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 = (parse_dt(wo["plannedEndTime"]) - parse_dt(wo["plannedStartTime"])).total_seconds() / 60 # 分钟
|
||
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)----
|
||
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
|
||
ws_t, we_t = parse_dt(wo["plannedStartTime"]), parse_dt(wo["plannedEndTime"]) # 工单时段
|
||
if ws_t < me and we_t > ms: # 时段重叠 → 设备冲突
|
||
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 = (parse_dt(wo["plannedEndTime"]) - parse_dt(wo["plannedStartTime"])).total_seconds() / 60 # 分钟
|
||
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 # 成本(演示口径)
|
||
|
||
# ---- ⑩ 排产日志(审计的业务侧留痕)----
|
||
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)})
|
||
|
||
# ---- ⑪ 输出结果摘要(契约校验后返回)----
|
||
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=[f"run:{version['versionNo']}"], # 证据引用:run-id(§3.4)
|
||
)
|
||
|
||
# ---------------- 占槽(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 # 本班次内无槽
|