aps-agent/server/aps_domain/flex.py

864 lines
41 KiB
Python
Raw Normal View History

# ============================================================
# 柔性排产领域服务(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) -> dict:
"""执行柔性排产(P1 直通,写审计 + 落盘)。返回引擎结果摘要。"""
from server.agent_core import harness
from server.agent_core.audit import write_audit
from server.state.seed import ensure_flex_seed
ensure_flex_seed(store.data) # 旧世界缺柔性主数据 → 幂等补种
result = harness.guard(
"flex.schedule", {"sortMode": sort_mode, "orderIds": order_ids, "window": window},
lambda: 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))
# M-B:工时可信度后置标注——推断/待维护工时显式写冲突,不再默默兜底
from server.aps_domain.readiness import annotate_time_conflicts
scheduled_nos = [o.get("orderNo") for o in store.data.get("flexOrders") or []
if not order_ids or o.get("id") in set(order_ids)]
extra = annotate_time_conflicts(store.data, result["versionId"], store.next_id, scheduled_nos)
if extra:
result["conflictCount"] = result.get("conflictCount", 0) + extra
for v in store.data.get("flexScheduleVersions") or []:
if v.get("id") == result["versionId"]:
v["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={"sortMode": result["sortMode"], "vlCount": result["vlCount"],
"conflictCount": result["conflictCount"],
"window": result.get("window")})
# 柔性单投影到销售订单表,避免「已排产但订单管理为空」
try:
from server.aps_domain.orders import sync_flex_orders_to_sales
sync_flex_orders_to_sales(store.data)
except Exception:
pass
# 排产后按工艺类型自动分解 → 生产/采购/委外建议
try:
from server.aps_domain.mrp import decompose_orders
from server.aps_domain.sourcing import annotate_world_sourcing
annotate_world_sourcing(store.data)
decomp = decompose_orders(store.data, store.next_id)
result["decompose"] = {
"orders": len(decomp.get("orders") or []),
"make": len(decomp.get("make") or []),
"purchase": len(decomp.get("purchase") or []),
"outsource": len(decomp.get("outsource") or []),
}
except Exception:
pass
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 = True) -> dict:
"""三模式对比(SC-13):沙盒内 ASC/DESC/BOTTLENECK 并行试排,不改主干。"""
from server.state.seed import ensure_flex_seed
from server.timeutil import add_minutes, fmt_date, today0
ensure_flex_seed(store.data)
start = fmt_date(add_minutes(today0(), 24 * 60))
rows = []
for mode, label in (("ASC", "正排"), ("DESC", "倒排"), ("BOTTLENECK", "瓶颈锚")):
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)
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"],
})
return {"rows": rows, "compressedDue": compress_due,
"hint": "交期宽松时三模式趋同;压力下正排保早交期、倒排后置、瓶颈锚优先喂饱瓶颈。"}
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)