mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-06-09 15:07:49 +00:00
1023 lines
37 KiB
Go
1023 lines
37 KiB
Go
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
|
||
// expenseRowsByFarmDateKey (opsional) membuat ListExpenseRealizationRowsByProjectFlockID
|
||
// date-aware untuk menguji perhitungan range BOP. Bila non-nil, dipakai menggantikan
|
||
// expenseRowsByFarmKey; key = "<flock>|<ekspedisi>|<YYYY-MM-DD>".
|
||
expenseRowsByFarmDateKey map[string][]commonRepo.HppV2ExpenseCostRow
|
||
routeCostByProject map[uint]float64
|
||
totalPopulationByKey map[string]float64
|
||
transferSummaryByPFK map[uint]struct {
|
||
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) GetAllTransferInputsByProjectFlockKandangID(_ context.Context, projectFlockKandangId uint, _ time.Time) ([]commonRepo.HppV2LatestTransferInputRow, error) {
|
||
row := s.latestTransferByPFK[projectFlockKandangId]
|
||
if row == nil {
|
||
return []commonRepo.HppV2LatestTransferInputRow{}, nil
|
||
}
|
||
return []commonRepo.HppV2LatestTransferInputRow{*row}, nil
|
||
}
|
||
|
||
func (s *hppV2RepoStub) GetManualDepreciationInputByProjectFlockID(_ context.Context, projectFlockID uint) (*commonRepo.HppV2ManualDepreciationInputRow, error) {
|
||
return s.manualInputByProject[projectFlockID], nil
|
||
}
|
||
|
||
func (s *hppV2RepoStub) GetRecordingStockRoutingAdjustmentCostByProjectFlockID(_ context.Context, projectFlockID uint, _ time.Time) (float64, error) {
|
||
return s.routeCostByProject[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
|
||
}
|
||
|
||
// GetMultiplicationPercentages — alias yang sama dengan GetDepreciationPercents untuk match
|
||
// interface HppV2CostRepository (interface dipakai method name baru ini).
|
||
func (s *hppV2RepoStub) GetMultiplicationPercentages(ctx context.Context, houseTypes []string, maxDay int, _ uint) (map[string]map[int]float64, map[string]*time.Time, error) {
|
||
vals, err := s.GetDepreciationPercents(ctx, houseTypes, maxDay)
|
||
return vals, make(map[string]*time.Time), err
|
||
}
|
||
|
||
// GetChickinPopulationByPFKForFarm — return populasi per PFK dari satu project flock.
|
||
// Stub minimal: return empty map (depreciation manual cutover tidak di-test di sini).
|
||
func (s *hppV2RepoStub) GetChickinPopulationByPFKForFarm(_ context.Context, _ uint) (map[uint]float64, error) {
|
||
return map[uint]float64{}, 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) 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
|
||
}
|
||
|
||
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, 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
|
||
}
|
||
|
||
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) GetEggProduksiBreakdownByProjectFlockKandangIds(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time) (float64, float64, 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, 0, 0, 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 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)
|
||
}
|
||
}
|