// Command normalize-recording-cutover-depletion // // Data-only normalization of recording population metrics for a cut-over flock // where pre-cutover mortality (culling + dead) was booked via stock adjustments // (which do NOT feed the recording population). It applies an "opening depletion" // offset to the CUMULATIVE depletion of every recording in a project_flock_kandang, // recomputing the population-dependent metric columns DIRECTLY on the `recordings` // table. // // It does NOT touch recording_depletions, stock_allocations, product_warehouses, // project_flock_populations, or adjustment_stocks — so inventory/FIFO stay intact // (the existing adjustments keep owning the stock movement). // // Recomputed columns (per recording, ordered by record_datetime,id): // // cumDepByDate = running SUM(recording_depletions.qty) up to that recording (INVARIANT) // new_tcq = initialChickin - cumDepByDate - opening // cum_depletion_rate = (cumDepByDate + opening) / initialChickin * 100 // feed_intake = feed_intake_old * (old_total_chick_qty / new_tcq) [null->null] // fcr_value = fcr_value_old * (old_total_chick_qty / new_tcq) [null->null] // // cum_intake and egg-based metrics are left untouched (see plan). // // Idempotent: only rows where total_chick_qty IS DISTINCT FROM new_tcq are updated. // Self-check: run with -opening=0; consistent rows are no-ops, any row that changes // was already inconsistent (stale) and gets reconciled to follow the depletion data. // // Usage: // // DB_HOST=localhost DB_PORT=5542 go run ./cmd/normalize-recording-cutover-depletion/ -pfk=91 -opening=0 # self-check dry-run // DB_HOST=localhost DB_PORT=5542 go run ./cmd/normalize-recording-cutover-depletion/ -pfk=91 -opening=3126 # dry-run // DB_HOST=localhost DB_PORT=5542 go run ./cmd/normalize-recording-cutover-depletion/ -pfk=91 -opening=3126 -apply # apply package main import ( "flag" "fmt" "log" "math" "os" "text/tabwriter" "gitlab.com/mbugroup/lti-api.git/internal/config" "gitlab.com/mbugroup/lti-api.git/internal/database" "gorm.io/gorm" ) type recRow struct { ID uint `gorm:"column:id"` Day *int `gorm:"column:day"` RecordDate string `gorm:"column:record_date"` TotalChickQty *float64 `gorm:"column:total_chick_qty"` CumDepletionRate *float64 `gorm:"column:cum_depletion_rate"` FeedIntake *float64 `gorm:"column:feed_intake"` FcrValue *float64 `gorm:"column:fcr_value"` CumDepByDate float64 `gorm:"column:cum_dep_by_date"` } const eps = 1e-6 func main() { var ( pfk uint opening float64 apply bool chickinOverride float64 ) flag.UintVar(&pfk, "pfk", 0, "project_flock_kandangs_id (required)") flag.Float64Var(&opening, "opening", 0, "opening depletion qty added to cumulative depletion of every recording") flag.BoolVar(&apply, "apply", false, "apply changes (default: dry-run)") flag.Float64Var(&chickinOverride, "chickin", 0, "override initial chickin base (0 = auto SUM project_chickins.usage_qty)") flag.Parse() if pfk == 0 { log.Fatal("-pfk is required") } db := database.Connect(config.DBHost, config.DBName) // 1) initial chickin base var initialChickin float64 if chickinOverride > 0 { initialChickin = chickinOverride } else { if err := db.Raw( `SELECT COALESCE(SUM(usage_qty),0) FROM project_chickins WHERE project_flock_kandang_id = ?`, pfk, ).Scan(&initialChickin).Error; err != nil { log.Fatalf("query initial chickin: %v", err) } } if initialChickin <= 0 { log.Fatalf("initial chickin <= 0 for pfk %d (got %.3f)", pfk, initialChickin) } // 2) sanity: duplicate record_datetime would make cumulative-by-date ambiguous var dupDatetimes int64 if err := db.Raw( `SELECT COUNT(*) FROM ( SELECT record_datetime FROM recordings WHERE project_flock_kandangs_id = ? AND deleted_at IS NULL GROUP BY record_datetime HAVING COUNT(*) > 1 ) t`, pfk, ).Scan(&dupDatetimes).Error; err != nil { log.Fatalf("check duplicate datetimes: %v", err) } if dupDatetimes > 0 { fmt.Printf("WARNING: %d duplicate record_datetime group(s) for pfk %d — cumulative-by-date ordering may be ambiguous; review carefully.\n\n", dupDatetimes, pfk) } // 3) load recordings + running cumulative depletion (by record_datetime, id) var rows []recRow q := ` WITH dep AS ( SELECT r.id, r.day, r.record_datetime, r.total_chick_qty, r.cum_depletion_rate, r.feed_intake, r.fcr_value, COALESCE((SELECT SUM(rd.qty) FROM recording_depletions rd WHERE rd.recording_id = r.id), 0) AS daily_dep FROM recordings r WHERE r.project_flock_kandangs_id = ? AND r.deleted_at IS NULL ) SELECT id, day, to_char(record_datetime, 'YYYY-MM-DD') AS record_date, total_chick_qty, cum_depletion_rate, feed_intake, fcr_value, SUM(daily_dep) OVER (ORDER BY record_datetime, id) AS cum_dep_by_date FROM dep ORDER BY record_datetime, id` if err := db.Raw(q, pfk).Scan(&rows).Error; err != nil { log.Fatalf("query recordings: %v", err) } if len(rows) == 0 { log.Fatalf("no recordings found for pfk %d", pfk) } mode := "DRY-RUN" if apply { mode = "APPLY" } fmt.Printf("=== normalize-recording-cutover-depletion ===\n") fmt.Printf("Mode: %s | pfk=%d | initialChickin=%.3f | opening=%.3f | recordings=%d\n\n", mode, pfk, initialChickin, opening, len(rows)) tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) fmt.Fprintln(tw, "id\tday\tdate\ttcq_old->new\tcumRate_old->new\tfeed_old->new\tfcr_old->new\tstatus") var willChange, anomalies, skipped int var negTcq int for _, r := range rows { newTcq := initialChickin - r.CumDepByDate - opening newRate := (r.CumDepByDate + opening) / initialChickin * 100 status := "" // detect pre-existing inconsistency (stale row): old tcq != invariant base (opening=0 expectation) expectedBase := initialChickin - r.CumDepByDate if r.TotalChickQty == nil || math.Abs(*r.TotalChickQty-expectedBase) > 1e-3 { status = "ANOMALY" anomalies++ } if newTcq < -eps { status = "NEG_TCQ!" negTcq++ } // idempotent guard if r.TotalChickQty != nil && math.Abs(*r.TotalChickQty-newTcq) < 1e-6 { if status == "" { status = "noop" } skipped++ } else { willChange++ } var newFeed, newFcr *float64 if r.FeedIntake != nil && r.TotalChickQty != nil && math.Abs(newTcq) > eps { v := *r.FeedIntake * (*r.TotalChickQty / newTcq) newFeed = &v } else { newFeed = r.FeedIntake } if r.FcrValue != nil && r.TotalChickQty != nil && math.Abs(newTcq) > eps { v := *r.FcrValue * (*r.TotalChickQty / newTcq) newFcr = &v } else { newFcr = r.FcrValue } fmt.Fprintf(tw, "%d\t%s\t%s\t%s -> %.3f\t%s -> %.3f\t%s -> %s\t%s -> %s\t%s\n", r.ID, iptr(r.Day), r.RecordDate, fptr(r.TotalChickQty), newTcq, fptr(r.CumDepletionRate), newRate, fptr(r.FeedIntake), fptrV(newFeed), fptr(r.FcrValue), fptrV(newFcr), status, ) } tw.Flush() fmt.Printf("\nSummary: will_change=%d skipped(noop)=%d anomalies=%d neg_tcq=%d\n", willChange, skipped, anomalies, negTcq) if negTcq > 0 { log.Fatalf("ABORT: %d recording(s) would get negative total_chick_qty — opening too large or data issue", negTcq) } if !apply { fmt.Println("\nDry-run only. Re-run with -apply to persist.") return } // 4) APPLY — single set-based UPDATE in a transaction (RHS uses pre-update column values) err := db.Transaction(func(tx *gorm.DB) error { res := tx.Exec(` WITH dep AS ( SELECT r.id, r.record_datetime, COALESCE((SELECT SUM(rd.qty) FROM recording_depletions rd WHERE rd.recording_id = r.id), 0) AS daily_dep FROM recordings r WHERE r.project_flock_kandangs_id = ? AND r.deleted_at IS NULL ), calc AS ( SELECT id, (? - cum_dep - ?) AS new_tcq, ((cum_dep + ?) / ? * 100) AS new_rate FROM ( SELECT id, SUM(daily_dep) OVER (ORDER BY record_datetime, id) AS cum_dep FROM dep ) s ) UPDATE recordings r SET total_chick_qty = c.new_tcq, cum_depletion_rate = c.new_rate, feed_intake = CASE WHEN r.feed_intake IS NULL OR r.total_chick_qty IS NULL OR c.new_tcq = 0 THEN r.feed_intake ELSE r.feed_intake * (r.total_chick_qty / c.new_tcq) END, fcr_value = CASE WHEN r.fcr_value IS NULL OR r.total_chick_qty IS NULL OR c.new_tcq = 0 THEN r.fcr_value ELSE r.fcr_value * (r.total_chick_qty / c.new_tcq) END, updated_at = NOW() FROM calc c WHERE r.id = c.id AND r.total_chick_qty IS DISTINCT FROM c.new_tcq`, pfk, initialChickin, opening, opening, initialChickin, ) if res.Error != nil { return res.Error } fmt.Printf("\nAPPLIED: %d recording row(s) updated.\n", res.RowsAffected) return nil }) if err != nil { log.Fatalf("apply failed: %v", err) } fmt.Println("Done. Verify with the queries in tmp/pfk91-cutover-fix.md.") } func fptr(p *float64) string { if p == nil { return "null" } return fmt.Sprintf("%.3f", *p) } func fptrV(p *float64) string { return fptr(p) } func iptr(p *int) string { if p == nil { return "-" } return fmt.Sprintf("%d", *p) }