aps-agent/server/engines/rule_engine.py

530 lines
38 KiB
Python
Raw Normal View History

2026-07-21 11:05:57 +08:00
# ============================================================
# 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
2026-07-21 11:05:57 +08:00
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/冲突/日志)。"""
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
2026-07-21 11:05:57 +08:00
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
2026-07-21 11:05:57 +08:00
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 掺入客户等级权重)----
2026-07-21 11:05:57 +08:00
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)
return (forecast, lw, so["priority"], so["deliveryDate"], rush) # 综合
all_items.sort(key=sort_key) # 应用排序
return all_items, campaign_meta, source_order_count
2026-07-21 11:05:57 +08:00
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
2026-07-21 11:05:57 +08:00
# ---- ③ 创建版本对象(版本链: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, # 如实记录请求引擎
2026-07-21 11:05:57 +08:00
"status": "DRAFT", # 新版本为草稿(发布走 P2 门禁)
"parentVersionId": world["scheduleVersions"][-1]["id"] if world["scheduleVersions"] else None, # 版本链
"orderCount": source_order_count, "poCount": 0, "woCount": 0,
2026-07-21 11:05:57 +08:00
"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,
2026-07-21 11:05:57 +08:00
}
if solver_meta:
version["solverMeta"] = solver_meta
2026-07-21 11:05:57 +08:00
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))
2026-07-21 11:05:57 +08:00
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)))
2026-07-21 11:05:57 +08:00
# ---- ⑤ 逐订单项排产 ----
for entry in all_items: # 遍历待排项
so, item = entry["so"], entry["item"] # 解构
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)
2026-07-21 11:05:57 +08:00
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"]
2026-07-21 11:05:57 +08:00
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)
2026-07-21 11:05:57 +08:00
# 展开工艺步骤并绑定工位;缺工位 → 致命冲突并跳过
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 可关/软硬)
2026-07-21 11:05:57 +08:00
material_ready = base_start # 默认立即齐套
kit_sev = material_shortage_severity(world)
if params.constraints.get("materialKit", True) and kit_sev: # 齐套约束开关
2026-07-21 11:05:57 +08:00
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,
2026-07-21 11:05:57 +08:00
"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") # 发号
camp = entry.get("campaign") or {}
camp_sources = camp.get("sourceOrderNos") or [so["orderNo"]]
is_camp = bool(entry.get("isCampaign"))
2026-07-21 11:05:57 +08:00
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"),
2026-07-21 11:05:57 +08:00
}
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')}"
)
2026-07-21 11:05:57 +08:00
world["productionOrders"].append(production_order) # 入库
version["poCount"] += 1 # 版本计数
if matrix_extra > 0:
total_changeover += matrix_extra
2026-07-21 11:05:57 +08:00
# ---- 逐工序占槽(核心: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)
2026-07-21 11:05:57 +08:00
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,
2026-07-21 11:05:57 +08:00
}
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
2026-07-21 11:05:57 +08:00
# 该工序的物料需求与齐套状态(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 记本线末族
2026-07-21 11:05:57 +08:00
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)
2026-07-21 11:05:57 +08:00
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": "启用加班/替代产线/压缩准备时间"})
2026-07-21 11:05:57 +08:00
# ---- ⑥ 容量冲突检测(仅扫本版本工单,修正 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 = (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": "分流至替代产线或启用加班"})
2026-07-21 11:05:57 +08:00
# ---- ⑦ 设备维保冲突检测(仅扫本版本工单;已取消维保不算窗口 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"): # 取消/完成的维保不避让
2026-07-21 11:05:57 +08:00
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": "调整工单时间或启用替代设备"})
2026-07-21 11:05:57 +08:00
# ---- ⑧ 冲突入库并回填计数 ----
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 # 成本(演示口径)
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)
2026-07-21 11:05:57 +08:00
# ---- ⑩ 排产日志(审计的业务侧留痕)----
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']}")
2026-07-21 11:05:57 +08:00
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,
2026-07-21 11:05:57 +08:00
)
# ---------------- 占槽(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 # 本班次内无槽