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 }