From edfd6ac95cdf1ac12b708c0da827f67b3db30900 Mon Sep 17 00:00:00 2001 From: giovanni Date: Sun, 7 Jun 2026 16:34:22 +0700 Subject: [PATCH] add command for normalize data recording population not match; adjust closing overhead and keuangan --- .../main.go | 266 ++++++++++++++++++ .../closings/services/closing.service.go | 45 +++ .../services/closingKeuangan.service.go | 9 +- 3 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 cmd/normalize-recording-cutover-depletion/main.go diff --git a/cmd/normalize-recording-cutover-depletion/main.go b/cmd/normalize-recording-cutover-depletion/main.go new file mode 100644 index 00000000..7ea30ee6 --- /dev/null +++ b/cmd/normalize-recording-cutover-depletion/main.go @@ -0,0 +1,266 @@ +// 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) +} diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 91a8c7d4..1dea8981 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -789,11 +789,56 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl totalActualPopulation := totalChickinQty - totalDepletion + // Prefer recording-based population (recordings.total_chick_qty) so closing stays + // consistent with normalized cut-over flocks. For normal flocks this equals + // chickin - depletion (no-op); it only differs when the recording population was + // normalized separately from recording_depletions. Falls back if any kandang in + // scope lacks a recording. + scopeKandangs := projectFlockKandangs + if projectFlockKandangID != nil { + scopeKandangs = nil + for _, k := range projectFlockKandangs { + if k.Id == *projectFlockKandangID { + scopeKandangs = append(scopeKandangs, k) + break + } + } + } + if recPop, ok := s.actualPopulationFromRecordings(c.Context(), scopeKandangs); ok { + totalActualPopulation = recPop + } + result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty, totalActualPopulation, projectFlockKandangID != nil, totalKandangCount) return &result, nil } +// actualPopulationFromRecordings sums the latest recordings.total_chick_qty across the +// given kandangs (the production population source of truth). Returns ok=false if any +// kandang lacks a recording, so the caller falls back to chickin-minus-depletion. +// For normal flocks this equals chickin - depletion; it only differs for cut-over flocks +// whose recording population was normalized separately from recording_depletions. +func (s closingService) actualPopulationFromRecordings(ctx context.Context, kandangs []entity.ProjectFlockKandang) (float64, bool) { + if s.RecordingRepo == nil || len(kandangs) == 0 { + return 0, false + } + total := 0.0 + for _, k := range kandangs { + latest, err := s.RecordingRepo.GetLatestByProjectFlockKandangID(ctx, k.Id) + if err != nil { + s.Log.Warnf("actualPopulationFromRecordings: latest recording pfk=%d: %v", k.Id, err) + return 0, false + } + if latest == nil || latest.TotalChickQty == nil { + return 0, false + } + if *latest.TotalChickQty > 0 { + total += *latest.TotalChickQty + } + } + return total, true +} + type activeKandangMetricRow struct { ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"` ProjectFlockID uint `gorm:"column:project_flock_id"` diff --git a/internal/modules/closings/services/closingKeuangan.service.go b/internal/modules/closings/services/closingKeuangan.service.go index 757d553c..e14642f3 100644 --- a/internal/modules/closings/services/closingKeuangan.service.go +++ b/internal/modules/closings/services/closingKeuangan.service.go @@ -156,7 +156,7 @@ func (s closingKeuanganService) calculateClosingKeuangan(c *fiber.Ctx, projectFl hppSection := s.buildHPPSection(c, projectFlock, projectFlockKandangs, costs, productionData) - profitLossSection := s.buildProfitLossSection(projectFlock, costs, productionData) + profitLossSection := s.buildProfitLossSection(c, projectFlock, projectFlockKandangs, costs, productionData) data := dto.ToClosingKeuanganData(hppSection, profitLossSection) return &data, nil @@ -386,7 +386,7 @@ func (s closingKeuanganService) buildHPPSection(c *fiber.Ctx, projectFlock *enti return dto.ToHPPSection(hppItems, hppSummary) } -func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.ProjectFlock, costs *CostData, production *ProductionData) dto.ProfitLossSection { +func (s closingKeuanganService) buildProfitLossSection(c *fiber.Ctx, projectFlock *entity.ProjectFlock, projectFlockKandangs []entity.ProjectFlockKandang, costs *CostData, production *ProductionData) dto.ProfitLossSection { totalWeightProduced := production.TotalWeightProduced totalEggWeightKg := production.TotalEggWeightKg @@ -394,6 +394,11 @@ func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.Proj totalWeightSold := production.TotalWeightSold totalBirdSold := production.TotalBirdSold actualPopulation := production.TotalPopulationIn - production.TotalDepletion + // Prefer recording-based population (consistent with buildHPPSection) so per-ekor + // P&L matches the normalized recording population for cut-over flocks. + if lastPopulation, ok := s.getLastPopulationFromRecordings(c, projectFlockKandangs); ok { + actualPopulation = lastPopulation + } isLaying := projectFlock.Category == string(utils.ProjectFlockCategoryLaying)