Compare commits

..

2 Commits

Author SHA1 Message Date
giovanni 2216f572c2 fix recording standar prod laying 2026-06-07 18:55:24 +07:00
giovanni edfd6ac95c add command for normalize data recording population not match; adjust closing overhead and keuangan 2026-06-07 16:34:22 +07:00
14 changed files with 797 additions and 442 deletions
@@ -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)
}
@@ -114,12 +114,6 @@ type HppV2CostRepository interface {
GetChickinPopulationByPFKForFarm(ctx context.Context, projectFlockID uint) (map[uint]float64, error)
GetMultiplicationPercentages(ctx context.Context, houseTypes []string, maxDay int, projectFlockID uint) (map[string]map[int]float64, map[string]*time.Time, error)
ListUsageCostRowsByProductFlags(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string, date *time.Time) ([]HppV2UsageCostRow, error)
// ListLayingUsageCostRowsByProductFlags meng-anchor atribusi ke kandang recording
// (recordings.project_flock_kandangs_id), bukan ke recording_stocks.project_flock_kandang_id.
// Diperlukan karena pakan/OVK kandang LAYING yang dikonsumsi dari gudang tipe LOKASI
// punya recording_stocks.project_flock_kandang_id = NULL — kasus ini harus tetap diatribusikan
// ke kandang laying sebagai production_cost (bukan jatuh ke RECORDING_STOCK_ROUTE / pullet_cost).
ListLayingUsageCostRowsByProductFlags(ctx context.Context, layingProjectFlockKandangID uint, flagNames []string, date *time.Time) ([]HppV2UsageCostRow, error)
ListAdjustmentCostRowsByProductFlags(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string, date *time.Time) ([]HppV2AdjustmentCostRow, error)
ListExpenseRealizationRowsByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time, ekspedisi bool) ([]HppV2ExpenseCostRow, error)
ListExpenseRealizationRowsByProjectFlockID(ctx context.Context, projectFlockID uint, date *time.Time, ekspedisi bool) ([]HppV2ExpenseCostRow, error)
@@ -373,19 +367,18 @@ func (r *HppV2RepositoryImpl) GetRecordingStockRoutingAdjustmentCostByProjectFlo
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("pfk_rec.project_flock_id = ?", projectFlockID).
Where("DATE(r.record_datetime) <= DATE(?)", periodDate).
// Hanya routing cross-kandang ASLI: stok yang dicatat di recording kandang X tetapi
// recording_stocks.project_flock_kandang_id menunjuk kandang lain (Y) saat ada transfer.
// Cabang lama "NOT(transferExists) AND rs.pfk IS NULL" DIHAPUS — kasus pakan/OVK laying
// dari gudang LOKASI (pfk NULL) kini diatribusikan sebagai production_cost via
// ListLayingUsageCostRowsByProductFlags, sehingga kedua jalur jadi disjoint (tanpa dobel).
Where(
fmt.Sprintf(
"(%s) AND rs.project_flock_kandang_id IS NOT NULL AND rs.project_flock_kandang_id <> r.project_flock_kandangs_id",
"((%s) AND rs.project_flock_kandang_id IS NOT NULL AND rs.project_flock_kandang_id <> r.project_flock_kandangs_id) OR (NOT (%s) AND rs.project_flock_kandang_id IS NULL)",
transferExistsCondition,
transferExistsCondition,
),
periodDate,
string(utils.ApprovalWorkflowTransferToLaying),
entity.ApprovalActionApproved,
periodDate,
string(utils.ApprovalWorkflowTransferToLaying),
entity.ApprovalActionApproved,
).
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags).
Scan(&total).Error
@@ -592,91 +585,6 @@ func (r *HppV2RepositoryImpl) ListUsageCostRowsByProductFlags(
return rows, nil
}
// ListLayingUsageCostRowsByProductFlags identik dengan ListUsageCostRowsByProductFlags,
// tetapi atribusi baris ditentukan oleh kandang RECORDING (r.project_flock_kandangs_id),
// dengan recording_stocks.project_flock_kandang_id boleh NULL (gudang LOKASI) atau sama
// dengan kandang laying. Baris yang routed ke kandang lain (rs.pfk <> kandang recording)
// SENGAJA TIDAK diikutkan di sini — itu ranah RECORDING_STOCK_ROUTE.
func (r *HppV2RepositoryImpl) ListLayingUsageCostRowsByProductFlags(
ctx context.Context,
layingProjectFlockKandangID uint,
flagNames []string,
date *time.Time,
) ([]HppV2UsageCostRow, error) {
if layingProjectFlockKandangID == 0 || len(flagNames) == 0 {
return []HppV2UsageCostRow{}, nil
}
if date == nil {
now := time.Now()
date = &now
}
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
stockableAdjustment := fifo.StockableKeyAdjustmentIn.String()
usableRecordingStock := fifo.UsableKeyRecordingStock.String()
rows := make([]HppV2UsageCostRow, 0)
err := r.db.WithContext(ctx).
Table("recordings AS r").
Select(`
sa.stockable_type AS stockable_type,
sa.stockable_id AS stockable_id,
COALESCE(pi.product_id, ast_pw.product_id, 0) AS source_product_id,
COALESCE(pi_prod.name, ast_prod.name, '') AS source_product_name,
COALESCE(SUM(sa.qty), 0) AS qty,
COALESCE(MAX(CASE
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0)
ELSE 0
END), 0) AS unit_price,
COALESCE(SUM(sa.qty * CASE
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0)
ELSE 0
END), 0) AS total_cost,
MIN(r.record_datetime) AS first_used_at,
MAX(r.record_datetime) AS last_used_at
`,
stockablePurchase,
stockableAdjustment,
stockablePurchase,
stockableAdjustment,
).
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins(
"JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND (sa.stockable_type = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?",
usableRecordingStock,
stockablePurchase,
stockableAdjustment,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
Joins("LEFT JOIN products AS pi_prod ON pi_prod.id = pi.product_id").
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
Joins("LEFT JOIN product_warehouses AS ast_pw ON ast_pw.id = ast.product_warehouse_id").
Joins("LEFT JOIN products AS ast_prod ON ast_prod.id = ast_pw.product_id").
Where("r.project_flock_kandangs_id = ?", layingProjectFlockKandangID).
Where("(rs.project_flock_kandang_id IS NULL OR rs.project_flock_kandang_id = ?)", layingProjectFlockKandangID).
Where("r.deleted_at IS NULL").
Where("r.record_datetime <= ?", *date).
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flagNames).
Group(`
sa.stockable_type,
sa.stockable_id,
COALESCE(pi.product_id, ast_pw.product_id, 0),
COALESCE(pi_prod.name, ast_prod.name, '')
`).
Order("MIN(r.record_datetime) ASC, sa.stockable_type ASC, sa.stockable_id ASC").
Scan(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
func (r *HppV2RepositoryImpl) ListAdjustmentCostRowsByProductFlags(
ctx context.Context,
projectFlockKandangIDs []uint,
@@ -192,27 +192,19 @@ func TestHppV2RepositoryGetRecordingStockRoutingAdjustmentCostByProjectFlockID(t
repo := &HppV2RepositoryImpl{db: db}
// Route sekarang HANYA menangkap routing cross-kandang asli
// (transferExists AND rs.pfk IS NOT NULL AND rs.pfk <> r.project_flock_kandangs_id).
// Baris pfk NULL (gudang LOKASI) tidak lagi masuk route — kini jadi production_cost
// laying-usage via ListLayingUsageCostRowsByProductFlags.
// Pada 2026-04-30 hanya rs 102 yang lolos: recording pfk 101 (transfer 1001 approved &
// executed, effective 04-05 <= 04-30), rs.pfk 201 <> 101 → 1 × 110 = 110.
periodDate := mustJakartaTime(t, "2026-04-30 00:00:00")
total, err := repo.GetRecordingStockRoutingAdjustmentCostByProjectFlockID(context.Background(), 1, periodDate)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
assertFloatEquals(t, total, 110)
assertFloatEquals(t, total, 750)
// Pada 2026-04-10 hanya recording pfk 101 & 102 yang masuk rentang tanggal; tetap hanya
// rs 102 (cross-kandang) yang lolos → 110.
earlyPeriod := mustJakartaTime(t, "2026-04-10 23:59:59")
earlyTotal, err := repo.GetRecordingStockRoutingAdjustmentCostByProjectFlockID(context.Background(), 1, earlyPeriod)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
assertFloatEquals(t, earlyTotal, 110)
assertFloatEquals(t, earlyTotal, 240)
}
func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB {
+21 -159
View File
@@ -55,10 +55,6 @@ type HppV2Service interface {
GetDirectPulletPurchaseBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error)
GetBopRegularBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error)
GetBopEkspedisiBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error)
// GetBopRegularProductionScopeRange / GetBopEkspedisiProductionScopeRange mengembalikan BOP
// production_cost untuk rentang [startDate, endDate] secara range-correct (tidak pernah negatif).
GetBopRegularProductionScopeRange(projectFlockKandangId uint, startDate, endDate *time.Time) (float64, error)
GetBopEkspedisiProductionScopeRange(projectFlockKandangId uint, startDate, endDate *time.Time) (float64, error)
GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error)
}
@@ -457,7 +453,7 @@ func (s *hppV2Service) getStockUsageComponent(projectFlockKandangId uint, endDat
total += growingCutoverPart.Total
}
layingNormalPart, err := s.buildLayingUsagePart(projectFlockKandangId, contextRow, endDate, config, false)
layingNormalPart, err := s.buildLayingUsagePart(projectFlockKandangId, endDate, config, false)
if err != nil {
return nil, err
}
@@ -466,7 +462,7 @@ func (s *hppV2Service) getStockUsageComponent(projectFlockKandangId uint, endDat
total += layingNormalPart.Total
}
layingCutoverPart, err := s.buildLayingUsagePart(projectFlockKandangId, contextRow, endDate, config, true)
layingCutoverPart, err := s.buildLayingUsagePart(projectFlockKandangId, endDate, config, true)
if err != nil {
return nil, err
}
@@ -741,7 +737,6 @@ func (s *hppV2Service) buildGrowingUsagePart(
func (s *hppV2Service) buildLayingUsagePart(
projectFlockKandangId uint,
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
endDate *time.Time,
config hppV2StockComponentConfig,
cutover bool,
@@ -783,16 +778,7 @@ func (s *hppV2Service) buildLayingUsagePart(
}, nil
}
// Untuk kandang LAYING, atribusi pakan/OVK berbasis kandang recording (termasuk konsumsi
// dari gudang LOKASI yang punya recording_stocks.project_flock_kandang_id = NULL). Untuk
// kandang non-laying, pertahankan semantik lama (strict rs.project_flock_kandang_id IN [pfk]).
var rows []commonRepo.HppV2UsageCostRow
var err error
if contextRow != nil && contextRow.ProjectFlockCategory == string(utils.ProjectFlockCategoryLaying) {
rows, err = s.hppRepo.ListLayingUsageCostRowsByProductFlags(context.Background(), projectFlockKandangId, config.NormalFlags, endDate)
} else {
rows, err = s.hppRepo.ListUsageCostRowsByProductFlags(context.Background(), []uint{projectFlockKandangId}, config.NormalFlags, endDate)
}
rows, err := s.hppRepo.ListUsageCostRowsByProductFlags(context.Background(), []uint{projectFlockKandangId}, config.NormalFlags, endDate)
if err != nil {
return nil, err
}
@@ -945,48 +931,17 @@ func (s *hppV2Service) buildLayingExpenseFarmPart(
return nil, nil
}
ratio, proration, err := s.layingFarmExpenseRatio(projectFlockKandangId, contextRow, endDate)
farmPFKIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), contextRow.ProjectFlockID)
if err != nil {
return nil, err
}
if ratio <= 0 {
return nil, nil
}
return buildExpensePartFromRows(
rows,
hppV2PartLayingFarm,
"Laying Farm",
[]string{hppV2ScopeProductionCost},
proration,
ratio,
), nil
}
// layingFarmExpenseRatio menghitung porsi (share) kandang laying terhadap seluruh farm pada
// endDate berdasarkan bobot telur KUMULATIF (fallback ke jumlah butir bila bobot 0). Return
// ratio 0 bila tak terhitung. Diekstrak agar dipakai bersama oleh buildLayingExpenseFarmPart
// dan GetExpenseProductionScopeRange (perhitungan BOP range-correct).
func (s *hppV2Service) layingFarmExpenseRatio(
projectFlockKandangId uint,
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
endDate *time.Time,
) (float64, *HppV2Proration, error) {
if contextRow == nil {
return 0, nil, nil
}
farmPFKIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), contextRow.ProjectFlockID)
if err != nil {
return 0, nil, err
}
targetPieces, targetWeight, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil {
return 0, nil, err
return nil, err
}
farmPieces, farmWeight, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), farmPFKIDs, endDate)
if err != nil {
return 0, nil, err
return nil, err
}
basis := hppV2ProrationEggWeight
@@ -998,120 +953,27 @@ func (s *hppV2Service) layingFarmExpenseRatio(
denominator = farmPieces
}
if denominator <= 0 {
return 0, nil, nil
return nil, nil
}
ratio := numerator / denominator
if ratio <= 0 {
return 0, nil, nil
return nil, nil
}
return ratio, &HppV2Proration{
Basis: basis,
Numerator: numerator,
Denominator: denominator,
Ratio: ratio,
}, nil
}
// GetExpenseProductionScopeRange menghitung BOP production_cost satu komponen expense untuk rentang
// [startDate, endDate] secara range-correct (tidak pernah negatif untuk expense non-negatif).
// - laying-direct (ratio 1, monoton): selisih kumulatif end - start.
// - laying-farm (prorated): (expenseCum(end) - expenseCum(start)) × ratio(end).
//
// Ini mengganti pola lama di report yang men-differensiasi dua angka yang sudah diprorata dengan
// ratio berbeda (ratio(end) vs ratio(start)) — sumber bug BOP negatif saat share antar kandang bergeser.
func (s *hppV2Service) GetExpenseProductionScopeRange(projectFlockKandangId uint, startDate, endDate *time.Time, config hppV2ExpenseComponentConfig) (float64, error) {
if s.hppRepo == nil {
return 0, nil
}
contextRow, err := s.hppRepo.GetProjectFlockKandangContext(context.Background(), projectFlockKandangId)
if err != nil {
return 0, err
}
// Samakan semantik tanggal dengan CalculateHppBreakdown: kumulatif dihitung sampai AKHIR hari
// (endOfDay). Penting karena ratio egg-weight memakai r.record_datetime (granular jam).
_, endOfEndDay, err := hppV2DayWindow(endDate)
if err != nil {
return 0, err
}
_, endOfStartDay, err := hppV2DayWindow(startDate)
if err != nil {
return 0, err
}
// laying-direct: delta kumulatif (monoton, >= 0).
directEnd, err := s.buildLayingExpenseDirectPart(projectFlockKandangId, &endOfEndDay, config)
if err != nil {
return 0, err
}
directStart, err := s.buildLayingExpenseDirectPart(projectFlockKandangId, &endOfStartDay, config)
if err != nil {
return 0, err
}
directDelta := hppV2PartTotal(directEnd) - hppV2PartTotal(directStart)
if directDelta < 0 {
directDelta = 0
}
// laying-farm: delta expense kumulatif × ratio(end).
farmRowsEnd, err := s.hppRepo.ListExpenseRealizationRowsByProjectFlockID(context.Background(), contextRow.ProjectFlockID, &endOfEndDay, config.Ekspedisi)
if err != nil {
return 0, err
}
farmRowsStart, err := s.hppRepo.ListExpenseRealizationRowsByProjectFlockID(context.Background(), contextRow.ProjectFlockID, &endOfStartDay, config.Ekspedisi)
if err != nil {
return 0, err
}
farmExpenseDelta := hppV2SumExpenseRows(farmRowsEnd) - hppV2SumExpenseRows(farmRowsStart)
if farmExpenseDelta < 0 {
farmExpenseDelta = 0
}
farmDelta := 0.0
if farmExpenseDelta > 0 {
ratio, _, err := s.layingFarmExpenseRatio(projectFlockKandangId, contextRow, &endOfEndDay)
if err != nil {
return 0, err
}
farmDelta = farmExpenseDelta * ratio
}
return directDelta + farmDelta, nil
}
// GetBopRegularProductionScopeRange / GetBopEkspedisiProductionScopeRange — wrapper range-correct
// untuk dua komponen BOP, memakai config yang sama dengan GetBopRegularBreakdown/GetBopEkspedisiBreakdown.
func (s *hppV2Service) GetBopRegularProductionScopeRange(projectFlockKandangId uint, startDate, endDate *time.Time) (float64, error) {
return s.GetExpenseProductionScopeRange(projectFlockKandangId, startDate, endDate, hppV2ExpenseComponentConfig{
Code: hppV2ComponentBopRegular,
Title: "BOP Regular",
Ekspedisi: false,
})
}
func (s *hppV2Service) GetBopEkspedisiProductionScopeRange(projectFlockKandangId uint, startDate, endDate *time.Time) (float64, error) {
return s.GetExpenseProductionScopeRange(projectFlockKandangId, startDate, endDate, hppV2ExpenseComponentConfig{
Code: hppV2ComponentBopEksp,
Title: "BOP Ekspedisi",
Ekspedisi: true,
})
}
func hppV2PartTotal(part *HppV2ComponentPart) float64 {
if part == nil {
return 0
}
return part.Total
}
func hppV2SumExpenseRows(rows []commonRepo.HppV2ExpenseCostRow) float64 {
total := 0.0
for _, row := range rows {
total += row.TotalCost
}
return total
return buildExpensePartFromRows(
rows,
hppV2PartLayingFarm,
"Laying Farm",
[]string{hppV2ScopeProductionCost},
&HppV2Proration{
Basis: basis,
Numerator: numerator,
Denominator: denominator,
Ratio: ratio,
},
ratio,
), nil
}
func (s *hppV2Service) getManualPulletCostComponent(
@@ -25,13 +25,9 @@ type hppV2RepoStub struct {
chickinRowsByKey map[string][]commonRepo.HppV2ChickinCostRow
expenseRowsByPFKKey map[string][]commonRepo.HppV2ExpenseCostRow
expenseRowsByFarmKey map[string][]commonRepo.HppV2ExpenseCostRow
// expenseRowsByFarmDateKey (opsional) membuat ListExpenseRealizationRowsByProjectFlockID
// date-aware untuk menguji perhitungan range BOP. Bila non-nil, dipakai menggantikan
// expenseRowsByFarmKey; key = "<flock>|<ekspedisi>|<YYYY-MM-DD>".
expenseRowsByFarmDateKey map[string][]commonRepo.HppV2ExpenseCostRow
routeCostByProject map[uint]float64
totalPopulationByKey map[string]float64
transferSummaryByPFK map[uint]struct {
routeCostByProject map[uint]float64
totalPopulationByKey map[string]float64
transferSummaryByPFK map[uint]struct {
projectFlockID uint
totalQty float64
}
@@ -122,10 +118,6 @@ func (s *hppV2RepoStub) ListUsageCostRowsByProductFlags(_ context.Context, proje
return append([]commonRepo.HppV2UsageCostRow{}, s.usageRowsByKey[stubKey(projectFlockKandangIDs, flagNames)]...), nil
}
func (s *hppV2RepoStub) ListLayingUsageCostRowsByProductFlags(_ context.Context, layingProjectFlockKandangID uint, flagNames []string, _ *time.Time) ([]commonRepo.HppV2UsageCostRow, error) {
return append([]commonRepo.HppV2UsageCostRow{}, s.usageRowsByKey[stubKey([]uint{layingProjectFlockKandangID}, flagNames)]...), nil
}
func (s *hppV2RepoStub) ListAdjustmentCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time) ([]commonRepo.HppV2AdjustmentCostRow, error) {
return append([]commonRepo.HppV2AdjustmentCostRow{}, s.adjustRowsByKey[stubKey(projectFlockKandangIDs, flagNames)]...), nil
}
@@ -134,10 +126,7 @@ func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockKandangIDs(_ con
return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByPFKKey[expenseStubKey(projectFlockKandangIDs, ekspedisi)]...), nil
}
func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockID(_ context.Context, projectFlockID uint, date *time.Time, ekspedisi bool) ([]commonRepo.HppV2ExpenseCostRow, error) {
if s.expenseRowsByFarmDateKey != nil && date != nil {
return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByFarmDateKey[expenseFarmDateKey(projectFlockID, ekspedisi, *date)]...), nil
}
func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockID(_ context.Context, projectFlockID uint, _ *time.Time, ekspedisi bool) ([]commonRepo.HppV2ExpenseCostRow, error) {
return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByFarmKey[expenseFarmKey(projectFlockID, ekspedisi)]...), nil
}
@@ -915,108 +904,6 @@ func expenseFarmKey(projectFlockID uint, ekspedisi bool) string {
return fmt.Sprintf("farm=%d|ekspedisi=%t", projectFlockID, ekspedisi)
}
func expenseFarmDateKey(projectFlockID uint, ekspedisi bool, date time.Time) string {
return fmt.Sprintf("%d|%t|%s", projectFlockID, ekspedisi, date.Format("2006-01-02"))
}
func chickinStubKey(ids []uint, flags []string, excludeTransferToLaying bool) string {
return stubKey(ids, append(append([]string{}, flags...), fmt.Sprintf("exclude_transfer_to_laying=%t", excludeTransferToLaying)))
}
// TestHppV2PakanBreakdown_LayingAttributesLokasiFeedAsProductionCost membuktikan Fix 1:
// untuk kandang LAYING, pemakaian pakan (termasuk dari gudang LOKASI dengan pfk NULL) diatribusikan
// sebagai production_cost via ListLayingUsageCostRowsByProductFlags — BUKAN pullet_cost.
// Stub memetakan ListLayingUsageCostRowsByProductFlags(50,...) ke usageRowsByKey[[50]+PAKAN].
func TestHppV2PakanBreakdown_LayingAttributesLokasiFeedAsProductionCost(t *testing.T) {
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
50: {
ProjectFlockKandangID: 50,
ProjectFlockID: 20,
ProjectFlockCategory: string(utils.ProjectFlockCategoryLaying),
KandangID: 1,
LocationID: 14,
HouseType: "close_house",
},
},
usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{
stubKey([]uint{50}, []string{"PAKAN"}): {
{StockableType: "purchase_items", StockableID: 9001, SourceProductID: 9, SourceProductName: "Pakan Laying", Qty: 310, UnitPrice: 1, TotalCost: 310},
},
},
// Tanpa transferSummaryByPFK[50] -> growing part nil; tanpa adjustRowsByKey -> laying cutover nil.
}
svc := NewHppV2Service(repo)
component, err := svc.GetPakanBreakdown(50, mustDate(t, "2026-05-31"))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if component == nil {
t.Fatal("expected PAKAN component")
}
if component.Total != 310 {
t.Fatalf("expected component total 310, got %v", component.Total)
}
if len(component.Parts) != 1 || component.Parts[0].Code != hppV2PartLayingNormal {
t.Fatalf("expected single laying_normal part, got %+v", component.Parts)
}
if got := componentScopeTotal(component, hppV2ScopeProductionCost); got != 310 {
t.Fatalf("expected production_cost 310, got %v", got)
}
if got := componentScopeTotal(component, hppV2ScopePulletCost); got != 0 {
t.Fatalf("expected pullet_cost 0 (feed laying bukan pullet), got %v", got)
}
}
// TestHppV2BopProductionScopeRange_NonNegativeAndProrated membuktikan Fix 2: BOP farm-level dihitung
// sebagai (expenseCum(end) - expenseCum(start)) × ratio(end) — range-correct & tidak pernah negatif.
// Range [2026-04-30, 2026-05-31] -> engine memakai endOfDay: start=2026-05-01, end=2026-06-01.
// Share kandang 50 = 30/(30+70) = 0.3.
// - REGULAR: expense farm tumbuh 1000 -> 1300 (delta 300) => 300 × 0.3 = 90.
// - EKSPEDISI: expense farm "turun" 500 -> 200 (delta -300, kasus uji clamp) => di-clamp ke 0.
func TestHppV2BopProductionScopeRange_NonNegativeAndProrated(t *testing.T) {
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
50: {ProjectFlockKandangID: 50, ProjectFlockID: 20, ProjectFlockCategory: string(utils.ProjectFlockCategoryLaying)},
},
pfkIDsByProject: map[uint][]uint{
20: {50, 51},
},
eggProductionByPFK: map[uint]struct {
pieces float64
kg float64
}{
50: {pieces: 300, kg: 30},
51: {pieces: 700, kg: 70},
},
expenseRowsByFarmDateKey: map[string][]commonRepo.HppV2ExpenseCostRow{
// REGULAR (ekspedisi=false): kumulatif 1000 (start) -> 1300 (end)
expenseFarmDateKey(20, false, mustTime(t, "2026-05-01")): {{TotalCost: 1000}},
expenseFarmDateKey(20, false, mustTime(t, "2026-06-01")): {{TotalCost: 800}, {TotalCost: 500}},
// EKSPEDISI (ekspedisi=true): kumulatif 500 (start) -> 200 (end) => delta negatif, harus di-clamp
expenseFarmDateKey(20, true, mustTime(t, "2026-05-01")): {{TotalCost: 500}},
expenseFarmDateKey(20, true, mustTime(t, "2026-06-01")): {{TotalCost: 200}},
},
}
svc := NewHppV2Service(repo)
start := mustDate(t, "2026-04-30")
end := mustDate(t, "2026-05-31")
reg, err := svc.GetBopRegularProductionScopeRange(50, start, end)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if reg != 90 {
t.Fatalf("expected BOP regular range 90 (300 × 0.3), got %v", reg)
}
eksp, err := svc.GetBopEkspedisiProductionScopeRange(50, start, end)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if eksp != 0 {
t.Fatalf("expected BOP ekspedisi range clamped to 0 (delta negatif), got %v", eksp)
}
}
@@ -0,0 +1,14 @@
-- Reverse UPSERT: hapus baris PFK 47 & 48 yang kemungkinan baru diinsert oleh up migration ini.
-- Jika sebelumnya sudah ada (ON CONFLICT DO UPDATE), baris ini akan terhapus —
-- restore manual dari backup jika diperlukan.
DELETE FROM farm_depreciation_manual_inputs
WHERE project_flock_id IN (47, 48);
-- UPDATE rows untuk PFK 427 tidak bisa di-reverse secara presisi:
-- nilai total_cost sebelum migration ini tidak tersimpan di migration history
-- (data awal di-load via cmd/import-farm-depreciation-manual-inputs dari Excel).
-- PFK 10 dan 11 tidak berubah (nilai sama dengan state dari migration 20260529144559).
-- Jika perlu rollback penuh: restore dari database backup atau re-import Excel lama.
-- Recompute snapshots setelah rollback
TRUNCATE TABLE farm_depreciation_snapshots;
@@ -0,0 +1,105 @@
UPDATE farm_depreciation_manual_inputs
SET total_cost = 1900157533.55,
cutover_date = DATE '2026-02-28',
updated_at = NOW()
WHERE project_flock_id = 10;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 146658321.066,
cutover_date = DATE '2026-02-28',
updated_at = NOW()
WHERE project_flock_id = 13;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 51824694.138,
cutover_date = DATE '2026-02-28',
updated_at = NOW()
WHERE project_flock_id = 17;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 15491774.796,
cutover_date = DATE '2026-02-28',
updated_at = NOW()
WHERE project_flock_id = 8;
-- Cutover 2026-02-28 (lanjutan)
UPDATE farm_depreciation_manual_inputs
SET total_cost = 575074391.36, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 4;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 578360642.51, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 5;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 880983605.92, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 6;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 391669576.153, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 9;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 2521797832.14, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 11;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 139227054.164, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 12;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 380083106.836, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 14;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 705136853.847, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 15;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 209816474.000, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 18;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 557606867.000, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 19;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 239330456.11, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 20;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 4724203916.72, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 26;
-- Cutover 2026-05-15
UPDATE farm_depreciation_manual_inputs
SET total_cost = 5449963647.43, cutover_date = DATE '2026-05-15', updated_at = NOW()
WHERE project_flock_id = 27;
-- Cutover 2026-06-08 (upsert — row mungkin belum ada)
INSERT INTO farm_depreciation_manual_inputs (project_flock_id, total_cost, cutover_date, created_at, updated_at)
VALUES (47, 5395429899.42, DATE '2026-06-08', NOW(), NOW())
ON CONFLICT (project_flock_id) DO UPDATE
SET total_cost = EXCLUDED.total_cost,
cutover_date = EXCLUDED.cutover_date,
updated_at = NOW();
-- Cutover 2026-06-16 (upsert — row mungkin belum ada)
INSERT INTO farm_depreciation_manual_inputs (project_flock_id, total_cost, cutover_date, created_at, updated_at)
VALUES (48, 5514616442.08, DATE '2026-06-16', NOW(), NOW())
ON CONFLICT (project_flock_id) DO UPDATE
SET total_cost = EXCLUDED.total_cost,
cutover_date = EXCLUDED.cutover_date,
updated_at = NOW();
-- Pengaman: pastikan snapshot di-recompute dengan total_cost baru
-- saat user request /api/reports/expense/depreciation
TRUNCATE TABLE farm_depreciation_snapshots;
@@ -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"`
@@ -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)
@@ -387,35 +387,87 @@ func (s productionStandardService) EnsureWeekAvailable(ctx context.Context, stan
return nil
}
week := ((day - 1) / 7) + 1
if week <= 0 {
requestedWeek := ((day - 1) / 7) + 1
if requestedWeek <= 0 {
return nil
}
upperCategory := strings.ToUpper(category)
if upperCategory == string(utils.ProjectFlockCategoryLaying) {
detail, err := s.ProductionStandardDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week)
effectiveWeek := requestedWeek
firstCommonWeek, ok, err := s.layingFirstCommonStandardWeek(ctx, standardID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week))
}
return err
}
if detail == nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week))
if ok && requestedWeek < firstCommonWeek {
effectiveWeek = firstCommonWeek
}
detail, err := s.ProductionStandardDetailRepo.GetByStandardIDAndWeek(ctx, standardID, effectiveWeek)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
growthDetail, err := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(ctx, standardID, effectiveWeek)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
if detail != nil && growthDetail != nil {
return nil
}
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", requestedWeek))
}
growthDetail, err := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week)
growthDetail, err := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(ctx, standardID, requestedWeek)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week))
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", requestedWeek))
}
return err
}
if growthDetail == nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week))
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", requestedWeek))
}
return nil
}
func (s productionStandardService) layingFirstCommonStandardWeek(ctx context.Context, standardID uint) (int, bool, error) {
details, err := s.ProductionStandardDetailRepo.GetByProductionStandardID(ctx, standardID)
if err != nil {
return 0, false, err
}
detailWeeks := make(map[int]struct{}, len(details))
for _, detail := range details {
if detail.Week <= 0 {
continue
}
detailWeeks[detail.Week] = struct{}{}
}
growthDetails, err := s.StandardGrowthDetailRepo.GetByProductionStandardID(ctx, standardID)
if err != nil {
return 0, false, err
}
firstCommonWeek := 0
for _, detail := range growthDetails {
if detail.Week <= 0 {
continue
}
if _, ok := detailWeeks[detail.Week]; !ok {
continue
}
if firstCommonWeek == 0 || detail.Week < firstCommonWeek {
firstCommonWeek = detail.Week
}
}
return firstCommonWeek, firstCommonWeek > 0, nil
}
@@ -0,0 +1,95 @@
package service
import (
"context"
"strings"
"testing"
"github.com/glebarez/sqlite"
repositories "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
)
func TestEnsureWeekAvailableAllowsLayingBeforeFirstCommonStandardWeek(t *testing.T) {
svc := setupProductionStandardServiceTest(t)
if err := svc.EnsureWeekAvailable(context.Background(), 1, string(utils.ProjectFlockCategoryLaying), 85); err != nil {
t.Fatalf("expected pre-standard laying week to be allowed, got %v", err)
}
}
func TestEnsureWeekAvailableRejectsLayingMissingWeekAfterStandardStarts(t *testing.T) {
svc := setupProductionStandardServiceTest(t)
err := svc.EnsureWeekAvailable(context.Background(), 1, string(utils.ProjectFlockCategoryLaying), 127)
if err == nil {
t.Fatal("expected missing laying standard week to be rejected")
}
if !strings.Contains(err.Error(), "week 19") {
t.Fatalf("expected error to mention requested week 19, got %v", err)
}
}
func TestEnsureWeekAvailableKeepsGrowingWeekStrict(t *testing.T) {
svc := setupProductionStandardServiceTest(t)
err := svc.EnsureWeekAvailable(context.Background(), 2, string(utils.ProjectFlockCategoryGrowing), 8)
if err == nil {
t.Fatal("expected missing growing standard week to be rejected")
}
if !strings.Contains(err.Error(), "week 2") {
t.Fatalf("expected error to mention requested week 2, got %v", err)
}
}
func setupProductionStandardServiceTest(t *testing.T) productionStandardService {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
statements := []string{
`CREATE TABLE production_standard_details (
id INTEGER PRIMARY KEY,
production_standard_id INTEGER NOT NULL,
week INTEGER NOT NULL,
target_hen_day_production NUMERIC NULL,
target_hen_house_production NUMERIC NULL,
target_egg_weight NUMERIC NULL,
target_egg_mass NUMERIC NULL,
standard_fcr NUMERIC NULL,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL
)`,
`CREATE TABLE standard_growth_details (
id INTEGER PRIMARY KEY,
production_standard_id INTEGER NOT NULL,
target_mean_bw NUMERIC NULL,
max_depletion NUMERIC NULL,
min_uniformity NUMERIC NOT NULL,
week INTEGER NOT NULL,
feed_intake NUMERIC NULL,
created_at TIMESTAMP NULL,
created_by INTEGER NOT NULL
)`,
`INSERT INTO production_standard_details (id, production_standard_id, week, standard_fcr) VALUES
(1, 1, 18, 2.1)`,
`INSERT INTO standard_growth_details (id, production_standard_id, week, min_uniformity, created_by) VALUES
(1, 1, 18, 80, 1),
(2, 2, 1, 80, 1)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
return productionStandardService{
ProductionStandardDetailRepo: repositories.NewProductionStandardDetailRepository(db),
StandardGrowthDetailRepo: repositories.NewStandardGrowthDetailRepository(db),
}
}
@@ -3256,19 +3256,8 @@ func (s *repportService) GetHppPerFarm(ctx *fiber.Ctx) (*dto.HppPerFarmResponseD
feed := codeTotals[hppPerFarmComponentPakan]
ovk := codeTotals[hppPerFarmComponentOvk]
// BOP dihitung range-correct via engine (hindari differential rasio egg-weight yang bisa
// negatif saat share antar kandang bergeser). Keluarkan kode BOP dari codeTotals agar tidak
// ikut terjumlah dua kali di akumulasi 'nonDepreciation'/'other'.
delete(codeTotals, hppPerFarmComponentBopRegular)
delete(codeTotals, hppPerFarmComponentBopEkspedisi)
bop, err := s.hppPerFarmFlockBopRange(ctx.Context(), flockID, startBreakdownDate, endBreakdownDate)
if err != nil {
return nil, nil, err
}
nonDepreciation := bop
bop := codeTotals[hppPerFarmComponentBopRegular] + codeTotals[hppPerFarmComponentBopEkspedisi]
nonDepreciation := 0.0
for _, value := range codeTotals {
nonDepreciation += value
}
@@ -3439,39 +3428,6 @@ func (s *repportService) hppPerFarmFlockCostRange(ctx context.Context, projectFl
return codeTotals, nil
}
// hppPerFarmFlockBopRange menjumlah BOP production_cost range-correct (BOP_REGULAR + BOP_EKSPEDISI)
// untuk seluruh PFK dalam flock, memakai GetBop*ProductionScopeRange di engine. Pendekatan ini
// menghitung delta expense kumulatif lalu memproratanya dengan rasio akhir-range — bukan
// men-differensiasi dua angka yang sudah diprorata berbeda — sehingga tidak pernah negatif.
func (s *repportService) hppPerFarmFlockBopRange(ctx context.Context, projectFlockID uint, startBreakdownDate, endBreakdownDate time.Time) (float64, error) {
if s.HppCostRepo == nil {
return 0, errors.New("hpp cost repository is not configured")
}
if s.HppV2Svc == nil {
return 0, errors.New("hpp v2 service is not configured")
}
pfkIDs, err := s.HppCostRepo.GetProjectFlockKandangIDs(ctx, projectFlockID)
if err != nil {
return 0, err
}
total := 0.0
for _, pfkID := range pfkIDs {
reg, err := s.HppV2Svc.GetBopRegularProductionScopeRange(pfkID, &startBreakdownDate, &endBreakdownDate)
if err != nil {
return 0, err
}
eksp, err := s.HppV2Svc.GetBopEkspedisiProductionScopeRange(pfkID, &startBreakdownDate, &endBreakdownDate)
if err != nil {
return 0, err
}
total += reg + eksp
}
return total, nil
}
// sumHppPerFarmDepreciationOverRange sums the daily depreciation_value from
// farm_depreciation_snapshots across [startDate, endDate] per project flock,
// computing (and persisting) any missing daily snapshot on demand — same lazy
@@ -205,6 +205,7 @@ func AttachProductionStandards(ctx context.Context, db *gorm.DB, warnOnly bool,
standardDetailByStd := make(map[uint]map[int]*entity.ProductionStandardDetail, len(standardIDs))
growthDetailByStd := make(map[uint]map[int]*entity.StandardGrowthDetail, len(standardIDs))
firstCommonWeekByStd := make(map[uint]int, len(standardIDs))
for standardID := range standardIDs {
details, err := standardDetailRepo.GetByProductionStandardID(ctx, standardID)
@@ -242,6 +243,10 @@ func AttachProductionStandards(ctx context.Context, db *gorm.DB, warnOnly bool,
growthMap[growth.Week] = &growth
}
growthDetailByStd[standardID] = growthMap
if firstCommonWeek, ok := firstCommonStandardWeek(detailMap, growthMap); ok {
firstCommonWeekByStd[standardID] = firstCommonWeek
}
}
// Batch-load laying transfer targets → EARLIEST source PFK chick_in_date per target.
@@ -284,6 +289,9 @@ func AttachProductionStandards(ctx context.Context, db *gorm.DB, warnOnly bool,
continue
}
week := computeTransferAwareWeek(item, sourceChickInByTarget)
if firstCommonWeek, ok := firstCommonWeekByStd[standardID]; ok {
week = effectiveProductionStandardWeek(item, week, firstCommonWeek)
}
item.StandardWeek = &week
cacheKey := standardKey{standardID: standardID, week: week}
if cached, ok := cache[cacheKey]; ok {
@@ -324,6 +332,38 @@ func applyProductionStandardValues(item *entity.Recording, values productionStan
item.StandardFcr = fcr
}
func firstCommonStandardWeek(
detailMap map[int]*entity.ProductionStandardDetail,
growthMap map[int]*entity.StandardGrowthDetail,
) (int, bool) {
firstWeek := 0
for week := range detailMap {
if week <= 0 {
continue
}
if _, ok := growthMap[week]; !ok {
continue
}
if firstWeek == 0 || week < firstWeek {
firstWeek = week
}
}
return firstWeek, firstWeek > 0
}
func effectiveProductionStandardWeek(item *entity.Recording, actualWeek int, firstCommonWeek int) int {
if item == nil || actualWeek <= 0 || firstCommonWeek <= 0 {
return actualWeek
}
if !IsLayingRecording(*item) {
return actualWeek
}
if actualWeek < firstCommonWeek {
return firstCommonWeek
}
return actualWeek
}
// collectLayingPFKIDs mengumpulkan semua project_flock_kandang_id dari recording laying
func collectLayingPFKIDs(items []*entity.Recording) []uint {
seen := make(map[uint]struct{})
@@ -1,10 +1,15 @@
package recording
import (
"context"
"testing"
"time"
"github.com/glebarez/sqlite"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
)
func TestMapDepletionsKeepsSourceWarehouseRoutes(t *testing.T) {
@@ -45,3 +50,126 @@ func TestMapEggsSetsProjectFlockKandangID(t *testing.T) {
t.Fatalf("expected project flock kandang id 44, got %+v", got[0].ProjectFlockKandangId)
}
}
func TestAttachProductionStandardsClampsLayingPreStandardWeek(t *testing.T) {
db := setupAttachProductionStandardTestDB(t)
day := 91
recordDate := time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC)
chickInDate := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
recording := &entity.Recording{
Id: 501,
ProjectFlockKandangId: 103,
RecordDatetime: recordDate,
Day: &day,
ProjectFlockKandang: &entity.ProjectFlockKandang{
Id: 103,
ProjectFlock: entity.ProjectFlock{
Id: 52,
Category: string(utils.ProjectFlockCategoryLaying),
ProductionStandardId: 1,
ProductionStandard: entity.ProductionStandard{
Id: 1,
Name: "STD Laying",
},
},
},
}
actualWeek := computeTransferAwareWeek(recording, map[uint]time.Time{103: chickInDate})
if actualWeek != 13 {
t.Fatalf("expected actual transfer-aware week 13, got %d", actualWeek)
}
if err := AttachProductionStandards(context.Background(), db, false, nil, recording); err != nil {
t.Fatalf("expected attach standard to succeed, got %v", err)
}
if recording.Day == nil || *recording.Day != 91 {
t.Fatalf("expected actual recording day to remain 91, got %+v", recording.Day)
}
if recording.StandardWeek == nil || *recording.StandardWeek != 18 {
t.Fatalf("expected effective standard week 18, got %+v", recording.StandardWeek)
}
if recording.StandardFeedIntake == nil || *recording.StandardFeedIntake != 120 {
t.Fatalf("expected feed intake std from week 18, got %+v", recording.StandardFeedIntake)
}
if recording.StandardHenDay == nil || *recording.StandardHenDay != 80 {
t.Fatalf("expected hen day std from week 18, got %+v", recording.StandardHenDay)
}
if recording.StandardFcr == nil || *recording.StandardFcr != 2.1 {
t.Fatalf("expected fcr std from week 18, got %+v", recording.StandardFcr)
}
}
func setupAttachProductionStandardTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
statements := []string{
`CREATE TABLE production_standard_details (
id INTEGER PRIMARY KEY,
production_standard_id INTEGER NOT NULL,
week INTEGER NOT NULL,
target_hen_day_production NUMERIC NULL,
target_hen_house_production NUMERIC NULL,
target_egg_weight NUMERIC NULL,
target_egg_mass NUMERIC NULL,
standard_fcr NUMERIC NULL,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL
)`,
`CREATE TABLE standard_growth_details (
id INTEGER PRIMARY KEY,
production_standard_id INTEGER NOT NULL,
target_mean_bw NUMERIC NULL,
max_depletion NUMERIC NULL,
min_uniformity NUMERIC NOT NULL,
week INTEGER NOT NULL,
feed_intake NUMERIC NULL,
created_at TIMESTAMP NULL,
created_by INTEGER NOT NULL
)`,
`CREATE TABLE laying_transfer_targets (
id INTEGER PRIMARY KEY,
laying_transfer_id INTEGER NOT NULL,
target_project_flock_kandang_id INTEGER NOT NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE laying_transfers (
id INTEGER PRIMARY KEY,
source_project_flock_kandang_id INTEGER NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE project_chickins (
id INTEGER PRIMARY KEY,
project_flock_kandang_id INTEGER NOT NULL,
chick_in_date TIMESTAMP NOT NULL,
deleted_at TIMESTAMP NULL
)`,
`INSERT INTO production_standard_details
(id, production_standard_id, week, target_hen_day_production, target_hen_house_production, target_egg_weight, target_egg_mass, standard_fcr)
VALUES (1, 1, 18, 80, 70, 55, 44, 2.1)`,
`INSERT INTO standard_growth_details
(id, production_standard_id, week, feed_intake, max_depletion, min_uniformity, created_by)
VALUES (1, 1, 18, 120, 1.5, 80, 1)`,
`INSERT INTO laying_transfers (id, source_project_flock_kandang_id, deleted_at) VALUES
(77, 83, NULL)`,
`INSERT INTO laying_transfer_targets (id, laying_transfer_id, target_project_flock_kandang_id, deleted_at) VALUES
(88, 77, 103, NULL)`,
`INSERT INTO project_chickins (id, project_flock_kandang_id, chick_in_date, deleted_at) VALUES
(99, 83, '2026-01-01 00:00:00', NULL)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
return db
}