package processor import ( "context" "database/sql" "fmt" "log/slog" "regexp" "strconv" "strings" "time" momlib "bjhardman.cn/bjhardman/mom" ) // EquipUploadService 每日定时把 OKUMA 设备的运行时长汇总写到 MOM 的 AUTO_EQUIP_UPLOAD 表。 // // 数据源:dc_device_status_record(PostgreSQL,与 back_cover 共库) // 触发:每分钟一次 ticker,命中 EquipUploadHour 整点且当日未上传过则触发 // 时长口径(与 OEE 计算保持一致): // - WorkTime = RUN(及其变体) 状态时长(小时) // - OpenTime = 非 OFFLINE 状态时长(小时) // - WaitTime = OpenTime - WorkTime // - SumCost = WorkTime type EquipUploadService struct { db *sql.DB mom *momlib.Mom hour int // 每日触发整点(0-23),上传前一日数据 deviceTypes []string // device_type 过滤;空表示所有 OKUMA 类 stopCh chan struct{} lastRunDate string // 已上传的日期(YYYY-MM-DD),用于幂等 } // NewEquipUploadService 构造服务。db / mom 任一为空时 Start 仍可调用但是空跑。 func NewEquipUploadService(db *sql.DB, mom *momlib.Mom, hour int, deviceTypes []string) *EquipUploadService { if hour < 0 || hour > 23 { hour = 10 } return &EquipUploadService{ db: db, mom: mom, hour: hour, deviceTypes: deviceTypes, stopCh: make(chan struct{}), } } // Start 启动后台循环。每分钟检查一次,命中 hour 整点且当日未跑则上传。 func (s *EquipUploadService) Start(ctx context.Context) { if s.db == nil || s.mom == nil { slog.Info("equip_upload: skip (db or mom not configured)") return } go func() { t := time.NewTicker(1 * time.Minute) defer t.Stop() slog.Info("equip_upload: started", "hour", s.hour, "deviceTypes", s.deviceTypes) for { select { case <-s.stopCh: return case <-ctx.Done(): return case now := <-t.C: s.tick(ctx, now) } } }() } func (s *EquipUploadService) Stop() { close(s.stopCh) } func (s *EquipUploadService) tick(ctx context.Context, now time.Time) { if now.Hour() != s.hour { return } today := now.Format("2006-01-02") if s.lastRunDate == today { return } yesterday := now.AddDate(0, 0, -1).Format("2006-01-02") if err := s.uploadDay(ctx, yesterday); err != nil { slog.Error("equip_upload: failed", "date", yesterday, "error", err) return } s.lastRunDate = today slog.Info("equip_upload: success", "date", yesterday) } // UploadDay 对外暴露的手动触发入口(可用于补传)。 func (s *EquipUploadService) UploadDay(ctx context.Context, date string) error { if s.db == nil || s.mom == nil { return fmt.Errorf("db or mom not configured") } return s.uploadDay(ctx, date) } // uploadDay 汇总 date(YYYY-MM-DD) 的设备状态时长并调 MOM func (s *EquipUploadService) uploadDay(ctx context.Context, date string) error { rows, err := s.queryDurations(ctx, date) if err != nil { return fmt.Errorf("query durations: %w", err) } if len(rows) == 0 { slog.Info("equip_upload: no data", "date", date) return nil } // device_name → (status → seconds) agg := make(map[string]map[string]float64) for _, r := range rows { if agg[r.DeviceName] == nil { agg[r.DeviceName] = make(map[string]float64) } agg[r.DeviceName][r.Status] += r.DurationSec } for deviceName, statusMap := range agg { work, open := computeWorkOpen(statusMap) wait := open - work if wait < 0 { wait = 0 } equipID := extractEquipID(deviceName) // 每条记录一个独立 ID,便于排查 uid := fmt.Sprintf("%s-%s-%d", date, deviceName, time.Now().UnixNano()) err := s.mom.InsertEquipUpload(ctx, momlib.EquipUpload{ ID: uid, // ResourceCode 由 SDK 从 MomConf 自动回填 EquipId: equipID, WorkDate: date, OpenTime: hours(open), WorkTime: hours(work), WaitTime: hours(wait), SumCost: hours(work), }) if err != nil { slog.Error("equip_upload: insert failed", "device", deviceName, "date", date, "error", err) continue } slog.Info("equip_upload: uploaded", "device", deviceName, "equipId", equipID, "date", date, "openH", hours(open), "workH", hours(work), "waitH", hours(wait)) } return nil } type statusRow struct { DeviceName string Status string DurationSec float64 } // queryDurations 查询指定日期 [date 00:00, date+1 00:00) 区间内每个设备每个状态的总秒数 func (s *EquipUploadService) queryDurations(ctx context.Context, date string) ([]statusRow, error) { // 如果 deviceTypes 为空,只查 OKUMA 类(与 dashboard-server 保持一致) deviceTypes := s.deviceTypes if len(deviceTypes) == 0 { deviceTypes = []string{"osp-mc", "osp-lathe", "okuma_mc", "okuma_lathe"} } placeholders := make([]string, len(deviceTypes)) args := []any{date, date} for i, dt := range deviceTypes { placeholders[i] = fmt.Sprintf("$%d", i+3) args = append(args, dt) } q := fmt.Sprintf(` SELECT r.device_name, r.status, EXTRACT(EPOCH FROM ( LEAST(COALESCE(r.end_time, NOW()), ($1::date + INTERVAL '1 day')) - GREATEST(r.start_time, $2::date) )) AS duration_sec FROM dc_device_status_record r WHERE r.start_time < ($1::date + INTERVAL '1 day') AND COALESCE(r.end_time, NOW()) > $2::date AND EXISTS ( SELECT 1 FROM dc_device_status d WHERE d.device_name = r.device_name AND d.device_type IN (%s) ) `, strings.Join(placeholders, ",")) rs, err := s.db.QueryContext(ctx, q, args...) if err != nil { return nil, err } defer rs.Close() var out []statusRow for rs.Next() { var row statusRow if err := rs.Scan(&row.DeviceName, &row.Status, &row.DurationSec); err != nil { return nil, err } if row.DurationSec > 0 { out = append(out, row) } } return out, rs.Err() } // computeWorkOpen 跟 dashboard-server/handlers/oee.go 中的 OEE 计算逻辑保持一致: // - work:RUN 状态(含 ONLINE 兜底——非 OSP 设备的连接级状态) // - open:非 OFFLINE 的所有时段(即设备开机时段) func computeWorkOpen(statusMap map[string]float64) (workSec, openSec float64) { for st, sec := range statusMap { if st == "OFFLINE" { continue } openSec += sec if st == "RUN" || strings.Contains(st, "RUN") || st == "ONLINE" { workSec += sec } } return } func hours(sec float64) float64 { h := sec / 3600.0 // 保留 2 位小数避免 SDK / DM 端浮点抖动 return float64(int(h*100+0.5)) / 100.0 } var equipIDDigits = regexp.MustCompile(`(\d+)`) // extractEquipID 从 device_name 提取尾部数字作为 MOM EquipId(float64)。 // 例如 "CNC1"→1,"OKUMA-MC-2"→2;提取不到则返回 0。 func extractEquipID(deviceName string) float64 { matches := equipIDDigits.FindAllString(deviceName, -1) if len(matches) == 0 { return 0 } // 取最后一段数字,更贴近"序号"语义 v, err := strconv.Atoi(matches[len(matches)-1]) if err != nil { return 0 } return float64(v) }