Files
bj_power/bj_power_mes/internal/processor/equip_upload.go
T

242 lines
6.9 KiB
Go
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.
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_recordPostgreSQL,与 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 计算逻辑保持一致:
// - workRUN 状态(含 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 EquipIdfloat64)。
// 例如 "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)
}