aps-agent/server/engines/rule_engine.py

365 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 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 # 本班次内无槽