Merge branch 'fix/recording' into 'development'

[FIX][BE]: fix calculate egg mass and hen house recordings

See merge request mbugroup/lti-api!410
This commit is contained in:
Adnan Zahir
2026-04-11 13:56:32 +07:00
3 changed files with 410 additions and 6 deletions
@@ -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 "<nil>"
}
return v.UTC().Format(time.RFC3339)
}
func displayUint(v uint) string {
if v == 0 {
return "<all>"
}
return fmt.Sprintf("%d", v)
}
@@ -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.
@@ -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 {