aps-agent/server/engines/rule_engine.py

659 lines
44 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.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 # 本班次内无槽