2026-07-23 13:38:43 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
# 柔性排产引擎 PoolEngine(moduleId: engines-pool, 可重生 ✅, 黄金测试 tests/golden/test_pool_engine.py)
|
|
|
|
|
|
# 吸收 demand/ 康尼芜湖方案:设备能力池 + 虚拟产线 + 瓶颈锚 / 正排 / 倒排。
|
|
|
|
|
|
#
|
|
|
|
|
|
# 与固定产线 RuleEngine 的本质区别:
|
|
|
|
|
|
# RuleEngine:订单 → 选一条固定产线 → 工序绑该线工位 → 占工位槽
|
|
|
|
|
|
# PoolEngine:订单 → 每道工序从"能力池"(拥有该工序能力的设备集合)动态取设备
|
|
|
|
|
|
# → 组装一条"虚拟产线"(VL)→ 占设备槽(含模具换型与设备移动耗时)
|
|
|
|
|
|
#
|
|
|
|
|
|
# 数据来源:world 的 flex* 键(见 server/state/seed.py 与 docs/product/demand-data-intake.md)。
|
|
|
|
|
|
# 产物:写入 flexScheduleVersions / flexVirtualLines / flexWorkOrders / flexConflicts。
|
|
|
|
|
|
# 权力等级 P1:只改内存 world,落盘由调用方决定。
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
from __future__ import annotations # 前向类型引用
|
|
|
|
|
|
|
|
|
|
|
|
from datetime import datetime # 时间类型
|
|
|
|
|
|
from typing import Any, Callable # 类型标注
|
|
|
|
|
|
|
|
|
|
|
|
from server.timeutil import add_minutes, fmt_date, fmt_dt, parse_dt, today0 # 日期工具
|
|
|
|
|
|
|
|
|
|
|
|
World = dict[str, Any] # 世界状态类型别名
|
|
|
|
|
|
|
|
|
|
|
|
# 排产模式
|
|
|
|
|
|
SORT_ASC = "ASC" # 正排:自开始日向后
|
|
|
|
|
|
SORT_DESC = "DESC" # 倒排:自交期向前(本切片用交期升序近似)
|
|
|
|
|
|
SORT_BOTTLENECK = "BOTTLENECK" # 瓶颈锚:瓶颈工序优先保障
|
2026-08-26 00:25:46 +08:00
|
|
|
|
SORT_KITTING_FIRST = "KITTING_FIRST" # 齐套优先:备料完成订单先排
|
|
|
|
|
|
SORT_SKILL_FIRST = "SKILL_FIRST" # 技能优先:高技能需求订单先排
|
2026-07-23 13:38:43 +08:00
|
|
|
|
|
|
|
|
|
|
# 滚动窗口别名 → 默认时长(可被 flexParams.rollingWindows 覆盖)
|
|
|
|
|
|
WINDOW_DEFAULTS = {"realtime": "60m", "short": "2h", "mid": "2d", "long": "7d", "full": "30d"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_duration(spec: str | None) -> int:
|
|
|
|
|
|
"""解析 '2h'/'2d'/'7d'/'120m' → 分钟数。"""
|
|
|
|
|
|
if not spec:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
s = str(spec).strip().lower()
|
|
|
|
|
|
if s.endswith("d"):
|
|
|
|
|
|
return int(float(s[:-1]) * 24 * 60)
|
|
|
|
|
|
if s.endswith("h"):
|
|
|
|
|
|
return int(float(s[:-1]) * 60)
|
|
|
|
|
|
if s.endswith("m"):
|
|
|
|
|
|
return int(float(s[:-1]))
|
|
|
|
|
|
return int(float(s))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resolve_window_minutes(params: dict, window: str | None) -> int | None:
|
|
|
|
|
|
"""按 realtime/short/mid/long/full 解析滚动时域分钟;None=不限制(等同 full)。"""
|
|
|
|
|
|
if not window or window.upper() in ("FULL", "ALL", "L4"):
|
|
|
|
|
|
return None
|
|
|
|
|
|
key = window.lower()
|
|
|
|
|
|
if key in ("l2",):
|
|
|
|
|
|
key = "short"
|
|
|
|
|
|
elif key in ("l3", "day"):
|
|
|
|
|
|
key = "mid"
|
|
|
|
|
|
elif key in ("live", "rt", "minute", "min"):
|
|
|
|
|
|
key = "realtime"
|
|
|
|
|
|
rw = (params or {}).get("rollingWindows") or {}
|
|
|
|
|
|
spec = rw.get(key) or WINDOW_DEFAULTS.get(key)
|
|
|
|
|
|
mins = parse_duration(spec)
|
|
|
|
|
|
return mins or None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def window_step_minutes(window: str | None) -> int:
|
|
|
|
|
|
"""滚动层级对应的排产步长:实时 1min / 短窗 15min / 中窗 60min / 长窗 4h。"""
|
|
|
|
|
|
if not window:
|
|
|
|
|
|
return 15
|
|
|
|
|
|
w = window.lower()
|
|
|
|
|
|
if w in ("realtime", "live", "rt", "minute", "min"):
|
|
|
|
|
|
return 1
|
|
|
|
|
|
if w in ("short", "l2"):
|
|
|
|
|
|
return 15
|
|
|
|
|
|
if w in ("mid", "l3", "day"):
|
|
|
|
|
|
return 60
|
|
|
|
|
|
if w in ("long",):
|
|
|
|
|
|
return 240
|
|
|
|
|
|
return 15
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-26 00:25:46 +08:00
|
|
|
|
def _kit_ready(order: dict[str, Any]) -> int:
|
|
|
|
|
|
status = str(order.get("kitStatus") or "")
|
|
|
|
|
|
return 0 if ("已完成" in status or "齐套" in status or status.upper() == "READY") else 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _skill_rank(level: Any) -> int:
|
|
|
|
|
|
return {"L4": 0, "L3": 1, "L2": 2, "L1": 3}.get(str(level or "L3").upper(), 4)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 13:38:43 +08:00
|
|
|
|
class PoolEngine:
|
|
|
|
|
|
"""柔性能力池排产引擎:能力池选设备 + 虚拟产线组装 + 瓶颈感知排序。"""
|
|
|
|
|
|
|
|
|
|
|
|
name = "FLEX" # 引擎标识
|
|
|
|
|
|
|
|
|
|
|
|
def solve(self, world: World, next_id: Callable[[str], int],
|
|
|
|
|
|
sort_mode: str | None = None, order_ids: list[int] | None = None,
|
|
|
|
|
|
start_date: str | None = None, name: str | None = None,
|
|
|
|
|
|
window: str | None = None,
|
|
|
|
|
|
seed_busy: dict[int, list[tuple[datetime, datetime]]] | None = None,
|
|
|
|
|
|
enforce_teams: bool | None = None) -> dict[str, Any]:
|
|
|
|
|
|
"""执行一次柔性排产,返回结果摘要 dict。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
world: 世界状态(读 flex* 主数据,写 flex* 产物表)
|
|
|
|
|
|
next_id: 发号函数 next_id(kind)->int
|
2026-08-26 00:25:46 +08:00
|
|
|
|
sort_mode: ASC/DESC/BOTTLENECK/KITTING_FIRST/SKILL_FIRST
|
|
|
|
|
|
(None 时取 flexParams.sortMode)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
order_ids: 目标订单 ID(None/空=全部 RELEASED/CREATED)
|
|
|
|
|
|
start_date: 起始日 YYYY-MM-DD(None=明天)
|
|
|
|
|
|
name: 版本名
|
|
|
|
|
|
window: 滚动窗 short/mid/long/full(SC-12);限制精排时域与步长
|
|
|
|
|
|
seed_busy: 预占用设备区间(窗外冻结工单,DY-01 L2/L3)
|
|
|
|
|
|
enforce_teams: 是否启用班组人力并发约束(SC-11;默认有 flexTeams 则启用)
|
|
|
|
|
|
"""
|
|
|
|
|
|
params = world.get("flexParams", {}) # 柔性参数
|
|
|
|
|
|
mode = (sort_mode or params.get("sortMode") or SORT_BOTTLENECK).upper() # 排产模式
|
2026-08-26 00:25:46 +08:00
|
|
|
|
if mode not in (
|
|
|
|
|
|
SORT_ASC, SORT_DESC, SORT_BOTTLENECK,
|
|
|
|
|
|
SORT_KITTING_FIRST, SORT_SKILL_FIRST,
|
|
|
|
|
|
):
|
2026-07-23 13:38:43 +08:00
|
|
|
|
mode = SORT_BOTTLENECK # 非法回落瓶颈锚
|
|
|
|
|
|
horizon_min = resolve_window_minutes(params, window)
|
|
|
|
|
|
step_min = window_step_minutes(window)
|
|
|
|
|
|
use_teams = enforce_teams if enforce_teams is not None else bool(world.get("flexTeams"))
|
|
|
|
|
|
from server.aps_domain.constraints import is_enabled, material_shortage_severity, profile_snapshot
|
|
|
|
|
|
if enforce_teams is None:
|
|
|
|
|
|
use_teams = use_teams and is_enabled(world, "C12_team")
|
|
|
|
|
|
kit_sev = material_shortage_severity(world)
|
|
|
|
|
|
use_tooling = is_enabled(world, "C12_tooling")
|
|
|
|
|
|
use_freeze = is_enabled(world, "C11_freeze")
|
|
|
|
|
|
|
|
|
|
|
|
# ---- ① 索引主数据 ----
|
|
|
|
|
|
ops_by_code = {o["code"]: o for o in world.get("flexOperations", [])} # 工序索引
|
|
|
|
|
|
equipment = world.get("flexEquipment", []) # 设备清单
|
|
|
|
|
|
molds = world.get("flexMolds", []) # 模具清单
|
|
|
|
|
|
routings = world.get("flexRoutings", []) # 工艺路线
|
|
|
|
|
|
mats_by_code = {m["code"]: m for m in world.get("flexMaterials", [])} # 物料索引
|
|
|
|
|
|
bom = world.get("flexBom", []) # BOM
|
|
|
|
|
|
team_cap = self._team_capacity(world) if use_teams else {} # 工序→可用人数
|
|
|
|
|
|
|
|
|
|
|
|
# ---- ② 收集待排订单 ----
|
|
|
|
|
|
orders = []
|
|
|
|
|
|
for o in world.get("flexOrders", []):
|
|
|
|
|
|
if order_ids and o["id"] not in order_ids: # 指定集过滤
|
|
|
|
|
|
continue
|
|
|
|
|
|
if o["status"] not in ("RELEASED", "CREATED"): # 仅未完成
|
|
|
|
|
|
continue
|
|
|
|
|
|
orders.append(o)
|
|
|
|
|
|
|
|
|
|
|
|
# ---- ③ 派工排序(吸收排产逻辑 PPT:正排 EDD / 倒排最晚优先 / 瓶颈锚)----
|
|
|
|
|
|
if mode == SORT_DESC:
|
|
|
|
|
|
# 倒排:交期最晚的订单先占资源(自交期向前的派工近似)
|
|
|
|
|
|
orders.sort(key=lambda o: (o["dueDate"], o["priority"]), reverse=True)
|
|
|
|
|
|
elif mode == SORT_BOTTLENECK:
|
|
|
|
|
|
# 瓶颈锚:含瓶颈工序的产品订单优先(保障瓶颈资源不被非瓶颈单挤占),再 EDD
|
|
|
|
|
|
orders.sort(key=lambda o: (0 if self._has_bottleneck(o["productCode"], routings, ops_by_code) else 1,
|
|
|
|
|
|
o["dueDate"], o["priority"]))
|
2026-08-26 00:25:46 +08:00
|
|
|
|
elif mode == SORT_KITTING_FIRST:
|
|
|
|
|
|
orders.sort(key=lambda o: (_kit_ready(o), o["dueDate"], o["priority"]))
|
|
|
|
|
|
elif mode == SORT_SKILL_FIRST:
|
|
|
|
|
|
orders.sort(key=lambda o: (_skill_rank(o.get("requiredSkillLevel")),
|
|
|
|
|
|
o["dueDate"], o["priority"]))
|
2026-07-23 13:38:43 +08:00
|
|
|
|
else:
|
|
|
|
|
|
# 正排:EDD(最早交期优先)+ 优先级
|
|
|
|
|
|
orders.sort(key=lambda o: (o["dueDate"], o["priority"]))
|
|
|
|
|
|
|
|
|
|
|
|
# ---- ④ 版本对象 ----
|
2026-08-11 00:54:05 +08:00
|
|
|
|
wall_now = datetime.now()
|
|
|
|
|
|
business_day = parse_dt(start_date + " 00:00") if start_date else today0()
|
|
|
|
|
|
now = business_day.replace(
|
|
|
|
|
|
hour=wall_now.hour,
|
|
|
|
|
|
minute=wall_now.minute,
|
|
|
|
|
|
second=wall_now.second,
|
|
|
|
|
|
microsecond=wall_now.microsecond,
|
|
|
|
|
|
)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
vid = next_id("flexScheduleVersion")
|
|
|
|
|
|
base_start = (parse_dt(start_date + " 08:00") if start_date
|
|
|
|
|
|
else add_minutes(today0(), 24 * 60)) # 默认明天
|
|
|
|
|
|
horizon_end = add_minutes(base_start, horizon_min) if horizon_min else None
|
|
|
|
|
|
version = {
|
|
|
|
|
|
"id": vid,
|
|
|
|
|
|
"versionNo": "FV" + fmt_date(now).replace("-", "") + f"-{len(world['flexScheduleVersions']) + 1:03d}",
|
|
|
|
|
|
"versionName": name or f"柔性排产 {fmt_dt(now)}",
|
|
|
|
|
|
"sortMode": mode, "engineType": "FLEX", "status": "DRAFT",
|
|
|
|
|
|
"window": window or "full",
|
|
|
|
|
|
"windowMinutes": horizon_min,
|
|
|
|
|
|
"stepMinutes": step_min,
|
|
|
|
|
|
"enforceTeams": use_teams,
|
|
|
|
|
|
"orderCount": len(orders), "vlCount": 0, "woCount": 0,
|
|
|
|
|
|
"totalTardiness": 0.0, "avgUtilization": 0.0, "conflictCount": 0,
|
|
|
|
|
|
"bottleneck": self._bottleneck_summary(routings, ops_by_code, equipment),
|
|
|
|
|
|
"constraintProfile": profile_snapshot(world),
|
|
|
|
|
|
"createdBy": "agent", "createdAt": fmt_dt(now),
|
|
|
|
|
|
}
|
|
|
|
|
|
world["flexScheduleVersions"].append(version)
|
|
|
|
|
|
|
|
|
|
|
|
# ---- ⑤ 占用登记(可注入窗外冻结占槽)----
|
|
|
|
|
|
eq_busy: dict[int, list[tuple[datetime, datetime]]] = {
|
|
|
|
|
|
k: list(v) for k, v in (seed_busy or {}).items()
|
|
|
|
|
|
}
|
|
|
|
|
|
eq_last_mold: dict[int, str | None] = {} # 设备 → 上次装的模具(判换型)
|
|
|
|
|
|
eq_used_min: dict[int, float] = {
|
|
|
|
|
|
k: sum((e - s).total_seconds() / 60 for s, e in ivs)
|
|
|
|
|
|
for k, ivs in eq_busy.items()
|
|
|
|
|
|
}
|
|
|
|
|
|
team_busy: dict[str, list[tuple[datetime, datetime]]] = {} # 工序 → 人力占用区间
|
|
|
|
|
|
conflicts: list[dict] = []
|
|
|
|
|
|
new_wo_ids: list[int] = []
|
|
|
|
|
|
deferred = 0
|
|
|
|
|
|
|
|
|
|
|
|
# ---- ⑥ 逐订单组装虚拟产线 ----
|
|
|
|
|
|
for o in orders:
|
|
|
|
|
|
product_steps = self._routing_of(o["productCode"], routings) # 该产品工艺步骤(有序)
|
|
|
|
|
|
if not product_steps:
|
|
|
|
|
|
conflicts.append({"conflictType": "NO_ROUTING", "severity": "CRITICAL",
|
|
|
|
|
|
"resourceType": "ROUTING", "orderNo": o["orderNo"],
|
|
|
|
|
|
"description": f"{o['orderNo']} 产品 {o['productCode']} 无工艺路线",
|
|
|
|
|
|
"suggestedSolution": "维护柔性工艺路线"})
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# 物料齐套检查(SC-04:可关/软硬)
|
|
|
|
|
|
material_ready = base_start
|
|
|
|
|
|
if kit_sev:
|
|
|
|
|
|
for b in [x for x in bom if x["productCode"] == o["productCode"]]:
|
|
|
|
|
|
mat = mats_by_code.get(b["materialCode"])
|
|
|
|
|
|
if not mat:
|
|
|
|
|
|
continue
|
|
|
|
|
|
need = b["quantity"] * o["quantity"]
|
|
|
|
|
|
if mat["stock"] < need and mat["stock"] + mat["inTransit"] >= need:
|
|
|
|
|
|
eta = add_minutes(today0(), 3 * 24 * 60) # 在途补货 +3 天
|
|
|
|
|
|
material_ready = max(material_ready, eta)
|
|
|
|
|
|
elif mat["stock"] + mat["inTransit"] < need:
|
|
|
|
|
|
conflicts.append({"conflictType": "MATERIAL_SHORTAGE", "severity": kit_sev,
|
|
|
|
|
|
"resourceType": "MATERIAL", "resourceName": mat["name"], "orderNo": o["orderNo"],
|
|
|
|
|
|
"description": f"{o['orderNo']} {mat['name']} 缺料 "
|
|
|
|
|
|
f"{int(need - mat['stock'] - mat['inTransit'] + 0.999)} {mat['unit']}",
|
|
|
|
|
|
"suggestedSolution": "紧急采购或启用替代物料"})
|
|
|
|
|
|
|
|
|
|
|
|
# SC-12:滚动短/中窗 — 首道工序若已超出时域则整单延期到下一滚动周期
|
|
|
|
|
|
if use_freeze and horizon_end and material_ready >= horizon_end:
|
|
|
|
|
|
deferred += 1
|
|
|
|
|
|
conflicts.append({"conflictType": "WINDOW_DEFERRED", "severity": "MINOR",
|
|
|
|
|
|
"resourceType": "WINDOW", "orderNo": o["orderNo"],
|
|
|
|
|
|
"description": f"{o['orderNo']} 超出滚动窗"
|
|
|
|
|
|
f"({window or 'custom'}={horizon_min}min),本轮不排",
|
|
|
|
|
|
"suggestedSolution": "扩大滚动窗或等待下一滚动周期"})
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
2026-08-11 00:54:05 +08:00
|
|
|
|
# Per-order atomic boundary: roll back all artifacts and resource state until the full routing succeeds.
|
|
|
|
|
|
order_wo_start = len(world["flexWorkOrders"])
|
|
|
|
|
|
order_vl_start = len(world["flexVirtualLines"])
|
|
|
|
|
|
version_wo_before = version["woCount"]
|
|
|
|
|
|
version_vl_before = version["vlCount"]
|
|
|
|
|
|
eq_busy_before = {key: list(intervals) for key, intervals in eq_busy.items()}
|
|
|
|
|
|
eq_last_mold_before = dict(eq_last_mold)
|
|
|
|
|
|
eq_used_min_before = dict(eq_used_min)
|
|
|
|
|
|
team_busy_before = {key: list(intervals) for key, intervals in team_busy.items()}
|
|
|
|
|
|
mold_before = [(mold, mold.get("lifeUsed"), mold.get("status")) for mold in molds]
|
|
|
|
|
|
conflict_boundary = len(conflicts)
|
|
|
|
|
|
|
2026-07-23 13:38:43 +08:00
|
|
|
|
vl_id = next_id("flexVirtualLine")
|
|
|
|
|
|
vl = {
|
|
|
|
|
|
"id": vl_id, "vlNo": f"VL-{o['orderNo']}", "versionId": vid,
|
|
|
|
|
|
"orderNo": o["orderNo"], "productCode": o["productCode"],
|
|
|
|
|
|
"quantity": o["quantity"], "wbs": o.get("wbs"),
|
|
|
|
|
|
"productionController": o.get("productionController"),
|
|
|
|
|
|
"assignments": [], "plannedStart": None, "plannedEnd": None,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cursor = max(base_start, material_ready) # 排产游标
|
|
|
|
|
|
vl_start = None
|
|
|
|
|
|
vl_end = cursor
|
|
|
|
|
|
ok = True
|
|
|
|
|
|
for step in product_steps: # 逐工序占设备
|
|
|
|
|
|
op = ops_by_code.get(step["operationCode"])
|
|
|
|
|
|
need_mold = bool(step.get("requireMold", False)) and use_tooling
|
|
|
|
|
|
op_code = step["operationCode"]
|
|
|
|
|
|
# 能力池:拥有该工序能力且状态可用的设备
|
|
|
|
|
|
pool = [e for e in equipment
|
|
|
|
|
|
if op_code in e.get("capabilities", []) and e.get("status") == "RUNNING"]
|
|
|
|
|
|
if not pool:
|
|
|
|
|
|
conflicts.append({"conflictType": "NO_CAPABILITY", "severity": "CRITICAL",
|
|
|
|
|
|
"resourceType": "EQUIPMENT", "orderNo": o["orderNo"],
|
|
|
|
|
|
"description": f"{o['orderNo']} 工序 {op_code} 无能力设备",
|
|
|
|
|
|
"suggestedSolution": "为设备登记该工序能力或采购设备"})
|
|
|
|
|
|
ok = False
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
# SC-11:无班组覆盖该工序 → 冲突(有班组数据时)
|
|
|
|
|
|
if use_teams and team_cap and op_code not in team_cap:
|
|
|
|
|
|
conflicts.append({"conflictType": "NO_TEAM", "severity": "CRITICAL",
|
|
|
|
|
|
"resourceType": "TEAM", "orderNo": o["orderNo"],
|
|
|
|
|
|
"description": f"{o['orderNo']} 工序 {op_code} 无班组资格覆盖",
|
|
|
|
|
|
"suggestedSolution": "为班组登记 supportOps 或外包该工序"})
|
|
|
|
|
|
ok = False
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
# 选模具(若需要):按工序取可用且有适配设备的模具
|
|
|
|
|
|
chosen_mold = None
|
|
|
|
|
|
if need_mold:
|
|
|
|
|
|
cands = [m for m in molds if m["operationCode"] == op_code
|
|
|
|
|
|
and m.get("status") == "AVAILABLE" and m["lifeUsed"] < m["lifeTotal"]]
|
|
|
|
|
|
if not cands:
|
|
|
|
|
|
conflicts.append({"conflictType": "NO_MOLD", "severity": "CRITICAL",
|
|
|
|
|
|
"resourceType": "MOLD", "orderNo": o["orderNo"],
|
|
|
|
|
|
"description": f"{o['orderNo']} 工序 {op_code} 无可用模具",
|
|
|
|
|
|
"suggestedSolution": "更换/采购模具或解除锁定"})
|
|
|
|
|
|
ok = False
|
|
|
|
|
|
break
|
|
|
|
|
|
chosen_mold = cands[0] # MVP:取第一副可用模具
|
|
|
|
|
|
# 池收敛到能装该模具的设备
|
|
|
|
|
|
pool = [e for e in pool if e["code"] in chosen_mold["adaptableEquipment"]] or pool
|
|
|
|
|
|
|
|
|
|
|
|
# 选设备:能力池内取累计占用最少者(负载均衡 → 虚拟产线动态组装)
|
|
|
|
|
|
chosen = min(pool, key=lambda e: eq_used_min.get(e["id"], 0.0))
|
|
|
|
|
|
|
|
|
|
|
|
# 工时 = 单件工时 × 数量 / 可动率;单件工时优先取路线覆盖,否则设备默认
|
|
|
|
|
|
per = step.get("stdTimePerUnit") or chosen.get("opStdTime", {}).get(op_code, 1)
|
|
|
|
|
|
run_min = (per * o["quantity"]) / (chosen.get("availabilityRate") or 1)
|
|
|
|
|
|
# 换型:换模具或工序自身换型时间
|
|
|
|
|
|
changeover = op.get("changeoverMin", 0) if op else 0
|
|
|
|
|
|
if need_mold and eq_last_mold.get(chosen["id"]) != chosen_mold["code"]:
|
|
|
|
|
|
changeover = max(changeover, chosen_mold.get("changeoverMin", 0))
|
|
|
|
|
|
# 设备移动:可移动设备加一次移动耗时
|
|
|
|
|
|
move_min = chosen.get("moveTimeMin", 0) if chosen.get("movable") else 0
|
|
|
|
|
|
duration = changeover + move_min + run_min
|
|
|
|
|
|
|
|
|
|
|
|
start, end = self._place(
|
|
|
|
|
|
cursor, duration, chosen["id"], eq_busy, world,
|
|
|
|
|
|
step_min=step_min, horizon_end=horizon_end,
|
|
|
|
|
|
team_op=op_code if use_teams else None,
|
|
|
|
|
|
team_busy=team_busy, team_capacity=team_cap.get(op_code, 0) if use_teams else 0,
|
|
|
|
|
|
)
|
2026-08-11 00:54:05 +08:00
|
|
|
|
# A routing that crosses the rolling window fails as a whole; no partial work orders survive.
|
2026-07-23 13:38:43 +08:00
|
|
|
|
if use_freeze and horizon_end and start >= horizon_end:
|
|
|
|
|
|
conflicts.append({"conflictType": "WINDOW_TRUNCATED", "severity": "MINOR",
|
|
|
|
|
|
"resourceType": "WINDOW", "orderNo": o["orderNo"],
|
|
|
|
|
|
"description": f"{o['orderNo']} 工序 {op_code} 越出滚动窗,后续步骤本轮不排",
|
|
|
|
|
|
"suggestedSolution": "下一滚动周期继续排或升窗到 mid/long"})
|
2026-08-11 00:54:05 +08:00
|
|
|
|
deferred += 1
|
|
|
|
|
|
ok = False
|
2026-07-23 13:38:43 +08:00
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
wo_id = next_id("flexWorkOrder")
|
|
|
|
|
|
wo = {
|
|
|
|
|
|
"id": wo_id, "orderNo": f"{o['orderNo']}-{step['seq']}", "versionId": vid, "vlId": vl_id,
|
|
|
|
|
|
"flexOrderNo": o["orderNo"], "productCode": o["productCode"], "quantity": o["quantity"],
|
|
|
|
|
|
"operationCode": op_code,
|
|
|
|
|
|
"operationName": op["name"] if op else op_code, "seq": step["seq"],
|
|
|
|
|
|
"equipmentId": chosen["id"], "equipmentCode": chosen["code"], "equipmentName": chosen["name"],
|
|
|
|
|
|
"moldCode": chosen_mold["code"] if chosen_mold else None,
|
|
|
|
|
|
"zone": chosen.get("zone"),
|
|
|
|
|
|
"teamCode": self._pick_team(world, op_code),
|
|
|
|
|
|
"changeoverMin": round(changeover, 1), "moveMin": round(move_min, 1),
|
|
|
|
|
|
"runMin": round(run_min, 1),
|
|
|
|
|
|
"plannedStartTime": fmt_dt(start), "plannedEndTime": fmt_dt(end),
|
|
|
|
|
|
"isBottleneck": bool(op and op.get("isBottleneck")),
|
|
|
|
|
|
"status": "PENDING", "frozen": False,
|
|
|
|
|
|
}
|
|
|
|
|
|
world["flexWorkOrders"].append(wo)
|
|
|
|
|
|
new_wo_ids.append(wo_id)
|
|
|
|
|
|
vl["assignments"].append({
|
|
|
|
|
|
"seq": step["seq"], "operationCode": op_code,
|
|
|
|
|
|
"equipmentCode": chosen["code"], "moldCode": wo["moldCode"],
|
|
|
|
|
|
"start": wo["plannedStartTime"], "end": wo["plannedEndTime"],
|
|
|
|
|
|
})
|
|
|
|
|
|
# 登记占用/负载/模具/人力/游标
|
|
|
|
|
|
eq_busy.setdefault(chosen["id"], []).append((start, end))
|
|
|
|
|
|
eq_used_min[chosen["id"]] = eq_used_min.get(chosen["id"], 0.0) + (end - start).total_seconds() / 60
|
|
|
|
|
|
if use_teams and op_code:
|
|
|
|
|
|
team_busy.setdefault(op_code, []).append((start, end))
|
|
|
|
|
|
if chosen_mold:
|
|
|
|
|
|
eq_last_mold[chosen["id"]] = chosen_mold["code"]
|
|
|
|
|
|
# SC-10 / MD-07:排产占用消耗模具寿命;到限自动锁定
|
|
|
|
|
|
chosen_mold["lifeUsed"] = int(chosen_mold.get("lifeUsed", 0)) + int(o["quantity"])
|
|
|
|
|
|
if chosen_mold["lifeUsed"] >= int(chosen_mold.get("lifeTotal") or 0):
|
|
|
|
|
|
chosen_mold["status"] = "LOCKED"
|
|
|
|
|
|
conflicts.append({
|
|
|
|
|
|
"conflictType": "MOLD_LIFE", "severity": "MAJOR",
|
|
|
|
|
|
"resourceType": "MOLD", "resourceName": chosen_mold["code"],
|
|
|
|
|
|
"orderNo": o["orderNo"],
|
|
|
|
|
|
"description": f"模具 {chosen_mold['code']} 寿命已用尽"
|
|
|
|
|
|
f"({chosen_mold['lifeUsed']}/{chosen_mold['lifeTotal']}),已锁定",
|
|
|
|
|
|
"suggestedSolution": "更换模具或重置寿命后解除锁定",
|
|
|
|
|
|
})
|
|
|
|
|
|
cursor = end # 下一工序自本工序结束起(工序 precedence)
|
|
|
|
|
|
vl_start = vl_start or start
|
|
|
|
|
|
vl_end = end
|
|
|
|
|
|
version["woCount"] += 1
|
|
|
|
|
|
|
2026-08-11 00:54:05 +08:00
|
|
|
|
if not ok:
|
|
|
|
|
|
# Roll back candidate artifacts and mutable capacity/tooling state for this order.
|
|
|
|
|
|
del world["flexWorkOrders"][order_wo_start:]
|
|
|
|
|
|
del world["flexVirtualLines"][order_vl_start:]
|
|
|
|
|
|
version["woCount"] = version_wo_before
|
|
|
|
|
|
version["vlCount"] = version_vl_before
|
|
|
|
|
|
eq_busy = eq_busy_before
|
|
|
|
|
|
eq_last_mold = eq_last_mold_before
|
|
|
|
|
|
eq_used_min = eq_used_min_before
|
|
|
|
|
|
team_busy = team_busy_before
|
|
|
|
|
|
for mold, life_used, status in mold_before:
|
|
|
|
|
|
mold["lifeUsed"] = life_used
|
|
|
|
|
|
mold["status"] = status
|
|
|
|
|
|
conflicts[conflict_boundary:] = [
|
|
|
|
|
|
conflict for conflict in conflicts[conflict_boundary:]
|
|
|
|
|
|
if conflict.get("conflictType") != "MOLD_LIFE"
|
|
|
|
|
|
]
|
2026-07-23 13:38:43 +08:00
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if not vl["assignments"]:
|
2026-08-11 00:54:05 +08:00
|
|
|
|
# Defensive rollback: an empty candidate can never become a production/MES object.
|
|
|
|
|
|
del world["flexWorkOrders"][order_wo_start:]
|
|
|
|
|
|
version["woCount"] = version_wo_before
|
|
|
|
|
|
eq_busy = eq_busy_before
|
|
|
|
|
|
eq_last_mold = eq_last_mold_before
|
|
|
|
|
|
eq_used_min = eq_used_min_before
|
|
|
|
|
|
team_busy = team_busy_before
|
|
|
|
|
|
for mold, life_used, status in mold_before:
|
|
|
|
|
|
mold["lifeUsed"] = life_used
|
|
|
|
|
|
mold["status"] = status
|
2026-07-23 13:38:43 +08:00
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
vl["plannedStart"] = fmt_dt(vl_start) if vl_start else None
|
|
|
|
|
|
vl["plannedEnd"] = fmt_dt(vl_end)
|
|
|
|
|
|
world["flexVirtualLines"].append(vl)
|
|
|
|
|
|
version["vlCount"] += 1
|
|
|
|
|
|
|
|
|
|
|
|
# 交期延迟冲突
|
|
|
|
|
|
due = parse_dt(o["dueDate"] + " 18:00")
|
|
|
|
|
|
if vl_end > due:
|
|
|
|
|
|
hours = (vl_end - due).total_seconds() / 3600
|
|
|
|
|
|
version["totalTardiness"] += hours
|
|
|
|
|
|
conflicts.append({"conflictType": "DELAY", "severity": "MAJOR",
|
|
|
|
|
|
"resourceType": "TIME", "orderNo": o["orderNo"],
|
|
|
|
|
|
"description": f"{o['orderNo']} 预计完成晚于交期 {hours:.1f} 小时",
|
|
|
|
|
|
"suggestedSolution": "提高优先级/增派瓶颈设备/分批"})
|
|
|
|
|
|
|
|
|
|
|
|
# ---- ⑦ 冲突入库 ----
|
|
|
|
|
|
for cf in conflicts:
|
|
|
|
|
|
cf["id"] = next_id("flexConflict")
|
|
|
|
|
|
cf["versionId"] = vid
|
|
|
|
|
|
cf["isResolved"] = False
|
|
|
|
|
|
world["flexConflicts"].append(cf)
|
|
|
|
|
|
version["conflictCount"] = len(conflicts)
|
|
|
|
|
|
version["deferredCount"] = deferred
|
2026-08-11 00:54:05 +08:00
|
|
|
|
# Recompute counters from committed rows so failed candidates can never drift version metadata.
|
|
|
|
|
|
version["vlCount"] = sum(
|
|
|
|
|
|
1 for vl in world["flexVirtualLines"] if vl.get("versionId") == vid
|
|
|
|
|
|
)
|
|
|
|
|
|
version["woCount"] = sum(
|
|
|
|
|
|
1 for wo in world["flexWorkOrders"] if wo.get("versionId") == vid
|
|
|
|
|
|
)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
|
|
|
|
|
|
# ---- ⑧ 设备利用率(瓶颈产能法的观测基础)----
|
|
|
|
|
|
version["avgUtilization"] = self._utilization(world, vid, base_start,
|
|
|
|
|
|
params.get("afterDays", 30))
|
|
|
|
|
|
|
|
|
|
|
|
# ---- ⑨ makespan(最晚完工)与准时单数 ----
|
|
|
|
|
|
my_vls = [v for v in world["flexVirtualLines"] if v["versionId"] == vid]
|
|
|
|
|
|
makespan = max((v["plannedEnd"] for v in my_vls), default=None)
|
|
|
|
|
|
on_time = 0
|
|
|
|
|
|
for v in my_vls:
|
|
|
|
|
|
o = next((x for x in world["flexOrders"] if x["orderNo"] == v["orderNo"]), None)
|
|
|
|
|
|
if o and v["plannedEnd"] and parse_dt(v["plannedEnd"]) <= parse_dt(o["dueDate"] + " 18:00"):
|
|
|
|
|
|
on_time += 1
|
|
|
|
|
|
version["makespan"] = makespan
|
|
|
|
|
|
version["onTimeCount"] = on_time
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"versionId": vid, "versionNo": version["versionNo"], "engineType": "FLEX",
|
|
|
|
|
|
"sortMode": mode, "status": "DRAFT",
|
|
|
|
|
|
"window": version["window"], "windowMinutes": horizon_min, "stepMinutes": step_min,
|
|
|
|
|
|
"enforceTeams": use_teams, "deferredCount": deferred,
|
|
|
|
|
|
"orderCount": version["orderCount"], "vlCount": version["vlCount"], "woCount": version["woCount"],
|
|
|
|
|
|
"conflictCount": version["conflictCount"], "totalTardiness": version["totalTardiness"],
|
|
|
|
|
|
"avgUtilization": version["avgUtilization"], "bottleneck": version["bottleneck"],
|
|
|
|
|
|
"makespan": makespan, "onTimeCount": on_time,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 占槽:设备级 + 可选班组并发(SC-11) ----------------
|
|
|
|
|
|
def _place(self, cursor: datetime, duration_min: float, eq_id: int,
|
|
|
|
|
|
eq_busy: dict[int, list[tuple[datetime, datetime]]], world: World,
|
|
|
|
|
|
step_min: int = 15, horizon_end: datetime | None = None,
|
|
|
|
|
|
team_op: str | None = None,
|
|
|
|
|
|
team_busy: dict[str, list[tuple[datetime, datetime]]] | None = None,
|
|
|
|
|
|
team_capacity: int = 0) -> tuple[datetime, datetime]:
|
|
|
|
|
|
"""在设备时间线上找首个不与已占区间重叠的起点;可选班组并发上限。"""
|
|
|
|
|
|
shifts = world.get("flexCalendar", [])
|
|
|
|
|
|
workdays = shifts[0].get("workdays", [1, 2, 3, 4, 5]) if shifts else [1, 2, 3, 4, 5]
|
|
|
|
|
|
intervals = eq_busy.get(eq_id, [])
|
|
|
|
|
|
t = cursor
|
|
|
|
|
|
max_steps = max(30 * 24 * 4, int((30 * 24 * 60) / max(step_min, 1)))
|
|
|
|
|
|
for _ in range(max_steps):
|
|
|
|
|
|
if horizon_end and t >= horizon_end:
|
|
|
|
|
|
break
|
|
|
|
|
|
if (t.isoweekday() in workdays) and self._in_shift(t, shifts):
|
|
|
|
|
|
end = add_minutes(t, duration_min)
|
|
|
|
|
|
overlap = any(t < iv_end and end > iv_start for iv_start, iv_end in intervals)
|
|
|
|
|
|
team_ok = True
|
|
|
|
|
|
if team_op and team_capacity > 0 and team_busy is not None:
|
|
|
|
|
|
concurrent = sum(1 for s, e in team_busy.get(team_op, []) if t < e and end > s)
|
|
|
|
|
|
team_ok = concurrent < team_capacity
|
|
|
|
|
|
if not overlap and team_ok:
|
|
|
|
|
|
return (t, end)
|
|
|
|
|
|
t = add_minutes(t, step_min)
|
|
|
|
|
|
return (cursor, add_minutes(cursor, duration_min)) # 兜底硬排
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _team_capacity(world: World) -> dict[str, int]:
|
|
|
|
|
|
"""工序 → 可用班组人数合计(supportOps 覆盖)。"""
|
|
|
|
|
|
cap: dict[str, int] = {}
|
|
|
|
|
|
for team in world.get("flexTeams", []) or []:
|
|
|
|
|
|
n = int(team.get("memberCount") or 0)
|
|
|
|
|
|
for op in team.get("supportOps") or []:
|
|
|
|
|
|
cap[op] = cap.get(op, 0) + n
|
|
|
|
|
|
return cap
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _pick_team(world: World, op_code: str) -> str | None:
|
|
|
|
|
|
"""为工序挑一个覆盖班组编码(展示用)。"""
|
|
|
|
|
|
for team in world.get("flexTeams", []) or []:
|
|
|
|
|
|
if op_code in (team.get("supportOps") or []):
|
|
|
|
|
|
return team.get("code")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _in_shift(t: datetime, shifts: list[dict]) -> bool:
|
|
|
|
|
|
"""t 是否落在任一班次工作时段内(扣除休息段)。"""
|
|
|
|
|
|
if not shifts:
|
|
|
|
|
|
return True
|
|
|
|
|
|
for sh in shifts:
|
|
|
|
|
|
s = datetime.strptime(sh["startTime"], "%H:%M").time()
|
|
|
|
|
|
e = datetime.strptime(sh["endTime"], "%H:%M").time()
|
|
|
|
|
|
if s <= t.time() < e:
|
|
|
|
|
|
for bp in sh.get("breaks", []):
|
|
|
|
|
|
bs = datetime.strptime(bp["start"], "%H:%M").time()
|
|
|
|
|
|
be = datetime.strptime(bp["end"], "%H:%M").time()
|
|
|
|
|
|
if bs <= t.time() < be:
|
|
|
|
|
|
return False # 落在休息段
|
|
|
|
|
|
return True
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 辅助 ----------------
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _routing_of(product_code: str, routings: list[dict]) -> list[dict]:
|
|
|
|
|
|
"""取产品工艺步骤(按 seq 升序)。"""
|
|
|
|
|
|
steps = [r for r in routings if r["productCode"] == product_code]
|
|
|
|
|
|
return sorted(steps, key=lambda r: r["seq"])
|
|
|
|
|
|
|
|
|
|
|
|
def _has_bottleneck(self, product_code: str, routings: list[dict], ops_by_code: dict) -> bool:
|
|
|
|
|
|
"""产品是否含瓶颈工序。"""
|
|
|
|
|
|
for s in self._routing_of(product_code, routings):
|
|
|
|
|
|
op = ops_by_code.get(s["operationCode"])
|
|
|
|
|
|
if op and op.get("isBottleneck"):
|
|
|
|
|
|
return True
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _bottleneck_summary(routings: list[dict], ops_by_code: dict, equipment: list[dict]) -> list[dict]:
|
|
|
|
|
|
"""瓶颈工序清单及其能力池设备数(瓶颈产能法的观测口径)。"""
|
|
|
|
|
|
out = []
|
|
|
|
|
|
seen = set()
|
|
|
|
|
|
for r in routings:
|
|
|
|
|
|
code = r["operationCode"]
|
|
|
|
|
|
if code in seen:
|
|
|
|
|
|
continue
|
|
|
|
|
|
op = ops_by_code.get(code)
|
|
|
|
|
|
if op and op.get("isBottleneck"):
|
|
|
|
|
|
seen.add(code)
|
|
|
|
|
|
pool_n = sum(1 for e in equipment if code in e.get("capabilities", []))
|
|
|
|
|
|
out.append({"operationCode": code, "operationName": op["name"], "poolEquipmentCount": pool_n})
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _utilization(world: World, vid: int, base_start: datetime, horizon_days: int) -> float:
|
|
|
|
|
|
"""本版本设备平均利用率(占用分钟 ÷ 展望期内单班可用分钟)。"""
|
|
|
|
|
|
wos = [w for w in world["flexWorkOrders"] if w["versionId"] == vid]
|
|
|
|
|
|
if not wos:
|
|
|
|
|
|
return 0.0
|
|
|
|
|
|
used: dict[int, float] = {}
|
|
|
|
|
|
for w in wos:
|
|
|
|
|
|
dur = (parse_dt(w["plannedEndTime"]) - parse_dt(w["plannedStartTime"])).total_seconds() / 60
|
|
|
|
|
|
used[w["equipmentId"]] = used.get(w["equipmentId"], 0.0) + dur
|
|
|
|
|
|
# 单班有效分钟 ≈ 8h(480min),工作日按展望期内 5/7 估算
|
|
|
|
|
|
avail_per_eq = 480 * max(1, int(horizon_days * 5 / 7))
|
|
|
|
|
|
utils = [min(u / avail_per_eq, 1.5) for u in used.values()]
|
|
|
|
|
|
return round(sum(utils) / len(utils), 4) if utils else 0.0
|