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 usageRowsByKey map[string][]commonRepo.HppV2UsageCostRow adjustRowsByKey map[string][]commonRepo.HppV2AdjustmentCostRow 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) 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) 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.TotalProductionCost; got != 2950 { t.Fatalf("expected total production cost 2950, 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 != 295 { t.Fatalf("expected estimation harga/kg 295, got %v", result.Hpp.Estimation.HargaKg) } if result.Hpp.Real.HargaKg != 737.5 { t.Fatalf("expected real harga/kg 737.5, 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-CUTOVER"}): { {AdjustmentID: 8201, ProductID: 34, ProductName: "OVK Growing Cut-over", Qty: 10, Price: 10, GrandTotal: 100}, }, stubKey([]uint{30}, []string{"OVK-CUTOVER"}): { {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.TotalProductionCost != 950 { t.Fatalf("expected total production cost 950, got %v", result.TotalProductionCost) } if result.Hpp.Estimation.HargaKg != 79.17 { t.Fatalf("expected estimation harga/kg 79.17, 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.TotalProductionCost != 358 { t.Fatalf("expected total production cost 358, got %v", result.TotalProductionCost) } if result.Hpp.Estimation.HargaKg != 119.33 { t.Fatalf("expected estimation harga/kg 119.33, got %v", result.Hpp.Estimation.HargaKg) } } 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) }