aps-agent/server/aps_domain/views.py

128 lines
8.0 KiB
Python
Raw 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 _version_pos(world: World, version_id: int) -> list[dict]:
"""取某版本的全部生产订单。"""
return [p for p in world["productionOrders"] if p["schedulingVersionId"] == version_id] # 按版本过滤
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) # 最新版本
pos = _version_pos(world, v["id"]) if v else [] # 该版本 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} # 视图响应
def due_view(world: World) -> list[dict[str, Any]]:
"""交期承诺看板:逐销售订单的承诺完成、裕量与风险(PPT 交期承诺看板的 M1 形态)。"""
v = _latest_version(world) # 最新版本
pos = _version_pos(world, v["id"]) if v else [] # 该版本 PO
rows: list[dict] = [] # 输出行
for so in world["salesOrders"]: # 遍历订单
if so["status"] == "CANCELLED": # 已取消不展示
continue
my_pos = [p for p in pos if p["salesOrderId"] == so["id"]] # 该订单的 PO
promised = max((p["plannedEndDate"] for p in my_pos), default=None) # 承诺完成=各 PO 最晚完成
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 # 看板行集