package service import ( "context" "fmt" "sort" "strings" "testing" "time" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" "gitlab.com/mbugroup/lti-api.git/internal/utils" ) type hppV2RepoStub struct { contextByPFK map[uint]*commonRepo.HppV2ProjectFlockKandangContext pfkIDsByProject map[uint][]uint latestTransferByPFK map[uint]*commonRepo.HppV2LatestTransferInputRow manualInputByProject map[uint]*commonRepo.HppV2ManualDepreciationInputRow snapshotByProjectKey map[string]*commonRepo.HppV2FarmDepreciationSnapshotRow chickInDateByProject map[uint]*time.Time depreciationByHouse map[string]map[int]float64 usageRowsByKey map[string][]commonRepo.HppV2UsageCostRow adjustRowsByKey map[string][]commonRepo.HppV2AdjustmentCostRow chickinRowsByKey map[string][]commonRepo.HppV2ChickinCostRow expenseRowsByPFKKey map[string][]commonRepo.HppV2ExpenseCostRow expenseRowsByFarmKey map[string][]commonRepo.HppV2ExpenseCostRow totalPopulationByKey map[string]float64 transferSummaryByPFK map[uint]struct { projectFlockID uint totalQty float64 } eggProductionByPFK map[uint]struct { pieces float64 kg float64 } eggSalesByPFK map[uint]struct { pieces float64 kg float64 } } func (s *hppV2RepoStub) GetProjectFlockKandangContext(_ context.Context, projectFlockKandangId uint) (*commonRepo.HppV2ProjectFlockKandangContext, error) { row := s.contextByPFK[projectFlockKandangId] if row == nil { return nil, fmt.Errorf("pfk %d not found", projectFlockKandangId) } return row, nil } func (s *hppV2RepoStub) GetProjectFlockKandangIDs(_ context.Context, projectFlockId uint) ([]uint, error) { return append([]uint{}, s.pfkIDsByProject[projectFlockId]...), nil } func (s *hppV2RepoStub) GetLatestTransferInputByProjectFlockKandangID(_ context.Context, projectFlockKandangId uint, _ time.Time) (*commonRepo.HppV2LatestTransferInputRow, error) { return s.latestTransferByPFK[projectFlockKandangId], nil } func (s *hppV2RepoStub) GetManualDepreciationInputByProjectFlockID(_ context.Context, projectFlockID uint) (*commonRepo.HppV2ManualDepreciationInputRow, error) { return s.manualInputByProject[projectFlockID], nil } func (s *hppV2RepoStub) GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(_ context.Context, projectFlockID uint, periodDate time.Time) (*commonRepo.HppV2FarmDepreciationSnapshotRow, error) { if s.snapshotByProjectKey == nil { return nil, nil } return s.snapshotByProjectKey[fmt.Sprintf("%d|%s", projectFlockID, periodDate.Format("2006-01-02"))], nil } func (s *hppV2RepoStub) GetEarliestChickInDateByProjectFlockID(_ context.Context, projectFlockID uint) (*time.Time, error) { return s.chickInDateByProject[projectFlockID], nil } func (s *hppV2RepoStub) GetDepreciationPercents(_ context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, error) { result := make(map[string]map[int]float64) for _, houseType := range houseTypes { source := s.depreciationByHouse[houseType] if len(source) == 0 { continue } result[houseType] = make(map[int]float64) for day, pct := range source { if day <= maxDay { result[houseType][day] = pct } } } return result, nil } func (s *hppV2RepoStub) ListUsageCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time) ([]commonRepo.HppV2UsageCostRow, error) { return append([]commonRepo.HppV2UsageCostRow{}, s.usageRowsByKey[stubKey(projectFlockKandangIDs, 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 } func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockKandangIDs(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time, ekspedisi bool) ([]commonRepo.HppV2ExpenseCostRow, error) { 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) { return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByFarmKey[expenseFarmKey(projectFlockID, ekspedisi)]...), nil } func (s *hppV2RepoStub) ListChickinCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time, excludeTransferToLaying bool) ([]commonRepo.HppV2ChickinCostRow, error) { return append([]commonRepo.HppV2ChickinCostRow{}, s.chickinRowsByKey[chickinStubKey(projectFlockKandangIDs, flagNames, excludeTransferToLaying)]...), nil } func (s *hppV2RepoStub) GetFeedUsageCost(_ context.Context, _ []uint, _ *time.Time) (float64, error) { return 0, nil } func (s *hppV2RepoStub) GetTotalPopulation(_ context.Context, projectFlockKandangIDs []uint) (float64, error) { return s.totalPopulationByKey[stubKey(projectFlockKandangIDs, nil)], nil } func (s *hppV2RepoStub) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time) (float64, float64, error) { totalPieces := 0.0 totalKg := 0.0 for _, projectFlockKandangID := range projectFlockKandangIDs { row := s.eggProductionByPFK[projectFlockKandangID] totalPieces += row.pieces totalKg += row.kg } return totalPieces, totalKg, nil } func (s *hppV2RepoStub) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time, _ *time.Time) (float64, float64, error) { if len(projectFlockKandangIDs) != 1 { return 0, 0, nil } row := s.eggSalesByPFK[projectFlockKandangIDs[0]] return row.pieces, row.kg, nil } func (s *hppV2RepoStub) GetTransferSourceSummary(_ context.Context, projectFlockKandangId uint) (uint, float64, error) { row := s.transferSummaryByPFK[projectFlockKandangId] return row.projectFlockID, row.totalQty, nil } func TestHppV2CalculateHppBreakdown_ComposesPakanSlices(t *testing.T) { repo := &hppV2RepoStub{ contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ 10: { ProjectFlockKandangID: 10, ProjectFlockID: 2, ProjectFlockCategory: "LAYING", KandangID: 100, KandangName: "Kandang A", LocationID: 16, }, }, pfkIDsByProject: map[uint][]uint{ 1: {101, 102}, }, usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{ stubKey([]uint{101, 102}, []string{"PAKAN"}): { {StockableType: "purchase_items", StockableID: 9001, SourceProductID: 8, SourceProductName: "Pakan Growing", Qty: 100, UnitPrice: 40, TotalCost: 4000}, }, stubKey([]uint{10}, []string{"PAKAN"}): { {StockableType: "purchase_items", StockableID: 9002, SourceProductID: 9, SourceProductName: "Pakan Laying", Qty: 50, UnitPrice: 30, TotalCost: 1500}, }, }, adjustRowsByKey: map[string][]commonRepo.HppV2AdjustmentCostRow{ stubKey([]uint{101, 102}, []string{"PAKAN-CUTOVER"}): { {AdjustmentID: 8001, ProductID: 11, ProductName: "Pakan Growing Cut-over", Qty: 20, Price: 30, GrandTotal: 600}, }, stubKey([]uint{10}, []string{"PAKAN-CUTOVER"}): { {AdjustmentID: 8002, ProductID: 12, ProductName: "Pakan Laying Cut-over", Qty: 10, Price: 30, GrandTotal: 300}, }, }, totalPopulationByKey: map[string]float64{ stubKey([]uint{101, 102}, nil): 1000, }, transferSummaryByPFK: map[uint]struct { projectFlockID uint totalQty float64 }{ 10: {projectFlockID: 1, totalQty: 250}, }, eggProductionByPFK: map[uint]struct { pieces float64 kg float64 }{ 10: {pieces: 100, kg: 10}, }, eggSalesByPFK: map[uint]struct { pieces float64 kg float64 }{ 10: {pieces: 40, kg: 4}, }, } svc := NewHppV2Service(repo) result, err := svc.CalculateHppBreakdown(10, mustDate(t, "2026-04-19")) if err != nil { t.Fatalf("expected no error, got %v", err) } if result == nil { t.Fatal("expected breakdown result") } if got := result.TotalPulletCost; got != 1150 { t.Fatalf("expected total pullet cost 1150, got %v", got) } if got := result.TotalProductionCost; got != 1800 { t.Fatalf("expected total production cost 1800, got %v", got) } if len(result.Components) != 1 { t.Fatalf("expected 1 component, got %d", len(result.Components)) } component := result.Components[0] if component.Code != "PAKAN" { t.Fatalf("expected PAKAN component, got %s", component.Code) } partTotals := map[string]float64{} for _, part := range component.Parts { partTotals[part.Code] = part.Total } if partTotals[hppV2PartGrowingNormal] != 1000 { t.Fatalf("expected growing normal 1000, got %v", partTotals[hppV2PartGrowingNormal]) } if partTotals[hppV2PartGrowingCutover] != 150 { t.Fatalf("expected growing cutover 150, got %v", partTotals[hppV2PartGrowingCutover]) } if partTotals[hppV2PartLayingNormal] != 1500 { t.Fatalf("expected laying normal 1500, got %v", partTotals[hppV2PartLayingNormal]) } if partTotals[hppV2PartLayingCutover] != 300 { t.Fatalf("expected laying cutover 300, got %v", partTotals[hppV2PartLayingCutover]) } if component.Parts[0].Proration == nil || component.Parts[0].Proration.Ratio != 0.25 { t.Fatalf("expected growing proration ratio 0.25, got %+v", component.Parts[0].Proration) } if result.Hpp.Estimation.HargaKg != 180 { t.Fatalf("expected estimation harga/kg 180, got %v", result.Hpp.Estimation.HargaKg) } if result.Hpp.Real.HargaKg != 450 { t.Fatalf("expected real harga/kg 450, got %v", result.Hpp.Real.HargaKg) } } func TestHppV2CalculateHppBreakdown_ManualCutoverUsesLayingSlicesOnly(t *testing.T) { repo := &hppV2RepoStub{ contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ 20: { ProjectFlockKandangID: 20, ProjectFlockID: 3, ProjectFlockCategory: "LAYING", KandangID: 200, KandangName: "Kandang B", LocationID: 17, }, }, usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{ stubKey([]uint{20}, []string{"PAKAN"}): { {StockableType: "purchase_items", StockableID: 9100, SourceProductID: 21, SourceProductName: "Pakan Laying", Qty: 20, UnitPrice: 10, TotalCost: 200}, }, }, adjustRowsByKey: map[string][]commonRepo.HppV2AdjustmentCostRow{ stubKey([]uint{20}, []string{"PAKAN-CUTOVER"}): { {AdjustmentID: 8100, ProductID: 22, ProductName: "Pakan Laying Cut-over", Qty: 30, Price: 10, GrandTotal: 300}, }, }, eggProductionByPFK: map[uint]struct { pieces float64 kg float64 }{ 20: {pieces: 50, kg: 5}, }, eggSalesByPFK: map[uint]struct { pieces float64 kg float64 }{ 20: {pieces: 25, kg: 2.5}, }, } svc := NewHppV2Service(repo) result, err := svc.CalculateHppBreakdown(20, mustDate(t, "2026-04-19")) if err != nil { t.Fatalf("expected no error, got %v", err) } if result.TotalProductionCost != 500 { t.Fatalf("expected total production cost 500, got %v", result.TotalProductionCost) } component := result.Components[0] if len(component.Parts) != 2 { t.Fatalf("expected 2 laying parts, got %d", len(component.Parts)) } for _, part := range component.Parts { if strings.HasPrefix(part.Code, "growing_") { t.Fatalf("expected no growing parts, got %s", part.Code) } } if result.Hpp.Estimation.HargaKg != 100 { t.Fatalf("expected estimation harga/kg 100, got %v", result.Hpp.Estimation.HargaKg) } } func TestHppV2CalculateHppBreakdown_IncludesOvkComponent(t *testing.T) { repo := &hppV2RepoStub{ contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ 30: { ProjectFlockKandangID: 30, ProjectFlockID: 4, ProjectFlockCategory: "LAYING", KandangID: 300, KandangName: "Kandang C", LocationID: 18, }, }, pfkIDsByProject: map[uint][]uint{ 5: {301, 302}, }, usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{ stubKey([]uint{30}, []string{"PAKAN"}): { {StockableType: "purchase_items", StockableID: 9200, SourceProductID: 31, SourceProductName: "Pakan Laying", Qty: 20, UnitPrice: 25, TotalCost: 500}, }, stubKey([]uint{301, 302}, []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia)}): { {StockableType: "purchase_items", StockableID: 9201, SourceProductID: 32, SourceProductName: "OVK Growing", Qty: 40, UnitPrice: 10, TotalCost: 400}, }, stubKey([]uint{30}, []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia)}): { {StockableType: "purchase_items", StockableID: 9202, SourceProductID: 33, SourceProductName: "OVK Laying", Qty: 15, UnitPrice: 10, TotalCost: 150}, }, }, adjustRowsByKey: map[string][]commonRepo.HppV2AdjustmentCostRow{ stubKey([]uint{301, 302}, []string{"OVK"}): { {AdjustmentID: 8201, ProductID: 34, ProductName: "OVK Growing Cut-over", Qty: 10, Price: 10, GrandTotal: 100}, }, stubKey([]uint{30}, []string{"OVK"}): { {AdjustmentID: 8202, ProductID: 35, ProductName: "OVK Laying Cut-over", Qty: 5, Price: 10, GrandTotal: 50}, }, }, totalPopulationByKey: map[string]float64{ stubKey([]uint{301, 302}, nil): 1000, }, transferSummaryByPFK: map[uint]struct { projectFlockID uint totalQty float64 }{ 30: {projectFlockID: 5, totalQty: 500}, }, eggProductionByPFK: map[uint]struct { pieces float64 kg float64 }{ 30: {pieces: 120, kg: 12}, }, eggSalesByPFK: map[uint]struct { pieces float64 kg float64 }{ 30: {pieces: 60, kg: 6}, }, } svc := NewHppV2Service(repo) result, err := svc.CalculateHppBreakdown(30, mustDate(t, "2026-04-19")) if err != nil { t.Fatalf("expected no error, got %v", err) } if result == nil { t.Fatal("expected breakdown result") } if len(result.Components) != 2 { t.Fatalf("expected 2 components, got %d", len(result.Components)) } componentTotals := map[string]float64{} for _, component := range result.Components { componentTotals[component.Code] = component.Total } if componentTotals[hppV2ComponentPakan] != 500 { t.Fatalf("expected pakan total 500, got %v", componentTotals[hppV2ComponentPakan]) } if componentTotals[hppV2ComponentOvk] != 450 { t.Fatalf("expected ovk total 450, got %v", componentTotals[hppV2ComponentOvk]) } if result.TotalPulletCost != 250 { t.Fatalf("expected total pullet cost 250, got %v", result.TotalPulletCost) } if result.TotalProductionCost != 700 { t.Fatalf("expected total production cost 700, got %v", result.TotalProductionCost) } if result.Hpp.Estimation.HargaKg != 58.33 { t.Fatalf("expected estimation harga/kg 58.33, got %v", result.Hpp.Estimation.HargaKg) } } func TestHppV2CalculateHppBreakdown_IncludesDocAndDirectPulletChickin(t *testing.T) { repo := &hppV2RepoStub{ contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ 35: { ProjectFlockKandangID: 35, ProjectFlockID: 8, ProjectFlockCategory: "LAYING", KandangID: 350, KandangName: "Kandang E", LocationID: 20, }, }, pfkIDsByProject: map[uint][]uint{ 9: {901, 902}, }, totalPopulationByKey: map[string]float64{ stubKey([]uint{901, 902}, nil): 1000, }, transferSummaryByPFK: map[uint]struct { projectFlockID uint totalQty float64 }{ 35: {projectFlockID: 9, totalQty: 250}, }, chickinRowsByKey: map[string][]commonRepo.HppV2ChickinCostRow{ chickinStubKey([]uint{901, 902}, []string{string(utils.FlagDOC)}, false): { {ProjectChickinID: 1, ProjectFlockKandangID: 901, ChickInDate: mustTime(t, "2026-04-01"), StockableType: "purchase_items", StockableID: 1001, SourceProductID: 77, SourceProductName: "DOC", Qty: 1000, UnitPrice: 2, TotalCost: 2000}, }, chickinStubKey([]uint{35}, []string{string(utils.FlagPullet), string(utils.FlagLayer)}, true): { {ProjectChickinID: 2, ProjectFlockKandangID: 35, ChickInDate: mustTime(t, "2026-04-15"), StockableType: "purchase_items", StockableID: 1002, SourceProductID: 78, SourceProductName: "Pullet", Qty: 50, UnitPrice: 20, TotalCost: 1000}, }, }, eggProductionByPFK: map[uint]struct { pieces float64 kg float64 }{ 35: {pieces: 100, kg: 10}, }, eggSalesByPFK: map[uint]struct { pieces float64 kg float64 }{ 35: {pieces: 80, kg: 8}, }, } svc := NewHppV2Service(repo) result, err := svc.CalculateHppBreakdown(35, mustDate(t, "2026-04-19")) if err != nil { t.Fatalf("expected no error, got %v", err) } componentTotals := map[string]float64{} for _, component := range result.Components { componentTotals[component.Code] = component.Total } if componentTotals[hppV2ComponentDocChickin] != 500 { t.Fatalf("expected doc chickin total 500, got %v", componentTotals[hppV2ComponentDocChickin]) } if componentTotals[hppV2ComponentDirectPulletPurchase] != 1000 { t.Fatalf("expected direct pullet purchase total 1000, got %v", componentTotals[hppV2ComponentDirectPulletPurchase]) } if result.TotalPulletCost != 500 { t.Fatalf("expected total pullet cost 500, got %v", result.TotalPulletCost) } if result.TotalProductionCost != 1000 { t.Fatalf("expected total production cost 1000, got %v", result.TotalProductionCost) } if result.Hpp.Estimation.HargaKg != 100 { t.Fatalf("expected estimation harga/kg 100, got %v", result.Hpp.Estimation.HargaKg) } } func TestHppV2CalculateHppBreakdown_IncludesBopRegularAndEkspedisi(t *testing.T) { repo := &hppV2RepoStub{ contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ 40: { ProjectFlockKandangID: 40, ProjectFlockID: 6, ProjectFlockCategory: "LAYING", KandangID: 400, KandangName: "Kandang D", LocationID: 19, }, }, pfkIDsByProject: map[uint][]uint{ 6: {40, 41}, 7: {701, 702}, }, totalPopulationByKey: map[string]float64{ stubKey([]uint{701, 702}, nil): 1000, }, transferSummaryByPFK: map[uint]struct { projectFlockID uint totalQty float64 }{ 40: {projectFlockID: 7, totalQty: 200}, }, expenseRowsByPFKKey: map[string][]commonRepo.HppV2ExpenseCostRow{ expenseStubKey([]uint{701, 702}, false): { {ExpenseRealizationID: 1, NonstockID: 11, NonstockName: "Growing BOP", Qty: 1, Price: 500, TotalCost: 500, RealizationDate: mustTime(t, "2026-04-10")}, }, expenseStubKey([]uint{40}, false): { {ExpenseRealizationID: 2, NonstockID: 12, NonstockName: "Laying BOP", Qty: 1, Price: 80, TotalCost: 80, RealizationDate: mustTime(t, "2026-04-19")}, }, expenseStubKey([]uint{701, 702}, true): { {ExpenseRealizationID: 3, NonstockID: 13, NonstockName: "Growing Expedition", Qty: 1, Price: 100, TotalCost: 100, RealizationDate: mustTime(t, "2026-04-11")}, }, expenseStubKey([]uint{40}, true): { {ExpenseRealizationID: 4, NonstockID: 14, NonstockName: "Laying Expedition", Qty: 1, Price: 40, TotalCost: 40, RealizationDate: mustTime(t, "2026-04-19")}, }, }, expenseRowsByFarmKey: map[string][]commonRepo.HppV2ExpenseCostRow{ expenseFarmKey(7, false): { {ExpenseRealizationID: 5, NonstockID: 15, NonstockName: "Growing Farm BOP", Qty: 1, Price: 300, TotalCost: 300, RealizationDate: mustTime(t, "2026-04-12")}, }, expenseFarmKey(6, false): { {ExpenseRealizationID: 6, NonstockID: 16, NonstockName: "Laying Farm BOP", Qty: 1, Price: 100, TotalCost: 100, RealizationDate: mustTime(t, "2026-04-19")}, }, expenseFarmKey(7, true): { {ExpenseRealizationID: 7, NonstockID: 17, NonstockName: "Growing Farm Expedition", Qty: 1, Price: 50, TotalCost: 50, RealizationDate: mustTime(t, "2026-04-12")}, }, expenseFarmKey(6, true): { {ExpenseRealizationID: 8, NonstockID: 18, NonstockName: "Laying Farm Expedition", Qty: 1, Price: 60, TotalCost: 60, RealizationDate: mustTime(t, "2026-04-19")}, }, }, eggProductionByPFK: map[uint]struct { pieces float64 kg float64 }{ 40: {pieces: 30, kg: 3}, 41: {pieces: 70, kg: 7}, }, eggSalesByPFK: map[uint]struct { pieces float64 kg float64 }{ 40: {pieces: 50, kg: 5}, }, } svc := NewHppV2Service(repo) result, err := svc.CalculateHppBreakdown(40, mustDate(t, "2026-04-19")) if err != nil { t.Fatalf("expected no error, got %v", err) } componentTotals := map[string]float64{} for _, component := range result.Components { componentTotals[component.Code] = component.Total } if componentTotals[hppV2ComponentBopRegular] != 270 { t.Fatalf("expected regular BOP total 270, got %v", componentTotals[hppV2ComponentBopRegular]) } if componentTotals[hppV2ComponentBopEksp] != 88 { t.Fatalf("expected expedition BOP total 88, got %v", componentTotals[hppV2ComponentBopEksp]) } if result.TotalPulletCost != 190 { t.Fatalf("expected total pullet cost 190, got %v", result.TotalPulletCost) } if result.TotalProductionCost != 168 { t.Fatalf("expected total production cost 168, got %v", result.TotalProductionCost) } if result.Hpp.Estimation.HargaKg != 56 { t.Fatalf("expected estimation harga/kg 56, got %v", result.Hpp.Estimation.HargaKg) } } func TestHppV2CalculateHppBreakdown_AddsDepreciationForNormalTransfer(t *testing.T) { sourceChickIn := mustTime(t, "2026-01-01") reportDate := sourceChickIn.AddDate(0, 0, 154) repo := &hppV2RepoStub{ contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ 50: { ProjectFlockKandangID: 50, ProjectFlockID: 10, ProjectFlockCategory: "LAYING", KandangID: 500, KandangName: "Kandang F", LocationID: 21, HouseType: "close_house", }, }, pfkIDsByProject: map[uint][]uint{ 11: {501}, }, latestTransferByPFK: map[uint]*commonRepo.HppV2LatestTransferInputRow{ 50: { ProjectFlockKandangID: 50, SourceProjectFlockID: 11, TransferDate: mustTime(t, "2026-05-20"), TransferQty: 100, TransferID: 701, }, }, chickInDateByProject: map[uint]*time.Time{ 11: &sourceChickIn, }, depreciationByHouse: map[string]map[int]float64{ "close_house": { 1: 10, }, }, usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{ stubKey([]uint{501}, []string{"PAKAN"}): { {StockableType: "purchase_items", StockableID: 9301, SourceProductID: 41, SourceProductName: "Pakan Growing", Qty: 25, UnitPrice: 40, TotalCost: 1000}, }, }, totalPopulationByKey: map[string]float64{ stubKey([]uint{501}, nil): 100, }, transferSummaryByPFK: map[uint]struct { projectFlockID uint totalQty float64 }{ 50: {projectFlockID: 11, totalQty: 100}, }, eggProductionByPFK: map[uint]struct { pieces float64 kg float64 }{ 50: {pieces: 20, kg: 10}, }, } svc := NewHppV2Service(repo) result, err := svc.CalculateHppBreakdown(50, &reportDate) if err != nil { t.Fatalf("expected no error, got %v", err) } if result.TotalPulletCost != 1000 { t.Fatalf("expected total pullet cost 1000, got %v", result.TotalPulletCost) } if result.TotalProductionCost != 100 { t.Fatalf("expected total production cost 100, got %v", result.TotalProductionCost) } var depreciation *HppV2Component for i := range result.Components { if result.Components[i].Code == hppV2ComponentDepreciation { depreciation = &result.Components[i] break } } if depreciation == nil { t.Fatal("expected depreciation component") } if depreciation.Total != 100 { t.Fatalf("expected depreciation total 100, got %v", depreciation.Total) } if len(depreciation.Parts) != 1 { t.Fatalf("expected single depreciation part, got %d", len(depreciation.Parts)) } if depreciation.Parts[0].Details["schedule_day"] != 1 { t.Fatalf("expected schedule day 1, got %+v", depreciation.Parts[0].Details) } if depreciation.Parts[0].Details["origin_date"] != "2026-01-01" { t.Fatalf("expected origin date 2026-01-01, got %+v", depreciation.Parts[0].Details) } if result.Hpp.Estimation.HargaKg != 10 { t.Fatalf("expected estimation harga/kg 10, got %v", result.Hpp.Estimation.HargaKg) } } func TestHppV2CalculateHppBreakdown_AddsDepreciationForManualCutoverFromCutoverDate(t *testing.T) { originDate := mustTime(t, "2026-01-01") cutoverDate := originDate.AddDate(0, 0, 155) repo := &hppV2RepoStub{ contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ 60: { ProjectFlockKandangID: 60, ProjectFlockID: 12, ProjectFlockCategory: "LAYING", KandangID: 600, KandangName: "Kandang G", LocationID: 22, HouseType: "close_house", }, }, pfkIDsByProject: map[uint][]uint{ 12: {60}, }, manualInputByProject: map[uint]*commonRepo.HppV2ManualDepreciationInputRow{ 12: { ID: 801, ProjectFlockID: 12, TotalCost: 1000, CutoverDate: cutoverDate, }, }, chickInDateByProject: map[uint]*time.Time{ 12: &originDate, }, depreciationByHouse: map[string]map[int]float64{ "close_house": { 1: 10, 2: 20, }, }, totalPopulationByKey: map[string]float64{ stubKey([]uint{60}, nil): 100, }, eggProductionByPFK: map[uint]struct { pieces float64 kg float64 }{ 60: {pieces: 20, kg: 10}, }, } svc := NewHppV2Service(repo) result, err := svc.CalculateHppBreakdown(60, &cutoverDate) if err != nil { t.Fatalf("expected no error, got %v", err) } if result.TotalPulletCost != 1000 { t.Fatalf("expected total pullet cost 1000, got %v", result.TotalPulletCost) } if result.TotalProductionCost != 200 { t.Fatalf("expected total production cost 200, got %v", result.TotalProductionCost) } componentTotals := map[string]float64{} for _, component := range result.Components { componentTotals[component.Code] = component.Total } if componentTotals[hppV2ComponentManualPulletCost] != 1000 { t.Fatalf("expected manual pullet cost 1000, got %v", componentTotals[hppV2ComponentManualPulletCost]) } if componentTotals[hppV2ComponentDepreciation] != 200 { t.Fatalf("expected depreciation 200, got %v", componentTotals[hppV2ComponentDepreciation]) } var depreciation *HppV2Component for i := range result.Components { if result.Components[i].Code == hppV2ComponentDepreciation { depreciation = &result.Components[i] break } } if depreciation == nil || len(depreciation.Parts) != 1 { t.Fatalf("expected one depreciation part, got %+v", depreciation) } if depreciation.Parts[0].Details["schedule_day"] != 2 { t.Fatalf("expected schedule day 2, got %+v", depreciation.Parts[0].Details) } if depreciation.Parts[0].Details["start_schedule_day"] != 2 { t.Fatalf("expected start schedule day 2, got %+v", depreciation.Parts[0].Details) } if result.Hpp.Estimation.HargaKg != 20 { t.Fatalf("expected estimation harga/kg 20, got %v", result.Hpp.Estimation.HargaKg) } } func TestHppV2CalculateHppBreakdown_UsesFarmSnapshotDepreciationProratedByEggProduction(t *testing.T) { reportDate := mustTime(t, "2026-06-05") repo := &hppV2RepoStub{ contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ 70: { ProjectFlockKandangID: 70, ProjectFlockID: 15, ProjectFlockCategory: "LAYING", KandangID: 700, KandangName: "Kandang Snapshot", LocationID: 25, HouseType: "close_house", }, }, pfkIDsByProject: map[uint][]uint{ 15: {70, 71}, }, snapshotByProjectKey: map[string]*commonRepo.HppV2FarmDepreciationSnapshotRow{ "15|2026-06-05": { ID: 901, ProjectFlockID: 15, PeriodDate: reportDate, DepreciationPercentEffective: 10, DepreciationValue: 1000, PulletCostDayNTotal: 10000, }, }, eggProductionByPFK: map[uint]struct { pieces float64 kg float64 }{ 70: {pieces: 200, kg: 20}, 71: {pieces: 800, kg: 80}, }, } svc := NewHppV2Service(repo) result, err := svc.CalculateHppBreakdown(70, &reportDate) if err != nil { t.Fatalf("expected no error, got %v", err) } if result == nil { t.Fatal("expected breakdown result") } var depreciation *HppV2Component for i := range result.Components { if result.Components[i].Code == hppV2ComponentDepreciation { depreciation = &result.Components[i] break } } if depreciation == nil { t.Fatal("expected depreciation component") } if depreciation.Total != 200 { t.Fatalf("expected depreciation total 200, got %v", depreciation.Total) } if result.TotalProductionCost != 200 { t.Fatalf("expected total production cost 200, got %v", result.TotalProductionCost) } if len(depreciation.Parts) != 1 { t.Fatalf("expected one depreciation part, got %d", len(depreciation.Parts)) } if depreciation.Parts[0].Code != hppV2PartDepreciationFarmSnapshot { t.Fatalf("expected farm snapshot depreciation part, got %s", depreciation.Parts[0].Code) } if depreciation.Parts[0].Proration == nil || depreciation.Parts[0].Proration.Ratio != 0.2 { t.Fatalf("expected proration ratio 0.2, got %+v", depreciation.Parts[0].Proration) } if depreciation.Parts[0].Details["snapshot_id"] != uint(901) { t.Fatalf("expected snapshot id 901, got %+v", depreciation.Parts[0].Details) } } func stubKey(ids []uint, flags []string) string { idParts := make([]string, 0, len(ids)) for _, id := range ids { idParts = append(idParts, fmt.Sprintf("%d", id)) } sort.Strings(idParts) flagParts := append([]string{}, flags...) sort.Strings(flagParts) return strings.Join(idParts, ",") + "|" + strings.Join(flagParts, ",") } func mustDate(t *testing.T, raw string) *time.Time { t.Helper() loc, err := time.LoadLocation("Asia/Jakarta") if err != nil { t.Fatalf("failed to load timezone: %v", err) } value, err := time.ParseInLocation("2006-01-02", raw, loc) if err != nil { t.Fatalf("failed to parse date %s: %v", raw, err) } return &value } func mustTime(t *testing.T, raw string) time.Time { t.Helper() value := mustDate(t, raw) return *value } func expenseStubKey(ids []uint, ekspedisi bool) string { return stubKey(ids, []string{fmt.Sprintf("ekspedisi=%t", ekspedisi)}) } func expenseFarmKey(projectFlockID uint, ekspedisi bool) string { return fmt.Sprintf("farm=%d|ekspedisi=%t", projectFlockID, ekspedisi) } func chickinStubKey(ids []uint, flags []string, excludeTransferToLaying bool) string { return stubKey(ids, append(append([]string{}, flags...), fmt.Sprintf("exclude_transfer_to_laying=%t", excludeTransferToLaying))) }