aps-agent/server/engines/pool_engine.py

588 lines
32 KiB
Python
Raw Normal View History

# ============================================================
# 柔性排产引擎 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" # 瓶颈锚:瓶颈工序优先保障
SORT_KITTING_FIRST = "KITTING_FIRST" # 齐套优先:备料完成订单先排
SORT_SKILL_FIRST = "SKILL_FIRST" # 技能优先:高技能需求订单先排
# 滚动窗口别名 → 默认时长(可被 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
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)
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
sort_mode: ASC/DESC/BOTTLENECK/KITTING_FIRST/SKILL_FIRST
(None 时取 flexParams.sortMode)
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() # 排产模式
if mode not in (
SORT_ASC, SORT_DESC, SORT_BOTTLENECK,
SORT_KITTING_FIRST, SORT_SKILL_FIRST,
):
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"]))
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"]))
else:
# 正排:EDD(最早交期优先)+ 优先级
orders.sort(key=lambda o: (o["dueDate"], o["priority"]))
# ---- ④ 版本对象 ----
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,
)
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
# 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)
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,
)
# A routing that crosses the rolling window fails as a whole; no partial work orders survive.
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"})
deferred += 1
ok = False
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
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"
]
continue
if not vl["assignments"]:
# 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
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
# 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
)
# ---- ⑧ 设备利用率(瓶颈产能法的观测基础)----
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