aps-agent/server/aps_domain/flex.py

1140 lines
53 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 柔性排产领域服务(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)