aps-agent/server/aps_domain/analytics.py

467 lines
20 KiB
Python
Raw Normal View History

# ============================================================
# 分析视图(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:资源利用率增强报告(柔性设备 + 班组)。
设备口径与「设备负荷」视口一致:只统计落在班次工作窗内的时间,
统计窗口跟着排产结果走;否则计划排在 10 月时整屏都会是 0%/休。
"""
from server.aps_domain.views import _latest_flex_version, _shift_windows_for
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":
# 固定产线走「设备负荷」视口的产线口径,这里只做指路
from server.aps_domain.views import load_view
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") or 0) > 1),
},
"equipment": [], "teams": [], "days": lv.get("days", []),
"hint": "固定产线请看「设备负荷」视图;这里只统计柔性试排的设备与班组。",
"load": lv,
}
ensure_flex_seed(world)
latest = _latest_flex_version(world)
vid = latest["id"] if latest else None
wos = [w for w in world.get("flexWorkOrders") or []
if (not vid or w.get("versionId") == vid) and not w.get("frozen")]
# 统计窗口跟着排产结果走:从最早开工那天开始,没有排产才退回今天
starts = []
for w in wos:
try:
starts.append(parse_dt(w["plannedStartTime"]))
except Exception:
continue
base = min(starts).replace(hour=0, minute=0, second=0, microsecond=0) if starts else today0()
day_list = [fmt_date(add_minutes(base, i * 24 * 60)) for i in range(max(1, days))]
day_windows = {d: _shift_windows_for(world, d) for d in day_list} # 当日有效工作窗
shift_total = sum((b - a).total_seconds() / 60
for ws in day_windows.values() for a, b in ws) # 窗口内总可用分钟
# ---- 设备行 ----
eq_rows = []
for eq in world.get("flexEquipment", []) or []:
eid = eq["id"]
rate = float(eq.get("availabilityRate") if eq.get("availabilityRate") is not None else 1.0)
used_total = 0.0
cells = []
for d in day_list:
windows = day_windows[d]
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
for a, b in windows: # 只累计与工作窗的重叠
lo, hi = max(ws, a), min(we, b)
if hi > lo:
used += (hi - lo).total_seconds() / 60
avail = round(sum((b - a).total_seconds() / 60 for a, b in windows) * rate) if windows else 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), "offShift": not windows,
})
used_total += used
avail_total = sum(c["avail"] for c in cells)
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
avail = members * shift_total # 班组可用人时 = 人数 × 窗口内班次分钟
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
report = {
"track": "flex",
"versionNo": latest.get("versionNo") if latest else None,
"versionStatus": latest.get("status") if latest else None,
"trialOnly": bool(latest.get("trialOnly")) if latest else None,
"shiftMinutes": round(shift_total),
"windowStart": day_list[0] if day_list else None,
"windowDays": len(day_list),
"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": "占用时间只统计班次内的时间:绿色=空闲,黄色=偏忙,橙色=接近排满,"
"红色=排满还超了;班组「人手到顶」表示同一时段需要的操作人数已达到班组人数。",
}
if shift_total <= 0:
report["emptyReason"] = "这个工厂还没有登记班次,算不出可用工时:请先在制造主数据里补班次。"
elif not wos:
report["emptyReason"] = "这一版柔性试排没有排任何工序,设备占用为空。"
return report
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": "所有订单超出交期的小时数合计"},
{"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": "车间完工率",
"value": (f"{round(mes_pct * 100)}%" if mes_pct is not None else "—"),
"hint": (f"已下发车间 {len(mes_rows)} 条 · 已完工 {mes_done} 条"
if mes_rows else "还没有工序下发到车间,暂时算不出完工率")},
]
# 数据来源说明:这张表算的是哪一版、有没有下发车间,必须写在页面上。
if fixed.get("hasVersion"):
source = {"track": "FIXED", "versionNo": fixed.get("versionNo"),
"status": fixed.get("status"), "trialOnly": False}
elif flex:
source = {"track": "FLEX", "versionNo": flex.get("versionNo"),
"status": flex.get("status"), "trialOnly": True}
else:
source = None
return {
"cards": cards,
"fixed": fixed if fixed.get("hasVersion") else None,
"flex": flex,
"source": source,
"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,
data_quality: dict | None = None,
due_basis: str | None = None) -> dict:
cols = [{
"id": r["sortMode"], "label": r["label"], "sortMode": r["sortMode"],
"strategyStatus": str(r.get("strategyStatus") or "READY").upper(),
"strategyEvidence": r.get("strategyEvidence") or [],
"resultStatus": r.get("resultStatus"),
"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", "总延误(小时)", True),
("onTimeCount", "能按时交付的订单", False),
("orderCount", "订单总数", True),
("conflictCount", "需要处理的冲突", True),
("vlCount", "排产产线数", True),
("avgUtilization", "设备利用率 (%)", False),
]
section = _matrix_from_cards(cols, metrics)
quality = data_quality or {}
if due_basis:
section["dueBasis"] = due_basis
production_ready = bool(quality.get("productionReady", True))
ready_rows = [
row for row in rows
if str(row.get("strategyStatus") or "READY").upper() == "READY"
and str(row.get("resultStatus") or "READY").upper() in {"READY", "WITH_CONFLICTS"}
]
best = min(
ready_rows,
key=lambda r: (
r["totalTardiness"], r["conflictCount"], -int(r.get("onTimeCount") or 0),
),
) if ready_rows else None
if best is None:
recommendation_status = "UNAVAILABLE"
recommendation_reason = "没有 strategyStatus=READY 的策略,请先补齐策略输入数据后重新试排。"
elif production_ready:
recommendation_status = "READY"
recommendation_reason = "策略输入与生产数据质量检查通过。"
else:
recommendation_status = "TRIAL_ONLY"
warning_messages = [
str(warning.get("message"))
for warning in (quality.get("warnings") or [])
if warning.get("message")
]
recommendation_reason = "数据待补:" + (
";".join(warning_messages) if warning_messages else "生产数据质量尚未就绪"
)
section.update({
"id": "flex", "title": "柔性排产 · 五种排序方式对比",
"hint": hint,
"dataQuality": quality,
"productionReady": production_ready,
"recommendationStatus": recommendation_status,
"recommendationReason": recommendation_reason,
"recommendation": best["label"] if best else None,
"recommendationId": best["sortMode"] if best else None,
"eligibleRecommendationIds": [row["sortMode"] for row in ready_rows],
})
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"):
fixed_orders = len(world.get("productionOrders") or []) + len(world.get("workOrders") or [])
if fixed_orders == 0:
# 固定产线一张单都没排过,硬算只会得到一排 0,看起来像"策略都一样"。
out["sections"].append({
"id": "fixed", "title": "固定产线 · 排产策略对比",
"unavailable": True,
"unavailableReason": (
"这个项目还没有固定产线的生产订单和工单,无法做策略对比。"
"下方柔性排产对比使用的是已导入的柔性订单数据。"
),
"columns": [], "rows": [],
})
else:
_, 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=False)
out["sections"].append(matrix_from_flex_rows(
flex["rows"], hint=flex.get("hint"), data_quality=flex.get("dataQuality"),
due_basis=flex.get("dueBasis")))
return out