package main import ( "context" "flag" "fmt" "log" "math" "os" "strings" "time" "gitlab.com/mbugroup/lti-api.git/internal/config" "gitlab.com/mbugroup/lti-api.git/internal/database" recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" "gorm.io/gorm" ) const metricEpsilon = 1e-9 type normalizeOptions struct { Apply bool RecordingID uint ProjectFlockKandangID uint From *time.Time To *time.Time BatchSize int Limit int } type normalizeStats struct { Processed int Changed int Updated int Skipped int Failed int } type recordingMetricRow struct { ID uint `gorm:"column:id"` ProjectFlockKandangID uint `gorm:"column:project_flock_kandangs_id"` RecordDatetime time.Time `gorm:"column:record_datetime"` HenHouse *float64 `gorm:"column:hen_house"` EggMass *float64 `gorm:"column:egg_mass"` } func main() { var ( apply bool recordingID uint projectFlockKandangID uint fromRaw string toRaw string batchSize int limit int ) flag.BoolVar(&apply, "apply", false, "Apply update. If false, run as dry-run") flag.UintVar(&recordingID, "recording-id", 0, "Target a single recording ID") flag.UintVar(&projectFlockKandangID, "project-flock-kandang-id", 0, "Filter by project_flock_kandangs_id") flag.StringVar(&fromRaw, "from", "", "Lower bound record_datetime (RFC3339 / YYYY-MM-DD)") flag.StringVar(&toRaw, "to", "", "Upper bound record_datetime (RFC3339 / YYYY-MM-DD)") flag.IntVar(&batchSize, "batch-size", 200, "Batch size when scanning recordings") flag.IntVar(&limit, "limit", 0, "Max recordings to process (0 = no limit)") flag.Parse() if batchSize <= 0 { log.Fatal("--batch-size must be > 0") } if limit < 0 { log.Fatal("--limit cannot be negative") } from, err := parseTimeBound(strings.TrimSpace(fromRaw), false) if err != nil { log.Fatalf("invalid --from: %v", err) } to, err := parseTimeBound(strings.TrimSpace(toRaw), true) if err != nil { log.Fatalf("invalid --to: %v", err) } if from != nil && to != nil && to.Before(*from) { log.Fatal("--to cannot be before --from") } opts := normalizeOptions{ Apply: apply, RecordingID: recordingID, ProjectFlockKandangID: projectFlockKandangID, From: from, To: to, BatchSize: batchSize, Limit: limit, } ctx := context.Background() db := database.Connect(config.DBHost, config.DBName) repo := recordingRepo.NewRecordingRepository(db) fmt.Printf("Mode: %s\n", modeLabel(opts.Apply)) fmt.Printf("Filter recording_id: %s\n", displayUint(opts.RecordingID)) fmt.Printf("Filter project_flock_kandangs_id: %s\n", displayUint(opts.ProjectFlockKandangID)) fmt.Printf("Filter from: %s\n", displayTime(opts.From)) fmt.Printf("Filter to: %s\n", displayTime(opts.To)) fmt.Printf("Batch size: %d\n", opts.BatchSize) fmt.Printf("Limit: %d\n\n", opts.Limit) stats, err := normalizeRecordings(ctx, db, repo, opts) if err != nil { log.Fatalf("normalize failed: %v", err) } fmt.Println() fmt.Printf( "Summary: processed=%d changed=%d updated=%d skipped=%d failed=%d\n", stats.Processed, stats.Changed, stats.Updated, stats.Skipped, stats.Failed, ) if stats.Failed > 0 { os.Exit(1) } } func normalizeRecordings( ctx context.Context, db *gorm.DB, repo recordingRepo.RecordingRepository, opts normalizeOptions, ) (normalizeStats, error) { stats := normalizeStats{} lastID := uint(0) initialChickCache := make(map[uint]float64) for { batchLimit := opts.BatchSize if opts.Limit > 0 { remaining := opts.Limit - stats.Processed if remaining <= 0 { break } if remaining < batchLimit { batchLimit = remaining } } rows, err := loadRecordingBatch(ctx, db, opts, lastID, batchLimit) if err != nil { return stats, err } if len(rows) == 0 { break } for _, row := range rows { stats.Processed++ lastID = row.ID initialChick, ok := initialChickCache[row.ProjectFlockKandangID] if !ok { initialChick, err = repo.GetTotalChickinByProjectFlockKandang(db.WithContext(ctx), row.ProjectFlockKandangID) if err != nil { fmt.Printf("FAIL rec=%d error=getTotalChickinByProjectFlockKandang: %v\n", row.ID, err) stats.Failed++ continue } initialChickCache[row.ProjectFlockKandangID] = initialChick } _, totalEggWeightGrams, err := repo.GetEggSummaryByRecording(db.WithContext(ctx), row.ID) if err != nil { fmt.Printf("FAIL rec=%d error=getEggSummaryByRecording: %v\n", row.ID, err) stats.Failed++ continue } cumulativeEggQty, err := repo.GetCumulativeEggQtyByProjectFlockKandang(db.WithContext(ctx), row.ProjectFlockKandangID, row.RecordDatetime) if err != nil { fmt.Printf("FAIL rec=%d error=getCumulativeEggQtyByProjectFlockKandang: %v\n", row.ID, err) stats.Failed++ continue } newHenHouse, newEggMass := computeNormalizedMetrics(initialChick, cumulativeEggQty, totalEggWeightGrams) henHouseChanged := metricChanged(row.HenHouse, newHenHouse) eggMassChanged := metricChanged(row.EggMass, newEggMass) if !henHouseChanged && !eggMassChanged { stats.Skipped++ continue } stats.Changed++ fmt.Printf( "PLAN rec=%d pfk=%d at=%s hen_house:%s->%s egg_mass:%s->%s\n", row.ID, row.ProjectFlockKandangID, row.RecordDatetime.UTC().Format(time.RFC3339), displayFloat(row.HenHouse), displayFloat(newHenHouse), displayFloat(row.EggMass), displayFloat(newEggMass), ) if !opts.Apply { continue } if err := updateRecordingMetrics(ctx, db, row.ID, newHenHouse, newEggMass); err != nil { fmt.Printf("FAIL rec=%d error=updateRecordingMetrics: %v\n", row.ID, err) stats.Failed++ continue } fmt.Printf( "DONE rec=%d hen_house=%s egg_mass=%s\n", row.ID, displayFloat(newHenHouse), displayFloat(newEggMass), ) stats.Updated++ } if opts.RecordingID > 0 { break } } return stats, nil } func loadRecordingBatch( ctx context.Context, db *gorm.DB, opts normalizeOptions, lastID uint, limit int, ) ([]recordingMetricRow, error) { query := db.WithContext(ctx). Table("recordings"). Select("id, project_flock_kandangs_id, record_datetime, hen_house, egg_mass"). Where("recordings.deleted_at IS NULL") if opts.RecordingID > 0 { query = query.Where("recordings.id = ?", opts.RecordingID) } if opts.ProjectFlockKandangID > 0 { query = query.Where("recordings.project_flock_kandangs_id = ?", opts.ProjectFlockKandangID) } if opts.From != nil { query = query.Where("recordings.record_datetime >= ?", *opts.From) } if opts.To != nil { query = query.Where("recordings.record_datetime <= ?", *opts.To) } if opts.RecordingID == 0 && lastID > 0 { query = query.Where("recordings.id > ?", lastID) } var rows []recordingMetricRow err := query. Order("recordings.id ASC"). Limit(limit). Scan(&rows).Error return rows, err } func computeNormalizedMetrics(initialChick, cumulativeEggQty, totalEggWeightGrams float64) (*float64, *float64) { var henHouse *float64 if initialChick > 0 && cumulativeEggQty >= 0 { value := cumulativeEggQty / initialChick henHouse = &value } var eggMass *float64 if initialChick > 0 && totalEggWeightGrams > 0 { value := totalEggWeightGrams / initialChick eggMass = &value } return henHouse, eggMass } func updateRecordingMetrics(ctx context.Context, db *gorm.DB, recordingID uint, henHouse, eggMass *float64) error { updates := map[string]any{} if henHouse == nil { updates["hen_house"] = gorm.Expr("NULL") } else { updates["hen_house"] = *henHouse } if eggMass == nil { updates["egg_mass"] = gorm.Expr("NULL") } else { updates["egg_mass"] = *eggMass } return db.WithContext(ctx). Table("recordings"). Where("id = ?", recordingID). Updates(updates).Error } func metricChanged(oldValue, newValue *float64) bool { if oldValue == nil && newValue == nil { return false } if oldValue == nil || newValue == nil { return true } return !nearlyEqual(*oldValue, *newValue) } func nearlyEqual(a, b float64) bool { scale := math.Max(1, math.Max(math.Abs(a), math.Abs(b))) return math.Abs(a-b) <= metricEpsilon*scale } func parseTimeBound(raw string, isUpper bool) (*time.Time, error) { if raw == "" { return nil, nil } layouts := []string{ time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05", "2006-01-02", } for _, layout := range layouts { parsed, err := time.Parse(layout, raw) if err != nil { continue } if layout == "2006-01-02" { if isUpper { endOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC) return &endOfDay, nil } startOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, time.UTC) return &startOfDay, nil } t := parsed.UTC() return &t, nil } return nil, fmt.Errorf("unsupported format %q", raw) } func modeLabel(apply bool) string { if apply { return "APPLY" } return "DRY-RUN" } func displayFloat(v *float64) string { if v == nil { return "NULL" } return fmt.Sprintf("%.6f", *v) } func displayTime(v *time.Time) string { if v == nil { return "" } return v.UTC().Format(time.RFC3339) } func displayUint(v uint) string { if v == 0 { return "" } return fmt.Sprintf("%d", v) }