mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-06-09 15:07:49 +00:00
add command for normalize data recording population not match; adjust closing overhead and keuangan
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
@@ -789,11 +789,56 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl
|
|||||||
|
|
||||||
totalActualPopulation := totalChickinQty - totalDepletion
|
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)
|
result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty, totalActualPopulation, projectFlockKandangID != nil, totalKandangCount)
|
||||||
|
|
||||||
return &result, nil
|
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 {
|
type activeKandangMetricRow struct {
|
||||||
ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"`
|
ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"`
|
||||||
ProjectFlockID uint `gorm:"column:project_flock_id"`
|
ProjectFlockID uint `gorm:"column:project_flock_id"`
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ func (s closingKeuanganService) calculateClosingKeuangan(c *fiber.Ctx, projectFl
|
|||||||
|
|
||||||
hppSection := s.buildHPPSection(c, projectFlock, projectFlockKandangs, costs, productionData)
|
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)
|
data := dto.ToClosingKeuanganData(hppSection, profitLossSection)
|
||||||
return &data, nil
|
return &data, nil
|
||||||
@@ -386,7 +386,7 @@ func (s closingKeuanganService) buildHPPSection(c *fiber.Ctx, projectFlock *enti
|
|||||||
return dto.ToHPPSection(hppItems, hppSummary)
|
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
|
totalWeightProduced := production.TotalWeightProduced
|
||||||
totalEggWeightKg := production.TotalEggWeightKg
|
totalEggWeightKg := production.TotalEggWeightKg
|
||||||
@@ -394,6 +394,11 @@ func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.Proj
|
|||||||
totalWeightSold := production.TotalWeightSold
|
totalWeightSold := production.TotalWeightSold
|
||||||
totalBirdSold := production.TotalBirdSold
|
totalBirdSold := production.TotalBirdSold
|
||||||
actualPopulation := production.TotalPopulationIn - production.TotalDepletion
|
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)
|
isLaying := projectFlock.Category == string(utils.ProjectFlockCategoryLaying)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user