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