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,
|
|
|
|
|
|
)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-26 00:25:46 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 11:05:57 +08:00
|
|
|
|
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/冲突/日志)。"""
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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}) # 加入待排集
|
|
|
|
|
|
|
2026-07-23 13:38:43 +08:00
|
|
|
|
# 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 # 策略模板名
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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)
|
2026-08-26 00:25:46 +08:00
|
|
|
|
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)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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
|
|
|
|
|
2026-07-23 13:38:43 +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-08-26 00:25:46 +08:00
|
|
|
|
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)
|
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, # 触发类型
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"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, # 版本链
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"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}", # 策略留痕
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"constraintProfile": profile_snapshot(world),
|
|
|
|
|
|
"campaign": campaign_meta,
|
2026-07-21 11:05:57 +08:00
|
|
|
|
}
|
2026-07-23 13:38:43 +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)) # 默认明天零点
|
2026-07-23 13:38:43 +08:00
|
|
|
|
# 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(版本内冲突检测范围)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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"] # 解构
|
2026-08-26 00:25:46 +08:00
|
|
|
|
entry_timing = entry.get("_cpOperationTiming") if direct_cp_timing else None
|
2026-07-21 11:05:57 +08:00
|
|
|
|
product = next(m for m in world["materials"] if m["id"] == item["productId"]) # 产品主数据
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-23 13:38:43 +08:00
|
|
|
|
# 选线: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) # 产线对象
|
|
|
|
|
|
|
2026-07-23 13:38:43 +08:00
|
|
|
|
# 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-08-26 00:25:46 +08:00
|
|
|
|
if direct_cp_timing:
|
|
|
|
|
|
assert isinstance(entry_timing, list)
|
|
|
|
|
|
matrix_extra = float(entry_timing[0].get("changeoverMin") or 0)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-23 13:38:43 +08:00
|
|
|
|
# 物料齐套:库存不足但在途可补 → 齐套时间推迟 3 天;全缺 → 缺料冲突(SC-04 可关/软硬)
|
2026-07-21 11:05:57 +08:00
|
|
|
|
material_ready = base_start # 默认立即齐套
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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: # 在途也不够 → 缺料
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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) # 排产游标:基准与齐套取晚者
|
2026-08-26 00:25:46 +08:00
|
|
|
|
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 时间",
|
|
|
|
|
|
})
|
2026-07-21 11:05:57 +08:00
|
|
|
|
po_start, po_end = cursor, cursor # PO 起止(回填)
|
|
|
|
|
|
po_id = next_id("productionOrder") # 发号
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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",
|
2026-08-11 00:54:05 +08:00
|
|
|
|
"conflictCount": 0, "isRushOrder": so["isRush"], "rushStrategy": so.get("rushStrategy"),
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"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
|
|
|
|
}
|
2026-07-23 13:38:43 +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 # 版本计数
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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"]) # 工序
|
2026-07-23 13:38:43 +08:00
|
|
|
|
# 工时 = 准备时间 +(首道跨族换型)+ 数量×单件时间/产线效率
|
|
|
|
|
|
setup = step["setupTime"] + (matrix_extra if idx == 0 else 0)
|
|
|
|
|
|
duration_min = setup + (item["quantity"] * step["runTimePerUnit"]) / (line.get("efficiencyFactor") or 1)
|
2026-08-26 00:25:46 +08:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-07-21 11:05:57 +08:00
|
|
|
|
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": "",
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"changeoverMin": matrix_extra if idx == 0 else 0,
|
2026-08-26 00:25:46 +08:00
|
|
|
|
"schedulingVersionId": version_id,
|
2026-07-21 11:05:57 +08:00
|
|
|
|
}
|
2026-08-26 00:25:46 +08:00
|
|
|
|
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,
|
|
|
|
|
|
})
|
2026-07-23 13:38:43 +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 结束
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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
|
2026-07-23 13:38:43 +08: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 # 累计延迟
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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 跨版本误报)----
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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] # 开始日期
|
2026-08-26 00:25:46 +08:00
|
|
|
|
dur = _processing_minutes(wo) # C7:开工日归集 processing,不计暂停包络
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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)----
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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
|
2026-08-26 00:25:46 +08:00
|
|
|
|
if any(ws_t < me and we_t > ms for ws_t, we_t in _processing_intervals(wo)):
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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: # 累计占用
|
2026-08-26 00:25:46 +08:00
|
|
|
|
dur = _processing_minutes(wo)
|
2026-07-21 11:05:57 +08:00
|
|
|
|
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 # 成本(演示口径)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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)})
|
|
|
|
|
|
|
|
|
|
|
|
# ---- ⑪ 输出结果摘要(契约校验后返回)----
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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"],
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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 # 本班次内无槽
|