aps-agent/server/aps_domain/views.py

276 lines
17 KiB
Python
Raw Permalink 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-views, 可重生 ✅)
# 把世界状态投影为前端三个视口所需的数据(P0 只读):
# summary(KPI) / gantt(甘特) / load(负荷热力) / due(交期承诺看板)
# 过滤/高亮所需的标记(客户、超期、冲突)由后端一次性算好附在数据上
# ============================================================
from __future__ import annotations # 前向类型引用
from typing import Any # 类型标注
from server.engines.queries import get_available_minutes # 班次可用分钟(热力图)
from server.timeutil import add_minutes, fmt_date, parse_dt, today0 # 日期工具
# 世界状态类型别名
World = dict[str, Any]
def _latest_version(world: World) -> dict | None:
"""取最新排产版本(无版本返回 None)。"""
versions = world["scheduleVersions"] # 版本表
return versions[-1] if versions else None # 末尾即最新
def _latest_flex_version(world: World) -> dict | None:
"""取最新柔性排产版本(无版本返回 None)。"""
versions = world.get("flexScheduleVersions") or [] # 柔性版本表
return versions[-1] if versions else None # 末尾即最新
def _version_pos(world: World, version_id: int) -> list[dict]:
"""取某版本的全部生产订单。"""
return [p for p in world["productionOrders"] if p["schedulingVersionId"] == version_id] # 按版本过滤
def _flex_promised_by_order(world: World, version_id: int) -> dict[str, str]:
"""柔性版本下各订单的承诺完成=该订单虚拟产线的最晚完成时间(键=订单号)。"""
promised: dict[str, str] = {} # 订单号 → 最晚完成时间
for vl in world.get("flexVirtualLines") or []: # 遍历虚拟产线
if vl.get("versionId") != version_id or not vl.get("plannedEnd"):
continue # 非本版本 / 无完成时间不参与
order_no = str(vl.get("orderNo") or "") # 订单号
if not order_no: # 无订单号跳过
continue
current = promised.get(order_no) # 已记录的完成时间
if current is None or vl["plannedEnd"] > current: # "YYYY-MM-DD HH:MM" 串序即时间序
promised[order_no] = vl["plannedEnd"]
return promised
def _is_overdue_po(world: World, po: dict) -> bool:
"""判断生产订单是否超期:计划完成晚于销售订单交期当日 18:00。"""
so = next((s for s in world["salesOrders"] if s["id"] == po["salesOrderId"]), None) # 关联订单
if not so or not po.get("plannedEndDate"): # 数据缺失视为不超期
return False
return parse_dt(po["plannedEndDate"]) > parse_dt(so["deliveryDate"] + " 18:00") # 比较交期
def world_summary(world: World) -> dict[str, Any]:
"""KPI 摘要:最新版本核心指标(视口工具栏 KPI 卡组数据源)。"""
v = _latest_version(world) # 最新版本
if not v: # 尚无版本
return {"hasVersion": False}
return { # 有版本 → 摘要字段
"hasVersion": True, "versionNo": v["versionNo"], "status": v["status"],
"woCount": v["woCount"], "poCount": v["poCount"], "conflictCount": v["conflictCount"],
"totalTardiness": round(v["totalTardiness"], 1),
"avgUtilization": round(v["avgUtilization"], 3),
"totalCost": round(v["totalCost"], 0),
}
def gantt_view(world: World) -> dict[str, Any]:
"""甘特视图数据:产线/工位骨架 + 最新版本工单(附过滤与高亮标记)。"""
v = _latest_version(world) # 最新版本
pos = _version_pos(world, v["id"]) if v else [] # 该版本 PO
po_by_id = {p["id"]: p for p in pos} # PO 索引
so_by_id = {s["id"]: s for s in world["salesOrders"]} # SO 索引
wos_out: list[dict] = [] # 输出工单集
for wo in world["workOrders"]: # 遍历全部工单
po = po_by_id.get(wo["productionOrderId"]) # 关联 PO(仅最新版本)
if not po: # 非本版本工单不输出
continue
so = so_by_id.get(po["salesOrderId"], {}) # 关联 SO(客户标记)
wos_out.append({ # 投影为前端 GanttWO 契约
"id": wo["id"], "orderNo": wo["orderNo"], "productId": wo["productId"],
"productName": wo["productName"], "quantity": wo["quantity"],
"operationName": wo["operationName"], "lineId": wo["lineId"],
"workstationId": wo["workstationId"], "workstationName": wo["workstationName"],
"start": wo["plannedStartTime"], "end": wo["plannedEndTime"],
"customerName": so.get("customerName", ""), "customerLevel": so.get("customerLevel", ""),
"overdue": _is_overdue_po(world, po), # 超期标记(高亮用)
# 冲突标记:齐套非通过 或 工单/PO 冲突计数 > 0
"conflict": wo["kitStatus"] != "PASSED" or wo["conflictCount"] > 0 or po["conflictCount"] > 0,
})
return { # 视图整体响应
"lines": [{"id": l["id"], "code": l["code"], "name": l["name"]} for l in world["lines"]], # 产线骨架
"workstations": [{"id": w["id"], "lineId": w["lineId"], "name": w["name"], "seq": w["sequenceNo"]}
for w in world["workstations"]], # 工位骨架
"workOrders": wos_out, # 工单条
}
def load_view(world: World, days: int = 14) -> dict[str, Any]:
"""负荷热力视图:行 × 未来 N 天的负荷率(占用/可用分钟)。
固定产线有版本时按产线展示;这个工厂只有柔性试排结果时,按设备展示,
否则计划员明明已经排完产,负荷页却整屏都是"休"。
"""
v = _latest_version(world) # 最新固定产线版本
if v:
pos = _version_pos(world, v["id"]) # 该版本 PO
po_ids = {p["id"] for p in pos} # PO ID 集
day_list = [fmt_date(add_minutes(today0(), i * 24 * 60)) for i in range(days)] # 日期列
rows: list[dict] = [] # 输出行集
for line in world["lines"]: # 逐产线
cells: list[dict] = [] # 该产线的逐日单元格
for d in day_list: # 逐日
used = sum( # 当日占用分钟:本版本、本线、当日开工的工单
(parse_dt(w["plannedEndTime"]) - parse_dt(w["plannedStartTime"])).total_seconds() / 60
for w in world["workOrders"]
if w["productionOrderId"] in po_ids and w["lineId"] == line["id"]
and w["plannedStartTime"][:10] == d)
avail = get_available_minutes(world, line["id"], d) # 当日可用分钟
ratio = (used / avail) if avail else 0.0 # 负荷率(无班次记 0,前端按 avail=0 显示"休")
cells.append({"ratio": round(ratio, 3), "used": round(used), "avail": avail}) # 单元格
rows.append({"lineId": line["id"], "lineCode": line["code"], # 产线行(带编码供聚焦匹配)
"lineName": line["name"], "cells": cells})
return {"days": day_list, "rows": rows, "track": "FIXED", "rowLabel": "产线"} # 视图响应
flex = _latest_flex_version(world) # 固定通道无版本 → 看柔性试排
if not flex:
day_list = [fmt_date(add_minutes(today0(), i * 24 * 60)) for i in range(days)]
return {"days": day_list, "rows": [], "track": None, "rowLabel": "产线",
"emptyReason": "这个项目还没有排产结果:固定产线没有排产版本,柔性排产也还没有试排记录。"}
# 未来排产的窗口跟着计划走:计划 10 月开工时,从今天起算两周只会看到满屏空白。
version_wos = [w for w in world.get("flexWorkOrders") or []
if w.get("versionId") == flex["id"] and w.get("plannedStartTime")]
starts = [parse_dt(w["plannedStartTime"]) for w in version_wos]
base = max(today0(), 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(days)]
return _flex_load_view(world, flex, day_list) # 柔性设备负荷
def _shift_windows_for(world: World, day: str) -> list[tuple[Any, Any]]:
"""柔性班次当天的有效工作窗(已扣休息;未登记班次返回空列表)。"""
shifts = world.get("flexCalendar") or [] # 柔性班次表
if not shifts: # 没有班次数据不猜
return []
weekday = parse_dt(day).isoweekday() # 1=周一 … 7=周日
windows: list[tuple[Any, Any]] = [] # 当天全部工作窗
for shift in shifts: # 逐条班次
if shift.get("enabled") is False: # 备用/默认关闭的班次不占产能
continue
if "关闭" in str(shift.get("sourceStatus") or ""): # 资料里标注停用的班次
continue
workdays = shift.get("workdays") # 适用星期
if workdays and weekday not in workdays: # 当天不上班
continue
start, end = shift.get("startTime"), shift.get("endTime") # 班次起止
if not start or not end:
continue
start_min, end_min = _clock_minutes(start), _clock_minutes(end) # 起止分钟
breaks = sorted( # 休息按时间排序
(_clock_minutes(b.get("start")), _clock_minutes(b.get("end")))
for b in (shift.get("breaks") or [])
)
cursor = start_min # 从班次起点开始切
for b_start, b_end in breaks: # 用休息切出工作窗
if b_start > cursor:
windows.append((parse_dt(f"{day} 00:00") + _minutes(cursor),
parse_dt(f"{day} 00:00") + _minutes(min(b_start, end_min))))
cursor = max(cursor, b_end) # 跳到休息后
if end_min > cursor: # 收尾工作窗
windows.append((parse_dt(f"{day} 00:00") + _minutes(cursor),
parse_dt(f"{day} 00:00") + _minutes(end_min)))
return windows
def _minutes(value: int):
"""分钟数转 timedelta(避免上层重复 import)。"""
from datetime import timedelta
return timedelta(minutes=max(0, value))
def _clock_minutes(value: str | None) -> int:
"""把 "HH:MM" 转成当日分钟数(缺失返回 0)。"""
text = str(value or "") # 容错
if ":" not in text:
return 0
hh, _, mm = text.partition(":") # 拆时/分
try:
return int(hh) * 60 + int(mm)
except ValueError:
return 0
def _flex_load_view(world: World, flex: dict, day_list: list[str]) -> dict[str, Any]:
"""柔性设备负荷:行=设备,单元格=当日已排工序占用分钟 ÷ 当日可动分钟。
只统计落在班次工作窗内的时间,跨夜工单不会把夜里算成设备占用。
"""
version_id = flex["id"] # 目标版本
wos = [w for w in world.get("flexWorkOrders") or [] if w.get("versionId") == version_id]
if not wos: # 版本内没有工单 → 如实空态
return {"days": day_list, "rows": [], "track": "FLEX", "rowLabel": "设备",
"versionNo": flex.get("versionNo"),
"emptyReason": f"柔性试排版本 {flex.get('versionNo')} 里还没有排到设备上的工序。"}
used_ids = {w.get("equipmentId") for w in wos} # 本次排产用到的设备
equipment = [e for e in world.get("flexEquipment") or [] if e.get("id") in used_ids]
day_bounds = { # 预解析每张工单的起止
id(w): (parse_dt(w["plannedStartTime"]), parse_dt(w["plannedEndTime"]))
for w in wos if w.get("plannedStartTime") and w.get("plannedEndTime")
}
rows: list[dict] = [] # 输出行
for eq in sorted(equipment, key=lambda e: (str(e.get("zone") or ""), str(e.get("code") or ""))):
rate = float(eq.get("availabilityRate") if eq.get("availabilityRate") is not None else 1.0)
cells: list[dict] = [] # 该设备逐日单元格
for d in day_list: # 逐日
windows = _shift_windows_for(world, d) # 当日工作窗
avail = round(sum((b - a).total_seconds() / 60 for a, b in windows) * rate) if windows else 0
used = 0.0 # 当日占用
for w in wos: # 逐工单算与工作窗的重叠
if w.get("equipmentId") != eq.get("id"):
continue
bounds = day_bounds.get(id(w))
if not bounds:
continue
ws, we = bounds # 工单起止
for a, b in windows: # 逐工作窗求交集
lo, hi = max(ws, a), min(we, b)
if hi > lo:
used += (hi - lo).total_seconds() / 60
ratio = (used / avail) if avail else 0.0 # 负荷率
cells.append({"ratio": round(ratio, 3), "used": round(used), "avail": avail})
rows.append({
"lineId": eq.get("id"), "lineCode": eq.get("code"),
"lineName": eq.get("name") or eq.get("code"),
"zone": eq.get("zone"), "cells": cells,
})
return {"days": day_list, "rows": rows, "track": "FLEX", "rowLabel": "设备",
"versionNo": flex.get("versionNo"), "trialOnly": bool(flex.get("trialOnly"))} # 视图响应
def due_view(world: World) -> list[dict[str, Any]]:
"""交期承诺看板:逐销售订单的承诺完成、裕量与风险(PPT 交期承诺看板的 M1 形态)。
承诺完成优先取固定产线最新版本的工单;固定通道还没有版本时回退柔性通道最新版本的
虚拟产线。少了这层回退,已经用柔性排出来的计划在交期看板上会整列显示"未排产"。
"""
v = _latest_version(world) # 最新固定产线版本
flex = _latest_flex_version(world) if not v else None # 仅在没有固定版本时回退柔性
pos = _version_pos(world, v["id"]) if v else [] # 该版本 PO
flex_promised = _flex_promised_by_order(world, flex["id"]) if flex else {} # 柔性承诺完成
rows: list[dict] = [] # 输出行
for so in world["salesOrders"]: # 遍历订单
if so["status"] == "CANCELLED": # 已取消不展示
continue
if flex: # 柔性通道:该订单虚拟产线的最晚完成
promised = flex_promised.get(str(so["orderNo"]))
else: # 固定通道:该订单 PO 的最晚完成
my_pos = [p for p in pos if p["salesOrderId"] == so["id"]]
promised = max((p["plannedEndDate"] for p in my_pos), default=None)
margin: float | None = None # 裕量小时
risk = "none" # 默认:未排产
if promised: # 已排产 → 计算裕量与风险
due = parse_dt(so["deliveryDate"] + " 18:00") # 交期基准
margin = round((due - parse_dt(promised)).total_seconds() / 3600, 1) # 正=提前
risk = "high" if margin < 0 else ("mid" if margin < 24 else "low") # 风险分档
rows.append({ # 投影为 DueRow 契约
"orderNo": so["orderNo"], "customerName": so["customerName"],
"customerLevel": so["customerLevel"], "isRush": so["isRush"],
"deliveryDate": so["deliveryDate"], "promisedEnd": promised,
"marginHours": margin, "risk": risk,
})
return rows # 看板行集