# ============================================================ # 分析视图(moduleId: domain-analytics, 可重生 ✅) # EX-07 方案对比表(KPI 逐项 diff)/ EX-08 KPI 仪表盘 # ============================================================ from __future__ import annotations from typing import Any from server.aps_domain.views import world_summary from server.timeutil import parse_dt World = dict[str, Any] def build_utilization_report(world: World, track: str = "flex", days: int = 7) -> dict: """EX-06:资源利用率增强报告(柔性设备 + 班组)。""" from server.state.seed import ensure_flex_seed from server.timeutil import add_minutes, fmt_date, parse_dt, today0 tr = (track or "flex").lower() if tr != "flex": # 固定轨复用已有 load_view 语义,此处返回提示 from server.aps_domain.views import load_view, world_summary lv = load_view(world, days=days) s = world_summary(world) return { "track": "fixed", "summary": { "avgUtilization": s.get("avgUtilization"), "versionNo": s.get("versionNo"), "overloaded": sum(1 for r in lv.get("rows", []) for c in r.get("cells", []) if c.get("ratio", 0) > 1), }, "equipment": [], "teams": [], "days": lv.get("days", []), "hint": "固定轨请看「负荷热力」视口;本报告聚焦柔性设备/班组。", "load": lv, } ensure_flex_seed(world) versions = world.get("flexScheduleVersions") or [] latest = versions[-1] if versions else None vid = latest["id"] if latest else None wos = [w for w in world.get("flexWorkOrders", []) if (not vid or w.get("versionId") == vid) and not w.get("frozen")] cal = (world.get("flexCalendar") or [{}])[0] # 单班可用分钟(扣休息) try: sh0 = parse_dt(f"2000-01-01 {cal.get('startTime', '08:00')}") sh1 = parse_dt(f"2000-01-01 {cal.get('endTime', '17:00')}") shift = (sh1 - sh0).total_seconds() / 60 for br in cal.get("breaks") or []: b0 = parse_dt(f"2000-01-01 {br['start']}") b1 = parse_dt(f"2000-01-01 {br['end']}") shift -= (b1 - b0).total_seconds() / 60 except Exception: shift = 480.0 shift = max(shift, 60) base = today0() day_list = [fmt_date(add_minutes(base, i * 24 * 60)) for i in range(max(1, days))] workdays = set(cal.get("workdays") or [1, 2, 3, 4, 5]) # Mon=1 def _is_workday(dstr: str) -> bool: try: wd = parse_dt(dstr + " 00:00").isoweekday() return wd in workdays except Exception: return True # ---- 设备行 ---- eq_rows = [] for eq in world.get("flexEquipment", []) or []: eid = eq["id"] used_total = 0.0 cells = [] for d in day_list: ds, de = parse_dt(d + " 00:00"), parse_dt(d + " 23:59") used = 0.0 for w in wos: if w.get("equipmentId") != eid: continue try: ws, we = parse_dt(w["plannedStartTime"]), parse_dt(w["plannedEndTime"]) except Exception: continue ov = max(0.0, (min(we, de) - max(ws, ds)).total_seconds() / 60) used += ov avail = shift * (eq.get("availabilityRate") or 1) if _is_workday(d) else 0.0 ratio = (used / avail) if avail else 0.0 cells.append({ "date": d, "used": round(used, 1), "avail": round(avail, 1), "ratio": round(ratio, 3), }) used_total += used work_n = sum(1 for d in day_list if _is_workday(d)) avail_total = shift * (eq.get("availabilityRate") or 1) * max(work_n, 1) util = used_total / avail_total if avail_total else 0.0 eq_rows.append({ "code": eq.get("code"), "name": eq.get("name"), "zone": eq.get("zone"), "status": eq.get("status"), "movable": bool(eq.get("movable")), "usedMinutes": round(used_total, 1), "availMinutes": round(avail_total, 1), "utilization": round(util, 3), "peakRatio": max((c["ratio"] for c in cells), default=0), "cells": cells, "alert": "crit" if util >= 0.95 or any(c["ratio"] > 1 for c in cells) else "warn" if util >= 0.85 else "ok", }) eq_rows.sort(key=lambda r: -r["utilization"]) # ---- 班组行(SC-11):按工序并发峰值 / 人数 ---- team_rows = [] for team in world.get("flexTeams", []) or []: ops = list(team.get("supportOps") or []) members = int(team.get("memberCount") or 0) # 扫描工单:同班组覆盖工序的重叠最大并发 intervals = [] for w in wos: if w.get("operationCode") not in ops: continue if team.get("code") and w.get("teamCode") and w.get("teamCode") != team.get("code"): # 若工单已钉具体班组,只计本班组 continue try: intervals.append((parse_dt(w["plannedStartTime"]), parse_dt(w["plannedEndTime"]))) except Exception: pass peak = 0 for i, (s, e) in enumerate(intervals): concurrent = sum(1 for s2, e2 in intervals if s < e2 and e > s2) peak = max(peak, concurrent) # 粗利用率:本班组工单总分钟 / (人数 × 班次 × 工作日) used = 0.0 for s, e in intervals: used += (e - s).total_seconds() / 60 work_n = sum(1 for d in day_list if _is_workday(d)) avail = members * shift * max(work_n, 1) util = used / avail if avail else 0.0 team_rows.append({ "code": team.get("code"), "name": team.get("name"), "memberCount": members, "supportOps": ops, "skillLevel": team.get("skillLevel"), "peakConcurrent": peak, "usedMinutes": round(used, 1), "availMinutes": round(avail, 1), "utilization": round(util, 3), "saturated": members > 0 and peak >= members, "alert": "crit" if (members > 0 and peak > members) or util >= 0.95 else "warn" if peak >= members or util >= 0.85 else "ok", }) team_rows.sort(key=lambda r: -r["utilization"]) utils = [r["utilization"] for r in eq_rows if r["status"] == "RUNNING"] avg = round(sum(utils) / len(utils), 3) if utils else 0.0 return { "track": "flex", "versionNo": latest.get("versionNo") if latest else None, "versionStatus": latest.get("status") if latest else None, "shiftMinutes": round(shift), "days": day_list, "summary": { "avgUtilization": avg, "equipmentCount": len(eq_rows), "runningCount": sum(1 for r in eq_rows if r["status"] == "RUNNING"), "overloaded": sum(1 for r in eq_rows if r["alert"] == "crit"), "warnCount": sum(1 for r in eq_rows if r["alert"] == "warn"), "idleCount": sum(1 for r in eq_rows if r["utilization"] < 0.15 and r["status"] == "RUNNING"), "teamSaturated": sum(1 for r in team_rows if r.get("saturated")), }, "equipment": eq_rows, "teams": team_rows, "hint": "绿<50% 黄<85% 橙≤100% 红超载;班组 saturated=并发触及人数上限(SC-11)。", } def build_kpi_dashboard(world: World) -> dict: """EX-08:固定轨 + 柔性轨 + 执行进度聚合仪表盘(只读)。""" from server.state.seed import ensure_flex_seed ensure_flex_seed(world) fixed = world_summary(world) # 准时率(固定):非超期 PO / 全部 PO on_time_rate = None wip = 0 overdue_po = 0 if fixed.get("hasVersion"): v = world["scheduleVersions"][-1] pos = [p for p in world["productionOrders"] if p.get("schedulingVersionId") == v["id"]] so_by = {s["id"]: s for s in world["salesOrders"]} for po in pos: so = so_by.get(po.get("salesOrderId")) if so and po.get("plannedEndDate"): late = parse_dt(po["plannedEndDate"]) > parse_dt(so["deliveryDate"] + " 18:00") if late: overdue_po += 1 if po.get("status") in ("CONFIRMED", "IN_PROGRESS", "RELEASED"): wip += 1 if pos: on_time_rate = round((len(pos) - overdue_po) / len(pos), 3) flex_ver = (world.get("flexScheduleVersions") or [None])[-1] flex = None if flex_ver: oc = flex_ver.get("orderCount") or flex_ver.get("vlCount") or 0 ot = flex_ver.get("onTimeCount") or 0 flex = { "versionNo": flex_ver.get("versionNo"), "status": flex_ver.get("status"), "sortMode": flex_ver.get("sortMode"), "woCount": flex_ver.get("woCount", 0), "vlCount": flex_ver.get("vlCount", 0), "conflictCount": flex_ver.get("conflictCount", 0), "totalTardiness": round(flex_ver.get("totalTardiness") or 0, 1), "avgUtilization": round(flex_ver.get("avgUtilization") or 0, 3), "onTimeCount": ot, "orderCount": oc, "onTimeRate": round(ot / oc, 3) if oc else None, "makespan": flex_ver.get("makespan"), } # 冲突未解决 flex_cf = sum(1 for c in world.get("flexConflicts", []) if not c.get("isResolved")) fixed_cf = sum(1 for c in world.get("conflicts", []) if not c.get("isResolved") and ( not fixed.get("hasVersion") or c.get("versionId") == world["scheduleVersions"][-1]["id"])) # MES 执行 mes_rows = [w for w in world.get("flexWorkOrders", []) if w.get("mesExternalId")] if flex_ver: mes_rows = [w for w in mes_rows if w.get("versionId") == flex_ver["id"]] mes_done = sum(1 for w in mes_rows if w.get("status") == "COMPLETED" or (w.get("progressPct") or 0) >= 100) mes_pct = round(mes_done / len(mes_rows), 3) if mes_rows else None # 柔性在制 flex_wip = sum(1 for w in world.get("flexWorkOrders", []) if flex_ver and w.get("versionId") == flex_ver["id"] and w.get("status") in ("RUNNING", "RELEASED", "PENDING")) cards = [ {"id": "util", "label": "平均利用率", "value": (f"{round((fixed.get('avgUtilization') or 0) * 100)}%" if fixed.get("hasVersion") else (f"{round((flex or {}).get('avgUtilization', 0) * 100)}%" if flex else "—")), "hint": "固定轨优先,否则柔性"}, {"id": "ontime", "label": "准时率", "value": (f"{round(on_time_rate * 100)}%" if on_time_rate is not None else (f"{round(flex['onTimeRate'] * 100)}%" if flex and flex.get("onTimeRate") is not None else "—")), "hint": "按期完工订单占比"}, {"id": "delay", "label": "总延迟", "value": (f"{fixed.get('totalTardiness')}h" if fixed.get("hasVersion") else (f"{flex['totalTardiness']}h" if flex else "—")), "hint": "版本合计 tardiness"}, {"id": "wip", "label": "在制工单", "value": str(wip + flex_wip), "hint": f"固定 {wip} + 柔性 {flex_wip}"}, {"id": "conflict", "label": "未解冲突", "value": str(fixed_cf + flex_cf), "hint": f"固定 {fixed_cf} / 柔性 {flex_cf}"}, {"id": "mes", "label": "MES 完工率", "value": (f"{round(mes_pct * 100)}%" if mes_pct is not None else "—"), "hint": f"已下发 {len(mes_rows)} · 完工 {mes_done}"}, ] return { "cards": cards, "fixed": fixed if fixed.get("hasVersion") else None, "flex": flex, "execution": { "dispatched": len(mes_rows), "completed": mes_done, "progressRate": mes_pct, }, "conflicts": {"fixed": fixed_cf, "flex": flex_cf, "total": fixed_cf + flex_cf}, "generatedAt": None, } def _matrix_from_cards( columns: list[dict], metrics: list[tuple[str, str, bool]], ) -> dict: """metrics: (key, label, lower_is_better)。从 columns[].kpi 抽逐项表。""" rows = [] for key, label, lower_better in metrics: values = [] for col in columns: kpi = col.get("kpi") or col val = kpi.get(key) if key == "avgUtilization" and val is not None and val <= 1: val = round(val * 100, 1) values.append(val) numeric = [(i, v) for i, v in enumerate(values) if isinstance(v, (int, float))] best_idx = None if numeric: best_idx = (min if lower_better else max)(numeric, key=lambda x: x[1])[0] best_val = values[best_idx] if best_idx is not None else None deltas = [] for v in values: if best_val is None or not isinstance(v, (int, float)): deltas.append(None) else: deltas.append(round(v - best_val, 2) if isinstance(best_val, float) else v - best_val) rows.append({ "metric": key, "label": label, "lowerIsBetter": lower_better, "values": values, "bestIdx": best_idx, "deltas": deltas, }) return {"columns": columns, "rows": rows} def matrix_from_fixed_cards(cards: list[dict], *, baseline: str | None = None) -> dict: cols = [{ "id": c["strategy"], "label": c["label"], "strategy": c["strategy"], "kpi": c["kpi"], "diffVsBaseline": c.get("diffVsBaseline"), "risks": c.get("risks") or [], } for c in cards] metrics = [ ("totalTardiness", "总延迟 (h)", True), ("conflictCount", "冲突数", True), ("avgUtilization", "利用率 (%)", False), ("totalCost", "预估成本", True), ("woCount", "工单数", True), ("poCount", "生产订单", True), ] section = _matrix_from_cards(cols, metrics) best = min(cards, key=lambda c: (c["kpi"]["totalTardiness"], c["kpi"]["conflictCount"])) section.update({ "id": "fixed", "title": "固定轨 · 策略对比", "baseline": baseline, "recommendation": best["label"], "recommendationId": best["strategy"], }) return section def matrix_from_flex_rows(rows: list[dict], *, hint: str | None = None) -> dict: cols = [{ "id": r["sortMode"], "label": r["label"], "sortMode": r["sortMode"], "kpi": { "totalTardiness": r["totalTardiness"], "conflictCount": r["conflictCount"], "avgUtilization": r["avgUtilization"], "onTimeCount": r["onTimeCount"], "orderCount": r["orderCount"], "vlCount": r["vlCount"], }, } for r in rows] metrics = [ ("totalTardiness", "总延迟 (h)", True), ("conflictCount", "冲突数", True), ("avgUtilization", "利用率 (%)", False), ("onTimeCount", "准时订单", False), ("vlCount", "虚拟产线", True), ("orderCount", "订单数", True), ] section = _matrix_from_cards(cols, metrics) best = min(rows, key=lambda r: (r["totalTardiness"], r["conflictCount"])) section.update({ "id": "flex", "title": "柔性轨 · 正排/倒排/瓶颈锚", "hint": hint, "recommendation": best["label"], "recommendationId": best["sortMode"], }) return section def build_compare_table(world: World, track: str = "both") -> dict: """EX-07:方案对比逐项 diff 表。track ∈ fixed|flex|both。""" from server.aps_domain.scenario import compare_scenarios from server.aps_domain.flex import compare_sort_modes from server.state.seed import ensure_flex_seed tr = (track or "both").lower() out: dict[str, Any] = {"track": tr, "sections": []} if tr in ("fixed", "both"): _, block = compare_scenarios(world) cards = block.props.get("cards") or [] out["sections"].append(matrix_from_fixed_cards( cards, baseline=block.props.get("baseline"))) if tr in ("flex", "both"): ensure_flex_seed(world) class _Tmp: def __init__(self, data): self.data = data flex = compare_sort_modes(_Tmp(world), compress_due=True) out["sections"].append(matrix_from_flex_rows(flex["rows"], hint=flex.get("hint"))) return out