242 lines
6.9 KiB
Go
242 lines
6.9 KiB
Go
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)
|
||
}
|