From aa3e655a674682e453f8a6a2061ba7f19536aee6 Mon Sep 17 00:00:00 2001 From: giovanni Date: Sat, 6 Jun 2026 10:29:33 +0700 Subject: [PATCH 1/3] adjust hpp per farm query to take feed and ovk --- .../repository/common.hppv2.repository.go | 102 +++++++++- .../common.hppv2.repository_test.go | 12 +- .../common/service/common.hppv2.service.go | 180 ++++++++++++++++-- .../service/common.hppv2.service_test.go | 121 +++++++++++- .../repports/services/repport.service.go | 48 ++++- 5 files changed, 429 insertions(+), 34 deletions(-) diff --git a/internal/common/repository/common.hppv2.repository.go b/internal/common/repository/common.hppv2.repository.go index a25ebf70..75bed362 100644 --- a/internal/common/repository/common.hppv2.repository.go +++ b/internal/common/repository/common.hppv2.repository.go @@ -114,6 +114,12 @@ 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) @@ -367,18 +373,19 @@ 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) OR (NOT (%s) AND rs.project_flock_kandang_id IS NULL)", - transferExistsCondition, + "(%s) AND rs.project_flock_kandang_id IS NOT NULL AND rs.project_flock_kandang_id <> r.project_flock_kandangs_id", 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 @@ -585,6 +592,91 @@ 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, diff --git a/internal/common/repository/common.hppv2.repository_test.go b/internal/common/repository/common.hppv2.repository_test.go index 1e76b2d5..9245bb3b 100644 --- a/internal/common/repository/common.hppv2.repository_test.go +++ b/internal/common/repository/common.hppv2.repository_test.go @@ -192,19 +192,27 @@ 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, 750) + assertFloatEquals(t, total, 110) + // 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, 240) + assertFloatEquals(t, earlyTotal, 110) } func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB { diff --git a/internal/common/service/common.hppv2.service.go b/internal/common/service/common.hppv2.service.go index d132cb14..fb760cfe 100644 --- a/internal/common/service/common.hppv2.service.go +++ b/internal/common/service/common.hppv2.service.go @@ -55,6 +55,10 @@ 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) } @@ -453,7 +457,7 @@ func (s *hppV2Service) getStockUsageComponent(projectFlockKandangId uint, endDat total += growingCutoverPart.Total } - layingNormalPart, err := s.buildLayingUsagePart(projectFlockKandangId, endDate, config, false) + layingNormalPart, err := s.buildLayingUsagePart(projectFlockKandangId, contextRow, endDate, config, false) if err != nil { return nil, err } @@ -462,7 +466,7 @@ func (s *hppV2Service) getStockUsageComponent(projectFlockKandangId uint, endDat total += layingNormalPart.Total } - layingCutoverPart, err := s.buildLayingUsagePart(projectFlockKandangId, endDate, config, true) + layingCutoverPart, err := s.buildLayingUsagePart(projectFlockKandangId, contextRow, endDate, config, true) if err != nil { return nil, err } @@ -737,6 +741,7 @@ func (s *hppV2Service) buildGrowingUsagePart( func (s *hppV2Service) buildLayingUsagePart( projectFlockKandangId uint, + contextRow *commonRepo.HppV2ProjectFlockKandangContext, endDate *time.Time, config hppV2StockComponentConfig, cutover bool, @@ -778,7 +783,16 @@ func (s *hppV2Service) buildLayingUsagePart( }, nil } - rows, err := s.hppRepo.ListUsageCostRowsByProductFlags(context.Background(), []uint{projectFlockKandangId}, config.NormalFlags, endDate) + // 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) + } if err != nil { return nil, err } @@ -931,17 +945,48 @@ func (s *hppV2Service) buildLayingExpenseFarmPart( return nil, nil } - farmPFKIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), contextRow.ProjectFlockID) + ratio, proration, err := s.layingFarmExpenseRatio(projectFlockKandangId, contextRow, endDate) 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 nil, err + return 0, nil, err } farmPieces, farmWeight, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), farmPFKIDs, endDate) if err != nil { - return nil, err + return 0, nil, err } basis := hppV2ProrationEggWeight @@ -953,27 +998,120 @@ func (s *hppV2Service) buildLayingExpenseFarmPart( denominator = farmPieces } if denominator <= 0 { - return nil, nil + return 0, nil, nil } ratio := numerator / denominator if ratio <= 0 { - return nil, nil + return 0, nil, nil } - return buildExpensePartFromRows( - rows, - hppV2PartLayingFarm, - "Laying Farm", - []string{hppV2ScopeProductionCost}, - &HppV2Proration{ - Basis: basis, - Numerator: numerator, - Denominator: denominator, - Ratio: ratio, - }, - ratio, - ), 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 } func (s *hppV2Service) getManualPulletCostComponent( diff --git a/internal/common/service/common.hppv2.service_test.go b/internal/common/service/common.hppv2.service_test.go index 5cbf0523..b628acad 100644 --- a/internal/common/service/common.hppv2.service_test.go +++ b/internal/common/service/common.hppv2.service_test.go @@ -25,9 +25,13 @@ type hppV2RepoStub struct { chickinRowsByKey map[string][]commonRepo.HppV2ChickinCostRow expenseRowsByPFKKey map[string][]commonRepo.HppV2ExpenseCostRow expenseRowsByFarmKey map[string][]commonRepo.HppV2ExpenseCostRow - routeCostByProject map[uint]float64 - totalPopulationByKey map[string]float64 - transferSummaryByPFK map[uint]struct { + // expenseRowsByFarmDateKey (opsional) membuat ListExpenseRealizationRowsByProjectFlockID + // date-aware untuk menguji perhitungan range BOP. Bila non-nil, dipakai menggantikan + // expenseRowsByFarmKey; key = "||". + expenseRowsByFarmDateKey map[string][]commonRepo.HppV2ExpenseCostRow + routeCostByProject map[uint]float64 + totalPopulationByKey map[string]float64 + transferSummaryByPFK map[uint]struct { projectFlockID uint totalQty float64 } @@ -118,6 +122,10 @@ 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 } @@ -126,7 +134,10 @@ func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockKandangIDs(_ con return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByPFKKey[expenseStubKey(projectFlockKandangIDs, ekspedisi)]...), nil } -func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockID(_ context.Context, projectFlockID uint, _ *time.Time, ekspedisi bool) ([]commonRepo.HppV2ExpenseCostRow, error) { +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 + } return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByFarmKey[expenseFarmKey(projectFlockID, ekspedisi)]...), nil } @@ -904,6 +915,108 @@ 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) + } +} diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 08f8d556..21f72e8c 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -3256,8 +3256,19 @@ func (s *repportService) GetHppPerFarm(ctx *fiber.Ctx) (*dto.HppPerFarmResponseD feed := codeTotals[hppPerFarmComponentPakan] ovk := codeTotals[hppPerFarmComponentOvk] - bop := codeTotals[hppPerFarmComponentBopRegular] + codeTotals[hppPerFarmComponentBopEkspedisi] - nonDepreciation := 0.0 + + // 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 for _, value := range codeTotals { nonDepreciation += value } @@ -3428,6 +3439,39 @@ 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 From edfd6ac95cdf1ac12b708c0da827f67b3db30900 Mon Sep 17 00:00:00 2001 From: giovanni Date: Sun, 7 Jun 2026 16:34:22 +0700 Subject: [PATCH 2/3] 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) From 2216f572c2aaa171101ecc34e025a7eb3df384ba Mon Sep 17 00:00:00 2001 From: giovanni Date: Sun, 7 Jun 2026 18:55:24 +0700 Subject: [PATCH 3/3] fix recording standar prod laying --- ..._farm_depreciation_manual_inputs..down.sql | 14 ++ ...te_farm_depreciation_manual_inputs..up.sql | 105 ++++++++++++++ .../services/production-standard.service.go | 74 ++++++++-- .../production-standard.service_test.go | 95 +++++++++++++ internal/utils/recording/recording_helpers.go | 40 ++++++ .../utils/recording/util.recording_test.go | 128 ++++++++++++++++++ 6 files changed, 445 insertions(+), 11 deletions(-) create mode 100644 internal/database/migrations/20260607115408_update_farm_depreciation_manual_inputs..down.sql create mode 100644 internal/database/migrations/20260607115408_update_farm_depreciation_manual_inputs..up.sql create mode 100644 internal/modules/master/production-standards/services/production-standard.service_test.go diff --git a/internal/database/migrations/20260607115408_update_farm_depreciation_manual_inputs..down.sql b/internal/database/migrations/20260607115408_update_farm_depreciation_manual_inputs..down.sql new file mode 100644 index 00000000..48e4ff1a --- /dev/null +++ b/internal/database/migrations/20260607115408_update_farm_depreciation_manual_inputs..down.sql @@ -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 4–27 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; diff --git a/internal/database/migrations/20260607115408_update_farm_depreciation_manual_inputs..up.sql b/internal/database/migrations/20260607115408_update_farm_depreciation_manual_inputs..up.sql new file mode 100644 index 00000000..24a72a2e --- /dev/null +++ b/internal/database/migrations/20260607115408_update_farm_depreciation_manual_inputs..up.sql @@ -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; diff --git a/internal/modules/master/production-standards/services/production-standard.service.go b/internal/modules/master/production-standards/services/production-standard.service.go index b2128e88..29921d6a 100644 --- a/internal/modules/master/production-standards/services/production-standard.service.go +++ b/internal/modules/master/production-standards/services/production-standard.service.go @@ -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 +} diff --git a/internal/modules/master/production-standards/services/production-standard.service_test.go b/internal/modules/master/production-standards/services/production-standard.service_test.go new file mode 100644 index 00000000..7915ab40 --- /dev/null +++ b/internal/modules/master/production-standards/services/production-standard.service_test.go @@ -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), + } +} diff --git a/internal/utils/recording/recording_helpers.go b/internal/utils/recording/recording_helpers.go index fda16f48..ac5c60f3 100644 --- a/internal/utils/recording/recording_helpers.go +++ b/internal/utils/recording/recording_helpers.go @@ -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{}) diff --git a/internal/utils/recording/util.recording_test.go b/internal/utils/recording/util.recording_test.go index 9ce0ff75..98d5ae17 100644 --- a/internal/utils/recording/util.recording_test.go +++ b/internal/utils/recording/util.recording_test.go @@ -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 +}