1140 lines
53 KiB
Python
1140 lines
53 KiB
Python
# ============================================================
|
||
# 柔性排产领域服务(moduleId: domain-flex, 可重生 ✅)
|
||
# 吸收 demand/ 康尼芜湖方案:设备能力池 + 虚拟产线 + 瓶颈产能观测。
|
||
# 职责:
|
||
# - flex_overview(world) 柔性主数据 + 能力池 + 最新版本产物的只读投影(P0)
|
||
# - run_flex_schedule(store,...) 触发 PoolEngine 排产(P1,经 harness.guard)
|
||
# - simulate_due(store, ...) 交期承诺模拟(PL-08 雏形:把询单插入后看完工区间)
|
||
# 数据键见 server/state/seed.py 的 flex* 段与 docs/product/demand-data-intake.md。
|
||
# ============================================================
|
||
from __future__ import annotations # 前向类型引用
|
||
|
||
import copy # 交期模拟用深拷贝沙盒
|
||
from typing import Any # 类型标注
|
||
|
||
from server.engines import PoolEngine # 柔性能力池引擎
|
||
|
||
World = dict[str, Any]
|
||
|
||
|
||
def capability_pools(world: World) -> list[dict]:
|
||
"""按工序能力归池:池 = 拥有该工序能力的设备集合(产能池模型)。"""
|
||
ops = {o["code"]: o for o in world.get("flexOperations", [])}
|
||
pools = []
|
||
for code, op in ops.items():
|
||
members = [e for e in world.get("flexEquipment", []) if code in e.get("capabilities", [])]
|
||
if not members:
|
||
continue
|
||
pools.append({
|
||
"operationCode": code,
|
||
"operationName": op["name"],
|
||
"isBottleneck": op.get("isBottleneck", False),
|
||
"equipmentCount": len(members),
|
||
"movableCount": sum(1 for e in members if e.get("movable")),
|
||
"equipment": [{"code": e["code"], "name": e["name"], "zone": e.get("zone"),
|
||
"movable": e.get("movable", False)} for e in members],
|
||
})
|
||
# 瓶颈池排前
|
||
return sorted(pools, key=lambda p: (0 if p["isBottleneck"] else 1, p["operationCode"]))
|
||
|
||
|
||
def capacity_analysis(world: World, shift_minutes: int = 480) -> dict:
|
||
"""瓶颈产能法(PL-07)+ EX-10 短/中/长期负荷预测。
|
||
|
||
日产能(件) = 池可用工时 ÷ 单件工时;池可用工时 = Σ(单班有效分钟 × 可动率)。
|
||
预测窗:4h / 1 班日 / 1 周(5 班),按已排工单与窗重叠分钟 ÷ 窗内可用分钟 → 利用率。
|
||
色标:≥85% 预警 · ≥95% 危急。
|
||
"""
|
||
from server.state.seed import ensure_flex_seed
|
||
from server.timeutil import add_minutes, parse_dt, today0
|
||
ensure_flex_seed(world)
|
||
ops = {o["code"]: o for o in world.get("flexOperations", [])}
|
||
versions = world.get("flexScheduleVersions", [])
|
||
latest_id = versions[-1]["id"] if versions else None
|
||
latest_wos = [w for w in world.get("flexWorkOrders", [])
|
||
if not latest_id or w.get("versionId") == latest_id]
|
||
|
||
load_by_eq: dict[int, float] = {}
|
||
for w in latest_wos:
|
||
dur = (parse_dt(w["plannedEndTime"]) - parse_dt(w["plannedStartTime"])).total_seconds() / 60
|
||
load_by_eq[w["equipmentId"]] = load_by_eq.get(w["equipmentId"], 0.0) + dur
|
||
|
||
# 预测起点:最新工单最早开工日 08:00,否则明天 08:00
|
||
base = add_minutes(today0(), 24 * 60).replace(hour=8, minute=0, second=0, microsecond=0)
|
||
if latest_wos:
|
||
base = min(parse_dt(w["plannedStartTime"]) for w in latest_wos).replace(
|
||
hour=8, minute=0, second=0, microsecond=0)
|
||
|
||
horizons = [
|
||
("h4", 240, "4小时"),
|
||
("d1", shift_minutes, "1天"),
|
||
("w1", shift_minutes * 5, "1周"),
|
||
]
|
||
|
||
def _overlap_min(ws, we, hs, he) -> float:
|
||
a, b = max(ws, hs), min(we, he)
|
||
return max(0.0, (b - a).total_seconds() / 60)
|
||
|
||
def _alert(util: float) -> str:
|
||
if util >= 0.95:
|
||
return "crit"
|
||
if util >= 0.85:
|
||
return "warn"
|
||
return "ok"
|
||
|
||
pools = []
|
||
alerts: list[dict] = []
|
||
for code, op in ops.items():
|
||
members = [e for e in world.get("flexEquipment", [])
|
||
if code in e.get("capabilities", []) and e.get("status") == "RUNNING"]
|
||
if not members:
|
||
continue
|
||
member_ids = {e["id"] for e in members}
|
||
daily_minutes = sum(shift_minutes * (e.get("availabilityRate") or 1) for e in members)
|
||
std = min((e.get("opStdTime", {}).get(code, 1) for e in members), default=1) or 1
|
||
daily_capacity = daily_minutes / std
|
||
loaded = sum(load_by_eq.get(e["id"], 0.0) for e in members)
|
||
|
||
forecast: dict[str, dict] = {}
|
||
for hk, hmin, hlabel in horizons:
|
||
hs, he = base, add_minutes(base, hmin)
|
||
avail = sum(hmin * (e.get("availabilityRate") or 1) for e in members)
|
||
loaded_h = 0.0
|
||
for w in latest_wos:
|
||
if w.get("equipmentId") not in member_ids:
|
||
continue
|
||
loaded_h += _overlap_min(
|
||
parse_dt(w["plannedStartTime"]), parse_dt(w["plannedEndTime"]), hs, he)
|
||
util = (loaded_h / avail) if avail else 0.0
|
||
level = _alert(util)
|
||
forecast[hk] = {
|
||
"label": hlabel, "horizonMinutes": hmin,
|
||
"availableMinutes": round(avail), "loadedMinutes": round(loaded_h),
|
||
"utilization": round(util, 3), "alert": level,
|
||
}
|
||
if level in ("warn", "crit"):
|
||
alerts.append({
|
||
"operationCode": code, "operationName": op["name"],
|
||
"horizon": hk, "horizonLabel": hlabel,
|
||
"utilization": round(util, 3), "alert": level,
|
||
"message": (f"{op['name']} {hlabel}负荷 {round(util * 100)}%"
|
||
f"({'危急' if level == 'crit' else '预警'})"),
|
||
})
|
||
|
||
pools.append({
|
||
"operationCode": code, "operationName": op["name"],
|
||
"isBottleneck": op.get("isBottleneck", False),
|
||
"equipmentCount": len(members),
|
||
"movableCount": sum(1 for e in members if e.get("movable")),
|
||
"dailyMinutes": round(daily_minutes),
|
||
"stdTimePerUnit": std,
|
||
"dailyCapacity": round(daily_capacity),
|
||
"loadedMinutes": round(loaded),
|
||
"workDays": round(loaded / daily_minutes, 2) if daily_minutes else 0.0,
|
||
"forecast": forecast,
|
||
})
|
||
pools.sort(key=lambda p: p["dailyCapacity"])
|
||
alerts.sort(key=lambda a: (0 if a["alert"] == "crit" else 1, -a["utilization"]))
|
||
return {
|
||
"pools": pools, "bottleneckPool": pools[0] if pools else None,
|
||
"shiftMinutes": shift_minutes,
|
||
"forecastBase": base.isoformat(sep=" "),
|
||
"horizons": [{"key": k, "minutes": m, "label": lb} for k, m, lb in horizons],
|
||
"alerts": alerts,
|
||
}
|
||
|
||
|
||
def flex_overview(world: World) -> dict:
|
||
"""柔性排产页数据:区域/能力池/设备/模具/订单 + 最新版本虚拟产线与冲突。"""
|
||
from server.state.seed import ensure_flex_seed
|
||
ensure_flex_seed(world) # 旧世界缺柔性主数据 → 幂等补种
|
||
versions = world.get("flexScheduleVersions", [])
|
||
latest = versions[-1] if versions else None
|
||
latest_id = latest["id"] if latest else None
|
||
vls = [v for v in world.get("flexVirtualLines", []) if not latest_id or v["versionId"] == latest_id]
|
||
wos = [w for w in world.get("flexWorkOrders", []) if not latest_id or w["versionId"] == latest_id]
|
||
cfs = [c for c in world.get("flexConflicts", []) if not latest_id or c["versionId"] == latest_id]
|
||
return {
|
||
"zones": world.get("flexZones", []),
|
||
"pools": capability_pools(world),
|
||
"equipment": world.get("flexEquipment", []),
|
||
"molds": world.get("flexMolds", []),
|
||
"operations": world.get("flexOperations", []),
|
||
"teams": world.get("flexTeams", []),
|
||
"orders": world.get("flexOrders", []),
|
||
"materials": world.get("flexMaterials", []),
|
||
"params": world.get("flexParams", {}),
|
||
"latestVersion": latest,
|
||
"virtualLines": vls,
|
||
"workOrders": wos,
|
||
"conflicts": cfs,
|
||
}
|
||
|
||
|
||
def flex_gantt_view(world: World) -> dict:
|
||
"""柔性甘特视图数据(右侧视口用):行=设备(按区域分组),条=柔性工单。
|
||
|
||
与固定产线甘特对齐的投影:设备骨架 + 最新版本工单(附超期/瓶颈/冲突标记)。
|
||
"""
|
||
from server.state.seed import ensure_flex_seed
|
||
from server.timeutil import parse_dt
|
||
ensure_flex_seed(world)
|
||
versions = world.get("flexScheduleVersions", [])
|
||
latest = versions[-1] if versions else None
|
||
latest_id = latest["id"] if latest else None
|
||
orders_by_no = {o["orderNo"]: o for o in world.get("flexOrders", [])}
|
||
products = {p["code"]: p for p in world.get("flexMaterials", [])
|
||
if p.get("type") == "FINISHED_PRODUCT"}
|
||
conflict_orders = {c.get("orderNo") for c in world.get("flexConflicts", [])
|
||
if not latest_id or c["versionId"] == latest_id}
|
||
|
||
def _overdue(w) -> bool:
|
||
o = orders_by_no.get(w.get("flexOrderNo"))
|
||
if not o or not o.get("dueDate") or not w.get("plannedEndTime"):
|
||
return False
|
||
return parse_dt(w["plannedEndTime"]) > parse_dt(o["dueDate"] + " 18:00")
|
||
|
||
wos_out: list[dict] = []
|
||
for w in world.get("flexWorkOrders", []):
|
||
if latest_id and w["versionId"] != latest_id:
|
||
continue
|
||
prod = products.get(w["productCode"])
|
||
wos_out.append({
|
||
"id": w["id"], "orderNo": w.get("flexOrderNo"), "woNo": w["orderNo"],
|
||
"productCode": w["productCode"],
|
||
"productName": prod["name"] if prod else w["productCode"],
|
||
"quantity": w["quantity"], "operationName": w["operationName"], "seq": w["seq"],
|
||
"equipmentId": w["equipmentId"], "equipmentCode": w["equipmentCode"],
|
||
"equipmentName": w["equipmentName"], "zone": w.get("zone"),
|
||
"moldCode": w.get("moldCode"),
|
||
"changeoverMin": w.get("changeoverMin", 0), "moveMin": w.get("moveMin", 0),
|
||
"runMin": w.get("runMin", 0),
|
||
"start": w["plannedStartTime"], "end": w["plannedEndTime"],
|
||
"isBottleneck": w.get("isBottleneck", False),
|
||
"overdue": _overdue(w),
|
||
"conflict": w.get("flexOrderNo") in conflict_orders,
|
||
})
|
||
zones = world.get("flexZones", [])
|
||
# 仅保留本版本用到的设备(无产物时给全部设备骨架,便于空态展示)
|
||
used_eq = {w["equipmentId"] for w in wos_out}
|
||
equipment = [{"id": e["id"], "code": e["code"], "name": e["name"], "zone": e.get("zone")}
|
||
for e in world.get("flexEquipment", [])
|
||
if not used_eq or e["id"] in used_eq]
|
||
return {
|
||
"versionNo": latest["versionNo"] if latest else None,
|
||
"sortMode": latest.get("sortMode") if latest else None,
|
||
"zones": [{"code": z.get("code"), "name": z.get("name")} for z in zones],
|
||
"equipment": equipment,
|
||
"workOrders": wos_out,
|
||
}
|
||
|
||
|
||
def run_flex_schedule(store, sort_mode: str | None = None, order_ids: list[int] | None = None,
|
||
start_date: str | None = None, name: str | None = None,
|
||
actor: str = "web", window: str | None = None,
|
||
enforce_teams: bool | None = None,
|
||
trial: bool = False) -> dict:
|
||
"""Run the governed closed-loop scheduling pipeline as one P1 action.
|
||
|
||
Real/site worlds always use the closed-loop requirement, supply, routing and
|
||
resource admission flow. The legacy direct PoolEngine path is retained only
|
||
for an explicitly constructed world carrying the demo factory marker, so golden demo fixtures remain isolated from product
|
||
semantics. ``trial=True`` uses the PoolEngine capability-pool draft for
|
||
folder-schedule quick trials, leaving strict admission for formal runs.
|
||
"""
|
||
from datetime import datetime
|
||
from zoneinfo import ZoneInfo
|
||
|
||
from server.agent_core import harness
|
||
from server.agent_core.audit import write_audit
|
||
from server.aps_domain.closed_loop_runtime import run_closed_loop_candidate
|
||
from server.aps_domain.mrp import decompose_orders
|
||
from server.aps_domain.orders import sync_flex_orders_to_sales
|
||
from server.aps_domain.sourcing import annotate_world_sourcing
|
||
from server.state.seed import ensure_flex_seed
|
||
|
||
ensure_flex_seed(store.data)
|
||
planning_context = store.data.get("planningContext") or {}
|
||
if planning_context.get("trialOnly"):
|
||
trial = True
|
||
if start_date is None and planning_context.get("planStart"):
|
||
start_date = str(planning_context["planStart"])[:10]
|
||
selected_order_nos: list[str] | None = None
|
||
if order_ids:
|
||
selected_ids = set(order_ids)
|
||
selected_order_nos = sorted({
|
||
str(row.get("salesOrderNo") or row.get("orderNo"))
|
||
for row in store.data.get("flexOrders") or []
|
||
if row.get("id") in selected_ids and (row.get("salesOrderNo") or row.get("orderNo"))
|
||
})
|
||
business_date = str(
|
||
planning_context.get("dataDate")
|
||
or store.data.get("businessDate")
|
||
or datetime.now(ZoneInfo("Asia/Shanghai")).date().isoformat()
|
||
)[:10]
|
||
explicit_demo = any(
|
||
str(factory.get("name") or "") == "\u6f14\u793a\u5de5\u5382"
|
||
for factory in store.data.get("factories") or []
|
||
)
|
||
|
||
def execute_pipeline() -> dict:
|
||
if explicit_demo or trial:
|
||
result = PoolEngine().solve(
|
||
store.data,
|
||
store.next_id,
|
||
sort_mode=sort_mode,
|
||
order_ids=order_ids,
|
||
start_date=start_date,
|
||
name=name,
|
||
window=window,
|
||
enforce_teams=enforce_teams,
|
||
trial=trial,
|
||
)
|
||
result["executionMode"] = "POOL_TRIAL" if trial else "EXPLICIT_DEMO_LEGACY"
|
||
synced = sync_flex_orders_to_sales(store.data)
|
||
annotate_world_sourcing(store.data)
|
||
decomposition = decompose_orders(store.data, store.next_id)
|
||
else:
|
||
synced = sync_flex_orders_to_sales(store.data)
|
||
annotate_world_sourcing(store.data)
|
||
decomposition = decompose_orders(store.data, store.next_id)
|
||
result = run_closed_loop_candidate(
|
||
store.data,
|
||
store.next_id,
|
||
business_date=business_date,
|
||
schedule_start_date=start_date,
|
||
order_nos=selected_order_nos,
|
||
sort_mode=sort_mode,
|
||
window=window,
|
||
name=name,
|
||
strict=True,
|
||
)
|
||
result["executionMode"] = "CLOSED_LOOP_V1"
|
||
result["salesOrdersSynced"] = synced
|
||
result["decompose"] = {
|
||
"orders": len(decomposition.get("orders") or []),
|
||
"make": len(decomposition.get("make") or []),
|
||
"purchase": len(decomposition.get("purchase") or []),
|
||
"outsource": len(decomposition.get("outsource") or []),
|
||
}
|
||
return result
|
||
|
||
result = harness.guard(
|
||
"flex.schedule",
|
||
{
|
||
"sortMode": sort_mode,
|
||
"orderIds": order_ids,
|
||
"window": window,
|
||
"businessDate": business_date,
|
||
"scheduleStartDate": start_date,
|
||
"executionMode": (
|
||
"POOL_TRIAL" if trial
|
||
else ("EXPLICIT_DEMO_LEGACY" if explicit_demo else "CLOSED_LOOP_V1")
|
||
),
|
||
},
|
||
execute_pipeline,
|
||
)
|
||
|
||
if int(result.get("woCount") or 0) > 0:
|
||
from server.aps_domain.readiness import annotate_time_conflicts
|
||
|
||
scheduled_nos = sorted({
|
||
str(row.get("flexOrderNo"))
|
||
for row in store.data.get("flexWorkOrders") or []
|
||
if row.get("versionId") == result.get("versionId") and row.get("flexOrderNo")
|
||
})
|
||
extra = annotate_time_conflicts(
|
||
store.data,
|
||
result["versionId"],
|
||
store.next_id,
|
||
scheduled_nos,
|
||
)
|
||
if extra:
|
||
result["conflictCount"] = int(result.get("conflictCount") or 0) + extra
|
||
for version in store.data.get("flexScheduleVersions") or []:
|
||
if version.get("id") == result["versionId"]:
|
||
version["conflictCount"] = result["conflictCount"]
|
||
break
|
||
|
||
write_audit(
|
||
store.data,
|
||
store.next_id,
|
||
actor=actor,
|
||
category="ALGO_RUN",
|
||
action="flex.schedule",
|
||
target={"type": "FLEX_VERSION", "id": result["versionId"]},
|
||
power="P1",
|
||
rationale={
|
||
"engineType": result.get("engineType") or "CLOSED_LOOP",
|
||
"executionMode": result.get("executionMode"),
|
||
"solveStatus": result.get("solveStatus"),
|
||
"sortMode": result.get("sortMode") or sort_mode or "CLOSED_LOOP",
|
||
"vlCount": int(result.get("vlCount") or 0),
|
||
"woCount": int(result.get("woCount") or 0),
|
||
"conflictCount": int(result.get("conflictCount") or 0),
|
||
"window": result.get("window") or window,
|
||
"planningProblemId": result.get("planningProblemId"),
|
||
"planningSourceHash": result.get("planningSourceHash"),
|
||
},
|
||
)
|
||
store.save()
|
||
return result
|
||
|
||
def reschedule_by_level(store, level: str = "L2", sort_mode: str | None = None,
|
||
actor: str = "web") -> dict:
|
||
"""分级重排(DY-01):L2 短窗 / L3 日(中)窗内重排,窗外工单冻结占槽;L4 全量。"""
|
||
from server.timeutil import add_minutes, fmt_date, parse_dt, today0
|
||
from server.state.seed import ensure_flex_seed
|
||
ensure_flex_seed(store.data)
|
||
lv = (level or "L2").upper()
|
||
if lv in ("L4", "FULL", "GLOBAL"):
|
||
return {"level": "L4", "schedule": run_flex_schedule(
|
||
store, sort_mode=sort_mode or "BOTTLENECK", actor=actor, name="L4 全局重排")}
|
||
|
||
window = "short" if lv == "L2" else "mid" # L3 → mid(2d)
|
||
world = store.data
|
||
versions = world.get("flexScheduleVersions", [])
|
||
if not versions:
|
||
# 无基线 → 直接按窗排一版
|
||
sch = run_flex_schedule(store, sort_mode=sort_mode or "BOTTLENECK",
|
||
actor=actor, window=window, name=f"{lv} 滚动首排")
|
||
return {"level": lv, "window": window, "frozenWo": 0, "mutableOrders": sch["orderCount"],
|
||
"schedule": sch}
|
||
|
||
latest = versions[-1]
|
||
latest_id = latest["id"]
|
||
params = world.get("flexParams", {})
|
||
from server.engines.pool_engine import resolve_window_minutes
|
||
horizon = resolve_window_minutes(params, window) or (120 if lv == "L2" else 2 * 24 * 60)
|
||
base_start = add_minutes(today0(), 24 * 60)
|
||
# 对齐最新版本习惯:若已有工单,用最早开工日 08:00 作窗起点
|
||
latest_wos = [w for w in world.get("flexWorkOrders", []) if w.get("versionId") == latest_id]
|
||
if latest_wos:
|
||
base_start = min(parse_dt(w["plannedStartTime"]) for w in latest_wos)
|
||
base_start = base_start.replace(hour=8, minute=0, second=0, microsecond=0)
|
||
window_end = add_minutes(base_start, horizon)
|
||
|
||
seed_busy: dict[int, list] = {}
|
||
mutable_nos: set[str] = set()
|
||
frozen_wos = []
|
||
frozen_vl_ids: set[int] = set()
|
||
for wo in latest_wos:
|
||
ws, we = parse_dt(wo["plannedStartTime"]), parse_dt(wo["plannedEndTime"])
|
||
overlaps = ws < window_end and we > base_start
|
||
if overlaps:
|
||
mutable_nos.add(wo["flexOrderNo"])
|
||
else:
|
||
frozen_wos.append(wo)
|
||
frozen_vl_ids.add(wo["vlId"])
|
||
seed_busy.setdefault(wo["equipmentId"], []).append((ws, we))
|
||
|
||
# 整单只要有窗外工序也在 mutable 集合外的订单:若订单部分 overlap,整单重排
|
||
# (已按 overlap 收集);纯冻结订单不进 order_ids
|
||
order_ids = [o["id"] for o in world.get("flexOrders", [])
|
||
if o["orderNo"] in mutable_nos and o["status"] in ("RELEASED", "CREATED")]
|
||
if not order_ids:
|
||
# 窗内无工单 → 全量按窗排
|
||
sch = run_flex_schedule(store, sort_mode=sort_mode or "BOTTLENECK",
|
||
actor=actor, window=window, name=f"{lv} 窗内排产")
|
||
return {"level": lv, "window": window, "frozenWo": len(frozen_wos),
|
||
"mutableOrders": sch["orderCount"], "schedule": sch}
|
||
|
||
from server.agent_core.audit import write_audit
|
||
start_date = fmt_date(base_start)
|
||
|
||
result = PoolEngine().solve(
|
||
world, store.next_id, sort_mode=sort_mode or "BOTTLENECK",
|
||
order_ids=order_ids, start_date=start_date,
|
||
name=f"{lv} 窗内重排", window=window, seed_busy=seed_busy)
|
||
# 把冻结工单/虚拟产线拷入新版本(不重复耗模具寿命)
|
||
new_vid = result["versionId"]
|
||
vl_map: dict[int, int] = {}
|
||
for vl in world.get("flexVirtualLines", []):
|
||
if vl["id"] not in frozen_vl_ids or vl.get("versionId") != latest_id:
|
||
continue
|
||
if vl["orderNo"] in mutable_nos:
|
||
continue
|
||
new_vl_id = store.next_id("flexVirtualLine")
|
||
vl_map[vl["id"]] = new_vl_id
|
||
world["flexVirtualLines"].append({
|
||
**vl, "id": new_vl_id, "versionId": new_vid, "frozen": True,
|
||
})
|
||
result["vlCount"] += 1
|
||
for wo in frozen_wos:
|
||
if wo["flexOrderNo"] in mutable_nos:
|
||
continue
|
||
world["flexWorkOrders"].append({
|
||
**wo, "id": store.next_id("flexWorkOrder"), "versionId": new_vid,
|
||
"vlId": vl_map.get(wo["vlId"], wo["vlId"]), "frozen": True, "status": "FROZEN",
|
||
})
|
||
result["woCount"] += 1
|
||
for v in world["flexScheduleVersions"]:
|
||
if v["id"] == new_vid:
|
||
v["vlCount"] = result["vlCount"]
|
||
v["woCount"] = result["woCount"]
|
||
v["rescheduleLevel"] = lv
|
||
result["rescheduleLevel"] = lv
|
||
result["frozenWo"] = len(frozen_wos)
|
||
|
||
write_audit(world, store.next_id, actor=actor, category="ALGO_RUN",
|
||
action="flex.reschedule", target={"type": "FLEX_VERSION", "id": result["versionId"]},
|
||
power="P2", rationale={"level": lv, "window": window,
|
||
"mutableOrders": len(order_ids),
|
||
"frozenWo": result.get("frozenWo", 0)})
|
||
store.save()
|
||
return {"level": lv, "window": window, "frozenWo": result.get("frozenWo", 0),
|
||
"mutableOrders": len(order_ids), "windowStart": fmt_date(base_start),
|
||
"horizonMinutes": horizon, "schedule": result}
|
||
|
||
|
||
def preview_reschedule(store, level: str = "L2") -> tuple[str, list[str]]:
|
||
"""生成 L2/L3/L4 确认卡摘要(不执行)。"""
|
||
from server.state.seed import ensure_flex_seed
|
||
from server.timeutil import add_minutes, parse_dt, today0
|
||
from server.engines.pool_engine import resolve_window_minutes
|
||
ensure_flex_seed(store.data)
|
||
lv = (level or "L2").upper()
|
||
if lv in ("L4", "FULL", "GLOBAL"):
|
||
n = len([o for o in store.data.get("flexOrders", [])
|
||
if o.get("status") in ("RELEASED", "CREATED")])
|
||
return f"L4 全局重排确认", [
|
||
f"将全量重排约 {n} 张柔性订单,生成新草稿版本",
|
||
"影响面:全部虚拟产线与工单时间轴",
|
||
"执行前自动建档,可回滚(P2)",
|
||
]
|
||
window = "short" if lv == "L2" else "mid"
|
||
params = store.data.get("flexParams", {})
|
||
horizon = resolve_window_minutes(params, window) or (120 if lv == "L2" else 2 * 24 * 60)
|
||
versions = store.data.get("flexScheduleVersions", [])
|
||
if not versions:
|
||
return f"{lv} 窗内重排确认", [
|
||
f"当前无基线版本,将按 {window} 窗直接首排",
|
||
f"时域约 {horizon} 分钟",
|
||
"执行前自动建档(P2)",
|
||
]
|
||
latest_id = versions[-1]["id"]
|
||
latest_wos = [w for w in store.data.get("flexWorkOrders", []) if w.get("versionId") == latest_id]
|
||
base_start = add_minutes(today0(), 24 * 60)
|
||
if latest_wos:
|
||
base_start = min(parse_dt(w["plannedStartTime"]) for w in latest_wos).replace(
|
||
hour=8, minute=0, second=0, microsecond=0)
|
||
window_end = add_minutes(base_start, horizon)
|
||
mutable = set()
|
||
frozen = 0
|
||
for wo in latest_wos:
|
||
ws, we = parse_dt(wo["plannedStartTime"]), parse_dt(wo["plannedEndTime"])
|
||
if ws < window_end and we > base_start:
|
||
mutable.add(wo["flexOrderNo"])
|
||
else:
|
||
frozen += 1
|
||
win_cn = "短窗 2h" if lv == "L2" else "日/中窗 2d"
|
||
return f"{lv} 窗内重排确认({win_cn})", [
|
||
f"窗外冻结约 {frozen} 工单(时间轴保留)",
|
||
f"窗内重排约 {len(mutable)} 张订单",
|
||
"他窗订单不动;执行前自动建档,可回滚(P2)",
|
||
]
|
||
|
||
|
||
def local_swap_equipment(store, equipment_code: str | None = None, wo_id: int | None = None,
|
||
target_code: str | None = None, mark_fault: bool = True,
|
||
dry_run: bool = False, actor: str = "web") -> dict:
|
||
"""L1 局部换机:池内备机接手,只改正目标工单,他单不动(不新建版本)。
|
||
|
||
dry_run=True 时只探测能否全部换机,不改世界、不写审计。
|
||
"""
|
||
from server.agent_core import harness
|
||
from server.agent_core.audit import write_audit
|
||
from server.state.seed import ensure_flex_seed
|
||
from server.timeutil import parse_dt
|
||
ensure_flex_seed(store.data)
|
||
world = store.data
|
||
versions = world.get("flexScheduleVersions", [])
|
||
if not versions:
|
||
raise ValueError("尚无柔性排产版本,请先排产再局部换机")
|
||
latest_id = versions[-1]["id"]
|
||
wos = [w for w in world.get("flexWorkOrders", [])
|
||
if w.get("versionId") == latest_id and not w.get("frozen")]
|
||
targets: list[dict] = []
|
||
if wo_id:
|
||
wo = next((w for w in wos if w["id"] == wo_id), None)
|
||
if wo is None:
|
||
raise ValueError(f"工单 id={wo_id} 不在最新版本")
|
||
targets = [wo]
|
||
elif equipment_code:
|
||
code = equipment_code.upper()
|
||
targets = [w for w in wos if w.get("equipmentCode", "").upper() == code]
|
||
if not targets:
|
||
raise ValueError(f"最新版本没有占用设备 {code} 的工单")
|
||
else:
|
||
raise ValueError("请指定 woId 或 equipmentCode")
|
||
|
||
equipment = {e["code"]: e for e in world.get("flexEquipment", [])}
|
||
|
||
def _busy_of(eq_id: int, exclude_wo_id: int) -> list[tuple]:
|
||
out = []
|
||
for w in wos:
|
||
if w["equipmentId"] == eq_id and w["id"] != exclude_wo_id:
|
||
out.append((parse_dt(w["plannedStartTime"]), parse_dt(w["plannedEndTime"])))
|
||
return out
|
||
|
||
def _free(eq_id: int, start, end, exclude_wo_id: int) -> bool:
|
||
return not any(start < e and end > s for s, e in _busy_of(eq_id, exclude_wo_id))
|
||
|
||
def _plan_one(wo: dict) -> dict | None:
|
||
"""返回可换目标设备,或 None。"""
|
||
op = wo["operationCode"]
|
||
start, end = parse_dt(wo["plannedStartTime"]), parse_dt(wo["plannedEndTime"])
|
||
old_code = wo["equipmentCode"]
|
||
if target_code:
|
||
te = equipment.get(target_code.upper())
|
||
cands = ([te] if te and te.get("status") == "RUNNING"
|
||
and op in te.get("capabilities", []) else [])
|
||
else:
|
||
cands = [e for e in world.get("flexEquipment", [])
|
||
if e["code"] != old_code and e.get("status") == "RUNNING"
|
||
and op in e.get("capabilities", [])
|
||
and _free(e["id"], start, end, wo["id"])]
|
||
mold_code = wo.get("moldCode")
|
||
if mold_code:
|
||
mold = next((m for m in world.get("flexMolds", []) if m["code"] == mold_code), None)
|
||
if mold:
|
||
adapted = [e for e in cands if e["code"] in (mold.get("adaptableEquipment") or [])]
|
||
if adapted:
|
||
cands = adapted
|
||
cands.sort(key=lambda e: e.get("availabilityRate") or 0, reverse=True)
|
||
if not cands:
|
||
return None
|
||
chosen = cands[0]
|
||
if target_code and not _free(chosen["id"], start, end, wo["id"]):
|
||
return None
|
||
return chosen
|
||
|
||
if dry_run:
|
||
planned, failed = [], []
|
||
for wo in targets:
|
||
ch = _plan_one(wo)
|
||
if ch is None:
|
||
failed.append({"woId": wo["id"], "orderNo": wo.get("flexOrderNo"),
|
||
"reason": "池内无空闲备机"})
|
||
else:
|
||
planned.append({"woId": wo["id"], "from": wo["equipmentCode"], "to": ch["code"]})
|
||
return {"swapped": planned, "failed": failed, "faulted": None, "dryRun": True,
|
||
"canAll": not failed and bool(planned)}
|
||
|
||
def _apply():
|
||
swapped: list[dict] = []
|
||
failed: list[dict] = []
|
||
for wo in targets:
|
||
op = wo["operationCode"]
|
||
start, end = parse_dt(wo["plannedStartTime"]), parse_dt(wo["plannedEndTime"])
|
||
old_code = wo["equipmentCode"]
|
||
if target_code:
|
||
te = equipment.get(target_code.upper())
|
||
cands = ([te] if te and te.get("status") == "RUNNING"
|
||
and op in te.get("capabilities", []) else [])
|
||
else:
|
||
cands = [e for e in world.get("flexEquipment", [])
|
||
if e["code"] != old_code and e.get("status") == "RUNNING"
|
||
and op in e.get("capabilities", [])
|
||
and _free(e["id"], start, end, wo["id"])]
|
||
mold_code = wo.get("moldCode")
|
||
if mold_code:
|
||
mold = next((m for m in world.get("flexMolds", []) if m["code"] == mold_code), None)
|
||
if mold:
|
||
adapted = [e for e in cands if e["code"] in (mold.get("adaptableEquipment") or [])]
|
||
if adapted:
|
||
cands = adapted
|
||
cands.sort(key=lambda e: e.get("availabilityRate") or 0, reverse=True)
|
||
|
||
if not cands:
|
||
failed.append({"woId": wo["id"], "orderNo": wo.get("flexOrderNo"),
|
||
"reason": "池内无空闲备机"})
|
||
continue
|
||
chosen = cands[0]
|
||
if target_code and not _free(chosen["id"], start, end, wo["id"]):
|
||
failed.append({"woId": wo["id"], "orderNo": wo.get("flexOrderNo"),
|
||
"reason": f"目标设备 {chosen['code']} 时段冲突"})
|
||
continue
|
||
|
||
before_code = wo["equipmentCode"]
|
||
wo["equipmentId"] = chosen["id"]
|
||
wo["equipmentCode"] = chosen["code"]
|
||
wo["equipmentName"] = chosen["name"]
|
||
wo["zone"] = chosen.get("zone")
|
||
wo["swappedFrom"] = before_code
|
||
for vl in world.get("flexVirtualLines", []):
|
||
if vl.get("id") != wo.get("vlId"):
|
||
continue
|
||
for a in vl.get("assignments") or []:
|
||
if a.get("seq") == wo.get("seq"):
|
||
a["equipmentCode"] = chosen["code"]
|
||
swapped.append({
|
||
"woId": wo["id"], "orderNo": wo.get("flexOrderNo"), "operationCode": op,
|
||
"from": before_code, "to": chosen["code"],
|
||
"start": wo["plannedStartTime"], "end": wo["plannedEndTime"],
|
||
})
|
||
|
||
faulted = None
|
||
if mark_fault and equipment_code and swapped:
|
||
eq = equipment.get(equipment_code.upper())
|
||
if eq:
|
||
eq["status"] = "MAINTENANCE"
|
||
faulted = equipment_code.upper()
|
||
return {"swapped": swapped, "failed": failed, "faulted": faulted}
|
||
|
||
result = harness.guard("flex.swap", {"equipmentCode": equipment_code, "woId": wo_id}, _apply)
|
||
if not result["swapped"] and result["failed"]:
|
||
raise ValueError(result["failed"][0]["reason"])
|
||
write_audit(world, store.next_id, actor=actor, category="WORLD_WRITE",
|
||
action="flex.swap", target={"type": "FLEX_WO", "id": wo_id or equipment_code},
|
||
power="P1", rationale={"swapped": len(result["swapped"]),
|
||
"failed": len(result["failed"])})
|
||
store.save()
|
||
return result
|
||
|
||
|
||
def simulate_due(store, product_code: str, quantity: int, sort_mode: str | None = None) -> dict:
|
||
"""交期承诺模拟(PL-08):询单插入沙盒 → 乐观/预计/悲观完工 + 资源缺口建议。不改主干。"""
|
||
from server.state.seed import ensure_flex_seed
|
||
ensure_flex_seed(store.data)
|
||
sandbox = copy.deepcopy(store.data)
|
||
new_id = max([o["id"] for o in sandbox.get("flexOrders", [])], default=0) + 1
|
||
probe_no = f"PROBE-{product_code}"
|
||
probe = {"id": new_id, "orderNo": probe_no, "productCode": product_code,
|
||
"quantity": quantity, "dueDate": "2999-12-31", "priority": 1,
|
||
"wbs": None, "productionController": None, "status": "RELEASED"}
|
||
|
||
# 乐观:独占资源
|
||
opt_world = copy.deepcopy(sandbox)
|
||
opt_world["flexOrders"] = [dict(probe)]
|
||
_reset_flex_products(opt_world)
|
||
_counter_run(opt_world, sort_mode)
|
||
opt_vl = next((v for v in opt_world["flexVirtualLines"] if v["orderNo"] == probe_no), None)
|
||
|
||
# 预计:与在制订单竞争
|
||
exp_world = copy.deepcopy(sandbox)
|
||
exp_world["flexOrders"] = list(exp_world.get("flexOrders", [])) + [{**probe, "priority": 9}]
|
||
_reset_flex_products(exp_world)
|
||
_counter_run(exp_world, sort_mode)
|
||
exp_vl = next((v for v in exp_world["flexVirtualLines"] if v["orderNo"] == probe_no), None)
|
||
|
||
# 悲观:竞争 + 全厂可动率打七折(模拟疲劳/故障余量)
|
||
pes_world = copy.deepcopy(exp_world)
|
||
for e in pes_world.get("flexEquipment", []):
|
||
e["availabilityRate"] = round((e.get("availabilityRate") or 1.0) * 0.7, 3)
|
||
_reset_flex_products(pes_world)
|
||
_counter_run(pes_world, sort_mode)
|
||
pes_vl = next((v for v in pes_world["flexVirtualLines"] if v["orderNo"] == probe_no), None)
|
||
|
||
bn = (opt_world["flexScheduleVersions"][-1]["bottleneck"]
|
||
if opt_world["flexScheduleVersions"] else [])
|
||
gaps = _due_gap_suggestions(sandbox, product_code, quantity, bn,
|
||
opt_vl["plannedEnd"] if opt_vl else None,
|
||
exp_vl["plannedEnd"] if exp_vl else None)
|
||
return {
|
||
"productCode": product_code, "quantity": quantity,
|
||
"optimisticFinish": opt_vl["plannedEnd"] if opt_vl else None,
|
||
"expectedFinish": exp_vl["plannedEnd"] if exp_vl else None,
|
||
"pessimisticFinish": pes_vl["plannedEnd"] if pes_vl else None,
|
||
"bottleneck": bn,
|
||
"gaps": gaps,
|
||
}
|
||
|
||
|
||
def _due_gap_suggestions(world: World, product_code: str, quantity: int,
|
||
bottleneck: list, opt_end: str | None, exp_end: str | None) -> list[dict]:
|
||
"""根据瓶颈池与乐观/预计差生成可执行缺口建议(PL-08)。"""
|
||
gaps: list[dict] = []
|
||
steps = [r for r in world.get("flexRoutings", []) if r["productCode"] == product_code]
|
||
for b in bottleneck or []:
|
||
code = b.get("operationCode") or b.get("code")
|
||
name = b.get("operationName") or b.get("name") or code
|
||
count = b.get("poolEquipmentCount") or b.get("count") or 0
|
||
if count <= 1:
|
||
gaps.append({
|
||
"kind": "BOTTLENECK_SINGLE",
|
||
"operationCode": code, "operationName": name,
|
||
"message": f"瓶颈工序「{name}」池内仅 {count} 台,属单点风险",
|
||
"action": "加派/采购同能力设备,或将该工序外包",
|
||
})
|
||
elif count <= 2:
|
||
gaps.append({
|
||
"kind": "BOTTLENECK_THIN",
|
||
"operationCode": code, "operationName": name,
|
||
"message": f"瓶颈工序「{name}」冗余不足({count} 台)",
|
||
"action": "可移动设备跨区调配,或拆批错峰",
|
||
})
|
||
need_mold_ops = {s["operationCode"] for s in steps if s.get("requireMold")}
|
||
for op in need_mold_ops:
|
||
molds = [m for m in world.get("flexMolds", [])
|
||
if m.get("operationCode") == op and m.get("status") == "AVAILABLE"
|
||
and m.get("lifeUsed", 0) < m.get("lifeTotal", 1)]
|
||
if not molds:
|
||
gaps.append({
|
||
"kind": "NO_MOLD", "operationCode": op, "operationName": op,
|
||
"message": f"工序 {op} 无可用模具(寿命或锁定)",
|
||
"action": "解锁/更换模具后再承诺交期",
|
||
})
|
||
else:
|
||
remain = min(m["lifeTotal"] - m["lifeUsed"] for m in molds)
|
||
if remain < quantity:
|
||
gaps.append({
|
||
"kind": "MOLD_LIFE", "operationCode": op, "operationName": op,
|
||
"message": f"模具剩余寿命 {remain} < 询单量 {quantity}",
|
||
"action": "准备备用模具或分批生产",
|
||
})
|
||
if opt_end and exp_end and exp_end > opt_end:
|
||
gaps.append({
|
||
"kind": "CONTENTION", "operationCode": None, "operationName": None,
|
||
"message": f"与在制争抢后完工从 {opt_end} 推迟到 {exp_end}",
|
||
"action": "提高询单优先级插单,或延后低优先级订单",
|
||
})
|
||
if not gaps:
|
||
gaps.append({
|
||
"kind": "OK", "operationCode": None, "operationName": None,
|
||
"message": "当前资源可覆盖询单,无明显缺口",
|
||
"action": "可按预计交期口头承诺,并预留悲观余量",
|
||
})
|
||
return gaps
|
||
|
||
|
||
def compare_sort_modes(store, compress_due: bool = False) -> dict:
|
||
"""五模式对比(SC-13):沙盒内 ASC/DESC/BOTTLENECK/KITTING_FIRST/SKILL_FIRST 并行试排,不改主干。
|
||
|
||
默认按订单真实交期试排。compress_due=True 会把交期压到今天+3~5 天做压力测试,
|
||
只允许显式的压测入口使用:把结果当默认值展示会报出真实排产里根本不存在的延误。
|
||
"""
|
||
from server.state.seed import ensure_flex_seed
|
||
from server.timeutil import add_minutes, fmt_date, today0
|
||
ensure_flex_seed(store.data)
|
||
world = store.data
|
||
eligible_orders = [o for o in world.get("flexOrders", [])
|
||
if o.get("status") in ("RELEASED", "CREATED")]
|
||
product_codes = {str(o.get("productCode") or "") for o in eligible_orders}
|
||
required_ops = {
|
||
str(r.get("operationCode") or "")
|
||
for r in world.get("flexRoutings", [])
|
||
if str(r.get("productCode") or "") in product_codes and r.get("operationCode")
|
||
}
|
||
equipment = world.get("flexEquipment", []) or []
|
||
mapped_equipment = [e for e in equipment if e.get("capabilities")]
|
||
covered_ops = {
|
||
str(code)
|
||
for e in mapped_equipment
|
||
for code in (e.get("capabilities") or [])
|
||
if code
|
||
}
|
||
uncovered_ops = sorted(required_ops - covered_ops)
|
||
molds = world.get("flexMolds", []) or []
|
||
|
||
def _positive(value) -> bool:
|
||
try:
|
||
return float(value or 0) > 0
|
||
except (TypeError, ValueError):
|
||
return False
|
||
|
||
mapped_molds = [m for m in molds
|
||
if m.get("operationCode") and m.get("adaptableEquipment")
|
||
and _positive(m.get("lifeTotal"))]
|
||
unknown_values = {"", "-", "未知", "未提供", "待确认", "unknown", "none", "null", "n/a", "na"}
|
||
|
||
def _known(value) -> bool:
|
||
return str(value or "").strip().lower() not in unknown_values
|
||
|
||
kit_known = [o for o in eligible_orders if _known(o.get("kitStatus"))]
|
||
skill_known = [o for o in eligible_orders if _known(o.get("requiredSkillLevel"))]
|
||
kit_values = {str(o.get("kitStatus") or "").strip() for o in kit_known}
|
||
skill_values = {str(o.get("requiredSkillLevel") or "").strip().upper()
|
||
for o in skill_known}
|
||
bottleneck_ops = {
|
||
str(o.get("code") or "") for o in world.get("flexOperations", [])
|
||
if o.get("isBottleneck") and o.get("code")
|
||
} & required_ops
|
||
|
||
warnings = []
|
||
if len(kit_known) < len(eligible_orders):
|
||
warnings.append({
|
||
"code": "MISSING_KIT_STATUS", "severity": "WARN",
|
||
"message": f"齐套状态仅覆盖 {len(kit_known)}/{len(eligible_orders)} 张订单",
|
||
})
|
||
if len(skill_known) < len(eligible_orders):
|
||
warnings.append({
|
||
"code": "MISSING_SKILL_LEVEL", "severity": "WARN",
|
||
"message": f"技能等级仅覆盖 {len(skill_known)}/{len(eligible_orders)} 张订单",
|
||
})
|
||
elif skill_known and len(skill_values) < 2:
|
||
warnings.append({
|
||
"code": "NO_SKILL_LEVEL_VARIATION", "severity": "WARN",
|
||
"message": f"{len(skill_known)} 张订单技能等级均为 {next(iter(skill_values))},技能优先无区分度",
|
||
})
|
||
if not bottleneck_ops and required_ops:
|
||
warnings.append({
|
||
"code": "MISSING_BOTTLENECK_MARKERS", "severity": "WARN",
|
||
"message": "工艺所需工序未标记瓶颈,瓶颈锚将按交期/优先级退化排序",
|
||
})
|
||
if len(mapped_equipment) < len(equipment):
|
||
warnings.append({
|
||
"code": "PARTIAL_EQUIPMENT_CAPABILITY", "severity": "WARN",
|
||
"message": f"设备能力仅覆盖 {len(mapped_equipment)}/{len(equipment)} 台设备",
|
||
})
|
||
if uncovered_ops:
|
||
warnings.append({
|
||
"code": "UNCOVERED_OPERATION_CAPABILITY", "severity": "ERROR",
|
||
"message": f"{len(uncovered_ops)} 个所需工序无设备能力覆盖",
|
||
"operationCodes": uncovered_ops,
|
||
})
|
||
if len(mapped_molds) < len(molds):
|
||
warnings.append({
|
||
"code": "INCOMPLETE_MOLD_MAPPING", "severity": "WARN",
|
||
"message": f"模具完整映射仅覆盖 {len(mapped_molds)}/{len(molds)} 套",
|
||
})
|
||
|
||
data_quality = {
|
||
"status": "PARTIAL" if warnings else "READY",
|
||
"productionReady": not warnings,
|
||
"eligibleOrderCount": len(eligible_orders),
|
||
"kitStatusKnownCount": len(kit_known),
|
||
"kitStatusDistinctCount": len(kit_values),
|
||
"skillLevelKnownCount": len(skill_known),
|
||
"skillLevelDistinctCount": len(skill_values),
|
||
"bottleneckOperationCount": len(bottleneck_ops),
|
||
"requiredOperationCount": len(required_ops),
|
||
"equipmentCount": len(equipment),
|
||
"capabilityMappedEquipmentCount": len(mapped_equipment),
|
||
"capabilityMappedEquipmentCodes": sorted(
|
||
str(e.get("code") or e.get("id")) for e in mapped_equipment
|
||
),
|
||
"unmappedEquipmentCount": len(equipment) - len(mapped_equipment),
|
||
"coveredOperationCount": len(required_ops & covered_ops),
|
||
"uncoveredOperationCodes": uncovered_ops,
|
||
"moldCount": len(molds),
|
||
"fullyMappedMoldCount": len(mapped_molds),
|
||
"warnings": warnings,
|
||
}
|
||
|
||
def _strategy_assessment(mode: str) -> tuple[str, list[dict]]:
|
||
if not eligible_orders:
|
||
return "DEGRADED", [{
|
||
"code": "NO_ELIGIBLE_ORDERS", "severity": "WARN",
|
||
"message": "没有 RELEASED/CREATED 订单,策略无可比较对象",
|
||
}]
|
||
if mode == "KITTING_FIRST":
|
||
if not kit_known:
|
||
return "DEGRADED", [{
|
||
"code": "MISSING_KIT_STATUS", "severity": "WARN",
|
||
"message": "齐套状态全部缺失,本模式实际按交期/优先级排序",
|
||
}]
|
||
if len(kit_known) < len(eligible_orders):
|
||
return "PARTIAL", [{
|
||
"code": "PARTIAL_KIT_STATUS", "severity": "WARN",
|
||
"message": f"仅 {len(kit_known)}/{len(eligible_orders)} 张订单有齐套状态",
|
||
}]
|
||
if len(kit_values) < 2:
|
||
return "DEGRADED", [{
|
||
"code": "NO_KIT_STATUS_VARIATION", "severity": "INFO",
|
||
"message": "全部订单齐套状态相同,本模式无排序区分度",
|
||
}]
|
||
if mode == "SKILL_FIRST":
|
||
if not skill_known:
|
||
return "DEGRADED", [{
|
||
"code": "MISSING_SKILL_LEVEL", "severity": "WARN",
|
||
"message": "技能等级全部缺失,本模式实际按交期/优先级排序",
|
||
}]
|
||
if len(skill_known) < len(eligible_orders):
|
||
return "PARTIAL", [{
|
||
"code": "PARTIAL_SKILL_LEVEL", "severity": "WARN",
|
||
"message": f"仅 {len(skill_known)}/{len(eligible_orders)} 张订单有技能等级",
|
||
}]
|
||
if len(skill_values) < 2:
|
||
return "DEGRADED", [{
|
||
"code": "NO_SKILL_LEVEL_VARIATION", "severity": "INFO",
|
||
"message": "全部订单技能等级相同,本模式无排序区分度",
|
||
}]
|
||
if mode == "BOTTLENECK" and not bottleneck_ops:
|
||
return "DEGRADED", [{
|
||
"code": "MISSING_BOTTLENECK_MARKERS", "severity": "WARN",
|
||
"message": "缺少瓶颈工序标记,本模式实际按交期/优先级排序",
|
||
}]
|
||
return "READY", []
|
||
|
||
start = fmt_date(add_minutes(today0(), 24 * 60))
|
||
rows = []
|
||
for mode, label in (("ASC", "正排"), ("DESC", "倒排"), ("BOTTLENECK", "瓶颈锚"),
|
||
("KITTING_FIRST", "齐套优先"), ("SKILL_FIRST", "技能优先")):
|
||
w = copy.deepcopy(store.data)
|
||
if compress_due:
|
||
base = today0()
|
||
for i, o in enumerate(w.get("flexOrders", [])):
|
||
o["dueDate"] = fmt_date(add_minutes(base, (3 + (i % 3)) * 24 * 60))
|
||
_reset_flex_products(w)
|
||
r = PoolEngine().solve(w, _sandbox_next_id(), sort_mode=mode, start_date=start)
|
||
conflicts = sorted(
|
||
(c for c in w.get("flexConflicts", []) if c.get("versionId") == r.get("versionId")),
|
||
key=lambda c: (str(c.get("conflictType") or ""), str(c.get("orderNo") or ""),
|
||
str(c.get("description") or "")),
|
||
)
|
||
strategy_status, strategy_evidence = _strategy_assessment(mode)
|
||
result_status = "READY"
|
||
if int(r.get("vlCount") or 0) < int(r.get("orderCount") or 0):
|
||
result_status = "PARTIAL"
|
||
elif conflicts:
|
||
result_status = "WITH_CONFLICTS"
|
||
rows.append({
|
||
"sortMode": mode, "label": label,
|
||
"totalTardiness": round(r["totalTardiness"], 1),
|
||
"onTimeCount": r["onTimeCount"], "orderCount": r["orderCount"],
|
||
"makespan": r["makespan"], "avgUtilization": round(r["avgUtilization"] * 100, 1),
|
||
"conflictCount": r["conflictCount"], "vlCount": r["vlCount"],
|
||
"strategyStatus": strategy_status,
|
||
"strategyEvidence": strategy_evidence,
|
||
"resultStatus": result_status,
|
||
"conflictTypes": sorted({str(c.get("conflictType") or "UNKNOWN") for c in conflicts}),
|
||
"conflictEvidence": [{
|
||
"conflictType": c.get("conflictType"),
|
||
"severity": c.get("severity"),
|
||
"orderNo": c.get("orderNo"),
|
||
"description": c.get("description"),
|
||
} for c in conflicts[:5]],
|
||
})
|
||
hint = "交期宽松时五模式趋同;压力下正排保早交期、倒排后置、瓶颈锚优先喂饱瓶颈、齐套优先减少等待、技能优先保障高技能工序。"
|
||
if warnings:
|
||
hint += " 当前数据证据不足:" + ";".join(w["message"] for w in warnings) + "。"
|
||
due_basis = ("压力测试:交期已压缩为今天起 3~5 天(不是订单真实交期)"
|
||
if compress_due else "按订单真实交期试排")
|
||
return {"rows": rows, "compressedDue": compress_due, "dueBasis": due_basis,
|
||
"hint": hint, "dataQuality": data_quality, "warnings": warnings}
|
||
|
||
|
||
def insert_rush_order(store, product_code: str, quantity: int, due_date: str | None = None,
|
||
priority: int = 1, actor: str = "web") -> dict:
|
||
"""紧急插单(DY-01 L4 雏形):写入 flexOrders 高优先 + 瓶颈锚重排。"""
|
||
from server.state.seed import ensure_flex_seed
|
||
from server.timeutil import add_minutes, fmt_date, today0
|
||
ensure_flex_seed(store.data)
|
||
world = store.data
|
||
mid = max((o.get("id", 0) for o in world.get("flexOrders", [])), default=0) + 1
|
||
due = due_date or fmt_date(add_minutes(today0(), 3 * 24 * 60))
|
||
order = {
|
||
"id": mid, "orderNo": f"RUSH-{mid:04d}", "productCode": product_code,
|
||
"quantity": quantity, "dueDate": due, "priority": priority,
|
||
"wbs": "RUSH", "productionController": None, "status": "RELEASED", "isRush": True,
|
||
}
|
||
world.setdefault("flexOrders", []).append(order)
|
||
result = run_flex_schedule(store, sort_mode="BOTTLENECK", actor=actor,
|
||
name=f"急单插单重排 {order['orderNo']}")
|
||
vl = next((v for v in world.get("flexVirtualLines", [])
|
||
if v["orderNo"] == order["orderNo"]
|
||
and v["versionId"] == result["versionId"]), None)
|
||
return {"order": order, "schedule": result,
|
||
"plannedStart": vl["plannedStart"] if vl else None,
|
||
"plannedEnd": vl["plannedEnd"] if vl else None}
|
||
|
||
|
||
def apply_equipment_fault(store, equipment_code: str, status: str = "MAINTENANCE",
|
||
reschedule: bool = True, actor: str = "web") -> dict:
|
||
"""设备故障/恢复(DY-01):故障时优先 L1 局部换机,池内无备机再缩池重排。"""
|
||
from server.state.seed import ensure_flex_seed
|
||
ensure_flex_seed(store.data)
|
||
world = store.data
|
||
eq = next((e for e in world.get("flexEquipment", [])
|
||
if e["code"].lower() == equipment_code.lower()), None)
|
||
if eq is None:
|
||
raise ValueError(f"找不到设备「{equipment_code}」")
|
||
before = capacity_analysis(world)
|
||
old_status = eq.get("status")
|
||
new_status = status.upper()
|
||
schedule = None
|
||
swap = None
|
||
response_level = None
|
||
|
||
# 故障 + 需响应:先探测 L1,能全部换机则局部处理,否则缩池全量重排
|
||
if new_status == "MAINTENANCE" and reschedule:
|
||
try:
|
||
probe = local_swap_equipment(store, equipment_code=eq["code"],
|
||
mark_fault=False, dry_run=True, actor=actor)
|
||
except ValueError:
|
||
probe = None
|
||
if probe and probe.get("canAll"):
|
||
swap = local_swap_equipment(store, equipment_code=eq["code"],
|
||
mark_fault=True, actor=actor)
|
||
response_level = "L1"
|
||
else:
|
||
eq["status"] = new_status
|
||
schedule = run_flex_schedule(store, sort_mode="BOTTLENECK", actor=actor,
|
||
name=f"设备{eq['code']}故障缩池重排")
|
||
response_level = "L4"
|
||
else:
|
||
eq["status"] = new_status
|
||
if reschedule:
|
||
schedule = run_flex_schedule(store, sort_mode="BOTTLENECK", actor=actor,
|
||
name=f"设备{eq['code']}→{eq['status']}后重排")
|
||
response_level = "L4"
|
||
else:
|
||
store.save()
|
||
|
||
after = capacity_analysis(world)
|
||
return {
|
||
"equipment": {"code": eq["code"], "name": eq["name"],
|
||
"beforeStatus": old_status, "afterStatus": eq["status"]},
|
||
"responseLevel": response_level,
|
||
"swap": swap,
|
||
"capacityBefore": before.get("bottleneckPool"),
|
||
"capacityAfter": after.get("bottleneckPool"),
|
||
"poolsAfter": after.get("pools"),
|
||
"schedule": schedule,
|
||
"conflicts": [c for c in world.get("flexConflicts", [])
|
||
if schedule and c.get("versionId") == schedule.get("versionId")],
|
||
}
|
||
|
||
|
||
def patch_flex_resource(store, kind: str, code: str, patch: dict, actor: str = "web") -> dict:
|
||
"""柔性资源快速补丁(设备状态/区域、模具寿命/锁定)——EX-10 What-if / MD-07。"""
|
||
from server.agent_core.audit import write_audit
|
||
from server.state.seed import ensure_flex_seed
|
||
ensure_flex_seed(store.data)
|
||
world = store.data
|
||
if kind == "equipment":
|
||
row = next((e for e in world.get("flexEquipment", []) if e["code"] == code), None)
|
||
if row is None:
|
||
raise ValueError(f"设备不存在:{code}")
|
||
for k in ("status", "zone", "movable", "availabilityRate", "moveTimeMin"):
|
||
if k in patch and patch[k] is not None:
|
||
row[k] = patch[k]
|
||
elif kind == "mold":
|
||
row = next((m for m in world.get("flexMolds", []) if m["code"] == code), None)
|
||
if row is None:
|
||
raise ValueError(f"模具不存在:{code}")
|
||
for k in ("status", "lifeUsed", "lifeTotal", "zone"):
|
||
if k in patch and patch[k] is not None:
|
||
row[k] = patch[k]
|
||
if int(row.get("lifeUsed", 0)) >= int(row.get("lifeTotal") or 0):
|
||
row["status"] = "LOCKED"
|
||
else:
|
||
raise ValueError("kind 须为 equipment 或 mold")
|
||
write_audit(world, store.next_id, actor=actor, category="WORLD_WRITE",
|
||
action=f"flex.{kind}.patch", target={"type": kind.upper(), "id": code},
|
||
power="P1", rationale=patch)
|
||
store.save()
|
||
return {"kind": kind, "code": code, "row": row, "capacity": capacity_analysis(world)}
|
||
|
||
|
||
def _sandbox_next_id():
|
||
counters: dict[str, int] = {}
|
||
|
||
def next_id(kind: str) -> int:
|
||
counters[kind] = counters.get(kind, 0) + 1
|
||
return counters[kind]
|
||
|
||
return next_id
|
||
|
||
|
||
def _reset_flex_products(world: World) -> None:
|
||
"""清空沙盒里的排产产物表(保证模拟从干净状态排)。"""
|
||
for k in ("flexScheduleVersions", "flexVirtualLines", "flexWorkOrders", "flexConflicts"):
|
||
world[k] = []
|
||
|
||
|
||
def _counter_run(world: World, sort_mode: str | None) -> None:
|
||
"""在沙盒内用独立发号器跑一次柔性排产。"""
|
||
PoolEngine().solve(world, _sandbox_next_id(), sort_mode=sort_mode)
|