Files
bj_power/bj_power_mes/internal/logic/dashboard.go
T
SunYF b6799c9de4 feat: 完成产线与仓储系统多模块迭代升级
本次迭代覆盖MES与WMS核心业务:
1. 新增接驳台托盘传感器读取与AGV对接能力
2. 完善工单排产、备料流程与权限体系拆分
3. 优化看板接口与前端路由、样式
4. 新增操作日志、库存盘点与角色保护逻辑
5. 修复代理地址、BOM保存等已知问题
2026-09-04 14:18:53 +08:00

834 lines
24 KiB
Go

package logic
import (
"context"
"sort"
"strconv"
"strings"
"time"
"bj_power_mes/ent"
"bj_power_mes/ent/dailyplan"
"bj_power_mes/ent/materialrequest"
"bj_power_mes/ent/plcsendlog"
"bj_power_mes/ent/torquerecord"
"bj_power_mes/ent/workorder"
"bj_power_mes/ent/workpiece"
"bj_power_mes/ent/workpieceprocess"
)
const dashboardTTL = 60 // 秒,看板缓存
// 工位活动判定窗口:窗口内有工序实绩视为「运行中」,否则为空闲。
// 旧实现用历史累计 done>0 判定,导致工位永远显示「运行中」,永不离线。
const stationActiveWindow = 5 * time.Minute
// ============================================================
// 主入口:看板全量快照
// ============================================================
// DashboardSnapshot 一次返回全部看板数据。
// 各子模块单独容错:某块查询失败只降级该块,不影响整屏(看板是展示系统,宁可缺一块也不要白屏)。
func (s *Service) DashboardSnapshot(ctx context.Context) (*DashboardSnapshot, error) {
return cachedTyped(ctx, s, "dashboard:snapshot", dashboardTTL, func() (*DashboardSnapshot, error) {
now := time.Now()
today := dayStart(now)
snap := &DashboardSnapshot{UpdatedAt: fmtDashboardTime(now)}
snap.Production = s.buildProduction(ctx, today)
snap.Quality = s.buildQuality(ctx, today)
snap.Trace = s.buildTrace(ctx)
snap.Warehouse = s.buildWarehouse(ctx)
snap.Equipment = s.buildEquipment(ctx, today)
snap.Agv = s.buildAgv(ctx)
snap.Performance = s.buildPerformance(ctx, today)
snap.Alarms = s.buildAlarms(ctx, today)
snap.Trends = s.buildTrends(ctx)
snap.Events = s.buildEvents(ctx)
return snap, nil
})
}
// ============================================================
// 屏1/屏2:生产统计
// ============================================================
func (s *Service) buildProduction(ctx context.Context, today time.Time) ProductionStats {
out := ProductionStats{}
tomorrow := today.Add(24 * time.Hour)
// 当日完工产量:以 workpiece.doneAt 落在本日为准
if n, err := s.ctx.EntClient.Workpiece.Query().
Where(workpiece.DoneAtGTE(today), workpiece.DoneAtLT(tomorrow)).Count(ctx); err == nil {
out.OutputToday = n
}
// 当日计划产量:日排产表当日 planQty 合计
out.TargetToday = s.sumDailyPlanQty(ctx, today.Format("2006-01-02"))
out.AchieveRate = safeRate(float64(out.OutputToday), float64(out.TargetToday))
// 在制:已进线未完工
if n, err := s.ctx.EntClient.Workpiece.Query().
Where(workpiece.DoneAtIsNil()).Count(ctx); err == nil {
out.InLine = n
}
// 当日工序实绩合格率(一次合格率口径:工序判定而非拧紧单次)
if steps, err := s.ctx.EntClient.WorkpieceProcess.Query().
Where(workpieceprocess.EndedAtGTE(today), workpieceprocess.EndedAtLT(tomorrow)).
All(ctx); err == nil {
for _, st := range steps {
if strings.EqualFold(st.Result, "NG") {
out.QualityNg++
} else {
out.QualityOk++
}
}
}
out.QualifiedRate = safeRate(float64(out.QualityOk), float64(out.QualityOk+out.QualityNg))
// 当日拧紧总次数
if n, err := s.ctx.EntClient.TorqueRecord.Query().
Where(torquerecord.TimeGTE(today), torquerecord.TimeLT(tomorrow)).Count(ctx); err == nil {
out.TorqueTotal = n
}
out.WorkOrders = s.buildWorkOrderProgress(ctx, today)
if len(out.WorkOrders) > 0 {
cur := out.WorkOrders[0]
out.CurrentOrder = &cur
}
// 工位状态汇总由 equipment 填充(避免重复查询),此处先占位
return out
}
func (s *Service) buildWorkOrderProgress(ctx context.Context, today time.Time) []WorkOrderProgress {
wos, err := s.ctx.EntClient.WorkOrder.Query().
Order(ent.Desc(workorder.FieldID)).Limit(20).All(ctx)
if err != nil {
return []WorkOrderProgress{}
}
date := today.Format("2006-01-02")
list := make([]WorkOrderProgress, 0, len(wos))
for _, w := range wos {
item := WorkOrderProgress{
OrderNo: w.WorkOrderNo,
ProductName: w.ProductName,
TotalQty: w.Quantity,
DoneQty: w.FinishedNum,
NgQty: w.FailNum,
Progress: safeRate(float64(w.FinishedNum), float64(w.Quantity)),
Status: w.Status,
PlanStart: fmtDashboardTime(orZero(w.PlanStart)),
PlanEnd: fmtDashboardTime(orZero(w.PlanEnd)),
DailyPlanQty: s.sumDailyPlanQtyByOrder(ctx, date, w.WorkOrderNo),
DailyDoneQty: s.countWorkpieceDoneByOrder(ctx, today, w.WorkOrderNo),
}
item.CurrentProcess = "工序 " + strings.TrimSpace(w.ProcessSeq)
list = append(list, item)
}
// 进行中的工单排前面,同状态按 ID 倒序
sort.SliceStable(list, func(i, j int) bool {
return rankOrderStatus(list[i].Status) < rankOrderStatus(list[j].Status)
})
return list
}
// ============================================================
// 屏3:质量分析
// ============================================================
func (s *Service) buildQuality(ctx context.Context, today time.Time) QualityStats {
out := QualityStats{OkRate: 0}
tomorrow := today.Add(24 * time.Hour)
// 近 200 条拧紧记录:用于绘制扭矩分布散点 + 计算统计量
recs, err := s.ctx.EntClient.TorqueRecord.Query().
Where(torquerecord.TimeGTE(today), torquerecord.TimeLT(tomorrow)).
Order(ent.Asc(torquerecord.FieldTime)).Limit(200).All(ctx)
if err != nil || len(recs) == 0 {
out.TorqueScatter = []TorquePoint{}
out.NgByStation = []RankItem{}
out.NgByOperator = []RankItem{}
out.DailyRate = s.buildDailyOkRate(ctx, 30)
return out
}
stat := TorqueStat{Count: len(recs), MinStrain: recs[0].Strain, MaxStrain: recs[0].Strain}
sum := 0.0
scatter := make([]TorquePoint, 0, len(recs))
ngByStation := map[string]int{}
ngByOperator := map[string]int{}
for i, r := range recs {
if strings.EqualFold(r.Result, "NG") {
stat.NgCount++
ngByStation[r.StationNo]++
ngByOperator[r.Operator]++
} else {
stat.OkCount++
}
sum += r.Strain
if r.Strain < stat.MinStrain {
stat.MinStrain = r.Strain
}
if r.Strain > stat.MaxStrain {
stat.MaxStrain = r.Strain
}
scatter = append(scatter, TorquePoint{Seq: i + 1, Strain: r.Strain, Result: r.Result})
}
stat.AvgStrain = sum / float64(len(recs))
// 规格上下限取自工艺步骤指标(step_criterion),取不到时回退为 0(前端不画控制线)
if lo, hi, target, unit, ok := s.loadTorqueCriterion(ctx); ok {
stat.LowerLimit = lo
stat.UpperLimit = hi
stat.Target = target
stat.Unit = unit
}
out.Torque = stat
out.OkRate = safeRate(float64(stat.OkCount), float64(stat.Count))
out.TorqueScatter = scatter
out.NgByStation = rankFromMap(ngByStation, "工位")
out.NgByOperator = rankFromMap(ngByOperator, "")
out.DailyRate = s.buildDailyOkRate(ctx, 30)
return out
}
// buildDailyOkRate 近 n 日工序一次合格率(供合格率走势图)
func (s *Service) buildDailyOkRate(ctx context.Context, days int) []TrendPoint {
now := time.Now()
points := make([]TrendPoint, 0, days)
for i := days - 1; i >= 0; i-- {
d := dayStart(now.AddDate(0, 0, -i))
next := d.Add(24 * time.Hour)
ok, ng := 0, 0
if steps, err := s.ctx.EntClient.WorkpieceProcess.Query().
Where(workpieceprocess.EndedAtGTE(d), workpieceprocess.EndedAtLT(next)).All(ctx); err == nil {
for _, st := range steps {
if strings.EqualFold(st.Result, "NG") {
ng++
} else {
ok++
}
}
}
points = append(points, TrendPoint{
Time: d.Format("01-02"),
Output: ok,
QualifiedRate: safeRate(float64(ok), float64(ok+ng)),
})
}
return points
}
// ============================================================
// 屏4:责任追溯
// ============================================================
// buildTrace 返回最近完工工件(含完整工序时间线),供大屏自动轮播
func (s *Service) buildTrace(ctx context.Context) TraceStats {
wps, err := s.ctx.EntClient.Workpiece.Query().
Where(workpiece.DoneAtNotNil()).
Order(ent.Desc(workpiece.FieldDoneAt)).Limit(10).All(ctx)
if err != nil {
return TraceStats{Recent: []TraceItem{}}
}
items := make([]TraceItem, 0, len(wps))
for _, w := range wps {
item := TraceItem{
Sn: w.Sn,
OrderNo: w.OrderNo,
Status: w.Status,
OnlineAt: fmtDashboardTime(orZero(w.OnlineAt)),
DoneAt: fmtDashboardTime(orZero(w.DoneAt)),
}
if w.DoneAt != nil && w.OnlineAt != nil {
item.DurationMin = w.DoneAt.Sub(*w.OnlineAt).Minutes()
}
item.Steps = s.loadTraceSteps(ctx, w.Sn)
item.StepCount = len(item.Steps)
for _, st := range item.Steps {
if strings.EqualFold(st.Result, "NG") {
item.NgCount++
} else {
item.OkCount++
}
}
items = append(items, item)
}
return TraceStats{Recent: items}
}
// loadTraceSteps 单工件的工序时间线:工序 / 工位 / 操作人 / 时间 / 判定 / 扭矩
func (s *Service) loadTraceSteps(ctx context.Context, sn string) []TraceStep {
steps, err := s.ctx.EntClient.WorkpieceProcess.Query().
Where(workpieceprocess.SnEQ(sn)).
Order(ent.Asc(workpieceprocess.FieldID)).All(ctx)
if err != nil {
return []TraceStep{}
}
// 该工件的拧紧数据:按工位聚合,用于展示每道工序的实际扭矩
tq, _ := s.ctx.EntClient.TorqueRecord.Query().
Where(torquerecord.SnEQ(sn)).All(ctx)
type agg struct{ count, ng int; sum float64 }
byStation := map[string]*agg{}
for _, t := range tq {
a, ok := byStation[t.StationNo]
if !ok {
a = &agg{}
byStation[t.StationNo] = a
}
a.count++
a.sum += t.Strain
if strings.EqualFold(t.Result, "NG") {
a.ng++
}
}
out := make([]TraceStep, 0, len(steps))
for _, st := range steps {
ts := TraceStep{
ProcessCode: st.ProcessCode,
ProcessName: st.ProcessName,
StationNo: atoiSafe(st.StationNo),
Operator: st.Operator,
Result: st.Result,
StartedAt: fmtDashboardTime(orZero(st.StartedAt)),
EndedAt: fmtDashboardTime(orZero(st.EndedAt)),
}
if a, ok := byStation[st.StationNo]; ok && a.count > 0 {
ts.TorqueCount = a.count
ts.TorqueNg = a.ng
ts.AvgStrain = a.sum / float64(a.count)
}
out = append(out, ts)
}
return out
}
// DashboardTrace 按需查询单个工件的追溯详情(供看板下钻,不进全量快照)
func (s *Service) DashboardTrace(ctx context.Context, sn string) (*TraceItem, error) {
w, err := s.ctx.EntClient.Workpiece.Query().Where(workpiece.SnEQ(sn)).Only(ctx)
if err != nil {
return nil, err
}
item := &TraceItem{
Sn: w.Sn,
OrderNo: w.OrderNo,
Status: w.Status,
OnlineAt: fmtDashboardTime(orZero(w.OnlineAt)),
DoneAt: fmtDashboardTime(orZero(w.DoneAt)),
Steps: s.loadTraceSteps(ctx, w.Sn),
}
item.StepCount = len(item.Steps)
for _, st := range item.Steps {
if strings.EqualFold(st.Result, "NG") {
item.NgCount++
} else {
item.OkCount++
}
}
return item, nil
}
// ============================================================
// 屏5:仓储动态(数据源 WMS 8890)
// ============================================================
func (s *Service) buildWarehouse(ctx context.Context) WarehouseStats {
out := WarehouseStats{Available: false, ZoneDetail: []ZoneStock{}}
if s.ctx.Wms == nil {
return out
}
data, err := s.ctx.Wms.DisplayOverview(ctx)
if err != nil {
return out
}
out.Available = true
out.TotalQty = toInt(data["totalQty"])
out.BatchCount = toInt(data["batchCount"])
out.SnInStock = toInt(data["snInStock"])
out.SemiInStock = toInt(data["semiInStock"])
out.PackageCount = toInt(data["packageCount"])
out.InToday = toInt(data["inToday"])
out.OutToday = toInt(data["outToday"])
out.LockedQty = toInt(data["lockedQty"])
out.MaterialTypes = toInt(data["materialTypes"])
if t, ok := data["time"].(string); ok {
out.UpdatedAt = t
}
if zm, ok := data["zoneDetail"].(map[string]any); ok {
for k, v := range zm {
parts := strings.SplitN(k, "/", 2)
z := ZoneStock{Qty: toInt(v)}
if len(parts) > 0 {
z.Zone = parts[0]
}
if len(parts) > 1 {
z.Quality = parts[1]
}
out.ZoneDetail = append(out.ZoneDetail, z)
}
sort.Slice(out.ZoneDetail, func(i, j int) bool {
return out.ZoneDetail[i].Qty > out.ZoneDetail[j].Qty
})
} else {
out.ZoneDetail = []ZoneStock{}
}
return out
}
// ============================================================
// 屏1:工位实时状态(3D 场景与工位矩阵共用)
// ============================================================
func (s *Service) buildEquipment(ctx context.Context, today time.Time) []StationState {
// 工位主数据
stMap := map[int]StationState{}
if sts, err := s.ctx.EntClient.Station.Query().All(ctx); err == nil {
for _, st := range sts {
stMap[st.StationNo] = StationState{
StationNo: st.StationNo,
Name: stationDisplayName(st.Name, st.StationNo),
Status: "idle",
}
if !strings.EqualFold(st.Status, "ENABLED") {
v := stMap[st.StationNo]
v.Status = "offline"
stMap[st.StationNo] = v
}
}
}
// 工位主数据缺失时兜底生成 12 个工位
for i := 1; i <= 12; i++ {
if _, ok := stMap[i]; !ok {
stMap[i] = StationState{StationNo: i, Name: stationDisplayName("", i), Status: "idle"}
}
}
window := time.Now().Add(-stationActiveWindow)
// 活动窗口内的工序实绩:判定 running/alarm,并取当前作业人与工件
if recent, err := s.ctx.EntClient.WorkpieceProcess.Query().
Where(workpieceprocess.EndedAtGTE(window)).
Order(ent.Desc(workpieceprocess.FieldEndedAt)).All(ctx); err == nil {
for _, r := range recent {
no := atoiSafe(r.StationNo)
v, ok := stMap[no]
if !ok {
continue
}
if v.Status == "offline" {
continue
}
if v.LastActiveAt == "" {
v.LastActiveAt = fmtDashboardTime(orZero(r.EndedAt))
v.CurrentSn = r.Sn
v.CurrentOperator = r.Operator
v.CurrentProcess = r.ProcessCode
}
if v.Status != "alarm" {
if strings.EqualFold(r.Result, "NG") {
v.Status = "alarm"
} else {
v.Status = "running"
}
}
stMap[no] = v
}
}
// 当日累计:每个工位的完成数与 NG 数
if steps, err := s.ctx.EntClient.WorkpieceProcess.Query().
Where(workpieceprocess.EndedAtGTE(today)).All(ctx); err == nil {
for _, r := range steps {
no := atoiSafe(r.StationNo)
v, ok := stMap[no]
if !ok {
continue
}
v.DoneToday++
if strings.EqualFold(r.Result, "NG") {
v.NgToday++
}
stMap[no] = v
}
}
out := make([]StationState, 0, len(stMap))
for _, v := range stMap {
out = append(out, v)
}
sort.Slice(out, func(i, j int) bool { return out[i].StationNo < out[j].StationNo })
return out
}
// ============================================================
// 3D 场景:AGV
// ============================================================
// buildAgv AGV 状态。
//
// RCS-2000 尚未对接,当前 Source="mock":任务取自真实备料单(material_request.targetDock),
// 即「哪台 AGV 在给哪个工位送什么料」是真实业务事件,仅坐标/电量/进度为模拟值。
// 接入 RCS 后改为调用 /api/robot/controller/robot/query 填充真实坐标即可,前端无需改动。
func (s *Service) buildAgv(ctx context.Context) AgvStats {
out := AgvStats{Source: "mock", Robots: []AgvRobot{}}
reqs, err := s.ctx.EntClient.MaterialRequest.Query().
Where(materialrequest.TargetDockNEQ("")).
Order(ent.Desc(materialrequest.FieldID)).Limit(2).All(ctx)
if err != nil || len(reqs) == 0 {
// 无任务时给出两台待命 AGV,3D 场景不至于空场
out.Robots = []AgvRobot{
{Code: "AGV-01", Status: "idle", Battery: 88, FromDock: "DOCK21", ToDock: "", Progress: 0},
{Code: "AGV-02", Status: "idle", Battery: 76, FromDock: "DOCK21", ToDock: "", Progress: 0},
}
return out
}
for i, r := range reqs {
carrier := r.MaterialName
if carrier == "" {
carrier = r.MaterialCode
}
out.Robots = append(out.Robots, AgvRobot{
Code: "AGV-" + pad2(i+1),
Status: agvStatusFromRequest(r.Status),
Battery: 90 - i*12,
FromDock: "DOCK21",
ToDock: r.TargetDock,
TaskNo: r.RequestNo,
Carrier: carrier,
// 进度按任务创建时间线性推进,60 秒走完全程;仅用于 3D 动效
Progress: mockAgvProgress(r.CreatedAt),
})
}
return out
}
// agvStatusFromRequest 备料单状态 → AGV 状态(3D 动效据此播放不同动画)
func agvStatusFromRequest(status string) string {
switch strings.ToUpper(status) {
case "LOCKED":
return "loading"
case "DELIVERING":
return "moving"
case "DONE":
return "idle"
default:
return "waiting"
}
}
// ============================================================
// 屏6:人员绩效
// ============================================================
func (s *Service) buildPerformance(ctx context.Context, today time.Time) []RankItem {
steps, err := s.ctx.EntClient.WorkpieceProcess.Query().
Where(workpieceprocess.EndedAtGTE(today)).All(ctx)
if err != nil {
return []RankItem{}
}
type agg struct {
count, ok, ng int
stations map[int]bool
}
byOp := map[string]*agg{}
for _, st := range steps {
if st.Operator == "" {
continue
}
a, ok := byOp[st.Operator]
if !ok {
a = &agg{stations: map[int]bool{}}
byOp[st.Operator] = a
}
a.count++
if strings.EqualFold(st.Result, "NG") {
a.ng++
} else {
a.ok++
}
a.stations[atoiSafe(st.StationNo)] = true
}
out := make([]RankItem, 0, len(byOp))
for op, a := range byOp {
item := RankItem{
Key: op,
Label: op,
Count: a.count,
OkCount: a.ok,
NgCount: a.ng,
OkRate: safeRate(float64(a.ok), float64(a.count)),
}
for no := range a.stations {
item.StationNos = append(item.StationNos, no)
}
sort.Ints(item.StationNos)
out = append(out, item)
}
sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count })
return out
}
// ============================================================
// 报警 / 趋势 / 事件流
// ============================================================
func (s *Service) buildAlarms(ctx context.Context, today time.Time) []AlarmItem {
items := []AlarmItem{}
if n, err := s.ctx.EntClient.TorqueRecord.Query().
Where(torquerecord.Result("NG"), torquerecord.TimeGTE(today)).Count(ctx); err == nil && n > 0 {
items = append(items, AlarmItem{
ID: "torque-ng", Level: "critical", Type: "quality",
Target: "拧紧", Message: "当日拧紧 NG " + itoa(n) + " 次", Time: fmtDashboardTime(time.Now()),
Status: "active",
})
}
if n, err := s.ctx.EntClient.PlcSendLog.Query().
Where(plcsendlog.Status("TIMEOUT")).Count(ctx); err == nil && n > 0 {
items = append(items, AlarmItem{
ID: "plc-timeout", Level: "critical", Type: "equipment",
Target: "PLC", Message: "存在 PLC 下发超时 " + itoa(n) + " 条", Time: fmtDashboardTime(time.Now()),
Status: "active",
})
}
if wh := s.buildWarehouse(ctx); wh.Available && wh.LockedQty > 0 {
items = append(items, AlarmItem{
ID: "stock-locked", Level: "warning", Type: "material",
Target: "库房", Message: "锁定库存 " + itoa(wh.LockedQty) + " 件待出库", Time: fmtDashboardTime(time.Now()),
Status: "active",
})
}
if len(items) == 0 {
items = []AlarmItem{}
}
return items
}
func (s *Service) buildTrends(ctx context.Context) []TrendPoint {
points := make([]TrendPoint, 0, 7)
for i := 6; i >= 0; i-- {
d := dayStart(time.Now().AddDate(0, 0, -i))
next := d.Add(24 * time.Hour)
done := 0
if n, err := s.ctx.EntClient.Workpiece.Query().
Where(workpiece.DoneAtGTE(d), workpiece.DoneAtLT(next)).Count(ctx); err == nil {
done = n
}
ok, ng := 0, 0
if steps, err := s.ctx.EntClient.WorkpieceProcess.Query().
Where(workpieceprocess.EndedAtGTE(d), workpieceprocess.EndedAtLT(next)).All(ctx); err == nil {
for _, st := range steps {
if strings.EqualFold(st.Result, "NG") {
ng++
} else {
ok++
}
}
}
points = append(points, TrendPoint{
Time: d.Format("01-02"),
Output: done,
QualifiedRate: safeRate(float64(ok), float64(ok+ng)),
})
}
return points
}
// buildEvents 底部实时事件跑马灯:取最近 20 条工序实绩
func (s *Service) buildEvents(ctx context.Context) []EventItem {
steps, err := s.ctx.EntClient.WorkpieceProcess.Query().
Where(workpieceprocess.EndedAtNotNil()).
Order(ent.Desc(workpieceprocess.FieldEndedAt)).Limit(20).All(ctx)
if err != nil {
return []EventItem{}
}
out := make([]EventItem, 0, len(steps))
for _, st := range steps {
level := "info"
if strings.EqualFold(st.Result, "NG") {
level = "error"
}
name := st.ProcessName
if name == "" {
name = "工序" + itoa(st.ProcessCode)
}
out = append(out, EventItem{
Time: fmtDateTimeHm(orZero(st.EndedAt)),
Text: "工位" + st.StationNo + " " + st.Operator + " 完成 " + st.Sn + " " + name + " " + st.Result,
Level: level,
})
}
return out
}
// ============================================================
// 辅助
// ============================================================
func dayStart(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
}
func orZero(t *time.Time) time.Time {
if t == nil {
return time.Time{}
}
return *t
}
func fmtDateTimeHm(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format("15:04")
}
func pad2(n int) string {
if n < 10 {
return "0" + itoa(n)
}
return itoa(n)
}
func atoiSafe(s string) int {
n, _ := strconv.Atoi(strings.TrimSpace(s))
return n
}
func stationDisplayName(name string, no int) string {
if strings.TrimSpace(name) != "" {
return name
}
return "工位" + itoa(no)
}
func toInt(v any) int {
switch x := v.(type) {
case float64:
return int(x)
case int:
return x
case int64:
return int(x)
case string:
n, _ := strconv.Atoi(x)
return n
}
return 0
}
// rankFromMap 把 map 排行化,按数量降序;prefix 用于生成展示名(如「工位」)
func rankFromMap(m map[string]int, prefix string) []RankItem {
out := make([]RankItem, 0, len(m))
for k, v := range m {
if k == "" {
continue
}
out = append(out, RankItem{Key: k, Label: prefix + k, Count: v, StationNos: []int{}})
}
sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count })
if len(out) > 10 {
out = out[:10]
}
return out
}
// rankOrderStatus 工单排序权重:进行中优先
func rankOrderStatus(status string) int {
switch strings.ToUpper(status) {
case "IN_PROGRESS":
return 0
case "RELEASED":
return 1
case "CREATED":
return 2
case "COMPLETED":
return 3
default:
return 4
}
}
// sumDailyPlanQty 当日排产计划总量(看板「目标产量」)
func (s *Service) sumDailyPlanQty(ctx context.Context, date string) int {
plans, err := s.ctx.EntClient.DailyPlan.Query().
Where(dailyplan.PlanDateEQ(date)).All(ctx)
if err != nil {
return 0
}
sum := 0
for _, p := range plans {
sum += p.PlanQty
}
return sum
}
// sumDailyPlanQtyByOrder 某工单当日排产计划量
func (s *Service) sumDailyPlanQtyByOrder(ctx context.Context, date, orderNo string) int {
plans, err := s.ctx.EntClient.DailyPlan.Query().
Where(dailyplan.PlanDateEQ(date), dailyplan.OrderNoEQ(orderNo)).All(ctx)
if err != nil {
return 0
}
sum := 0
for _, p := range plans {
sum += p.PlanQty
}
return sum
}
// countWorkpieceDoneByOrder 某工单当日完工数(日计划达成用)
func (s *Service) countWorkpieceDoneByOrder(ctx context.Context, today time.Time, orderNo string) int {
n, err := s.ctx.EntClient.Workpiece.Query().
Where(
workpiece.OrderNoEQ(orderNo),
workpiece.DoneAtGTE(today),
workpiece.DoneAtLT(today.Add(24*time.Hour)),
).Count(ctx)
if err != nil {
return 0
}
return n
}
// loadTorqueCriterion 取工艺指标中的扭矩规格(上下限/目标值/单位),用于绘制控制图规格线。
// 取不到时返回 ok=false,前端不画控制线(不臆造规格值)。
func (s *Service) loadTorqueCriterion(ctx context.Context) (lo, hi, target float64, unit string, ok bool) {
crits, err := s.ctx.EntClient.StepCriterion.Query().All(ctx)
if err != nil {
return 0, 0, 0, "", false
}
for _, c := range crits {
if !strings.Contains(c.Name, "扭矩") && !strings.Contains(c.Name, "扭力") {
continue
}
lo, hi = 0, 0
if c.Min != nil {
lo = *c.Min
}
if c.Max != nil {
hi = *c.Max
}
return lo, hi, c.Target, c.Unit, true
}
return 0, 0, 0, "", false
}
// mockAgvProgress 模拟 AGV 任务进度:任务创建后 60 秒线性走完全程。
// 仅用于 3D 动效,接入 RCS 后由真实坐标替换。
func mockAgvProgress(created time.Time) float64 {
elapsed := time.Since(created).Seconds()
if elapsed <= 0 {
return 0
}
p := elapsed / 60
if p > 1 {
return 1
}
return p
}