From 7c848bc50df56f20438a7dcaa9b4c2762f4cad24 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 9 Apr 2026 17:00:03 +0700 Subject: [PATCH 1/2] adjust validation from week 19 --- .../services/uniformity.service.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/modules/production/uniformities/services/uniformity.service.go b/internal/modules/production/uniformities/services/uniformity.service.go index 5c28ce78..1bb72ae4 100644 --- a/internal/modules/production/uniformities/services/uniformity.service.go +++ b/internal/modules/production/uniformities/services/uniformity.service.go @@ -386,10 +386,10 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file weekBase = config.LayingWeekStart() } if req.Week < weekBase { - if isLayingCategory { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase)) + if !isLayingCategory { + return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") } - return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") + // return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase)) } var latestWeek int @@ -401,10 +401,10 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate uniformity week sequence") } if latestWeek == 0 && req.Week != weekBase { - if isLayingCategory { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase)) + if !isLayingCategory { + return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") } - return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") + // return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase)) } // if latestWeek > 0 && req.Week > latestWeek+1 { // return nil, fiber.NewError(fiber.StatusBadRequest, "week must be sequential without skipping") @@ -582,10 +582,10 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui weekBase = config.LayingWeekStart() } if targetWeek < weekBase { - if isLayingCategory { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase)) + if !isLayingCategory { + return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") } - return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase)) } } if targetDate != nil { From 3702d41954a332d78b80e45bac3171ae8c7fb0ac Mon Sep 17 00:00:00 2001 From: giovanni Date: Fri, 10 Apr 2026 17:16:28 +0700 Subject: [PATCH 2/2] fix calculate egg mass and hen house recordings --- .../main.go | 380 ++++++++++++++++++ .../repositories/recording.repository.go | 30 +- .../recordings/services/recording.service.go | 6 +- 3 files changed, 410 insertions(+), 6 deletions(-) create mode 100644 cmd/normalize-data-egg-mass-and-hen-house/main.go diff --git a/cmd/normalize-data-egg-mass-and-hen-house/main.go b/cmd/normalize-data-egg-mass-and-hen-house/main.go new file mode 100644 index 00000000..b34fb7e2 --- /dev/null +++ b/cmd/normalize-data-egg-mass-and-hen-house/main.go @@ -0,0 +1,380 @@ +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) +} diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 6ea4c473..1f01f50b 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -758,15 +758,39 @@ func (r *RecordingRepositoryImpl) GetCumulativeEggQtyByProjectFlockKandang( return 0, nil } - var result float64 + var cumulativeEggQty float64 err := tx. Table("recording_eggs"). Select("COALESCE(SUM(recording_eggs.qty), 0)"). Joins("JOIN recordings ON recordings.id = recording_eggs.recording_id"). Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId). Where("recordings.record_datetime <= ?", recordTime). - Scan(&result).Error - return result, err + Scan(&cumulativeEggQty).Error + if err != nil { + return 0, err + } + + productWarehouseSubQuery := tx. + Table("recording_eggs"). + Select("DISTINCT recording_eggs.product_warehouse_id"). + Joins("JOIN recordings ON recordings.id = recording_eggs.recording_id"). + Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId). + Where("recordings.record_datetime <= ?", recordTime) + + var adjustmentEggQty float64 + err = tx. + Table("adjustment_stocks"). + Select("COALESCE(SUM(adjustment_stocks.total_qty), 0)"). + Where("adjustment_stocks.product_warehouse_id IN (?)", productWarehouseSubQuery). + Where("adjustment_stocks.function_code = ?", "RECORDING_EGG_IN"). + Where("adjustment_stocks.transaction_type = ?", "RECORDING"). + Where("adjustment_stocks.created_at <= ?", recordTime). + Scan(&adjustmentEggQty).Error + if err != nil { + return 0, err + } + + return cumulativeEggQty + adjustmentEggQty, nil } func (r *RecordingRepositoryImpl) GetProductionWeightAndQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeight float64, totalQty float64, err error) { // Body-weight tracking is removed; keep stub for report compatibility. diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 6f5fddb6..5c4d6a9c 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -1989,9 +1989,9 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm } var eggMass float64 - if remainingChick > 0 && totalEggWeightGrams > 0 { - // totalEggWeightGrams is in grams; egg mass is grams per hen. - eggMass = totalEggWeightGrams / remainingChick + if initialChickin > 0 && totalEggWeightGrams > 0 { + // totalEggWeightGrams is in grams; egg mass uses initial chick population. + eggMass = totalEggWeightGrams / initialChickin updates["egg_mass"] = eggMass recording.EggMass = &eggMass } else {