mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-06-09 15:07:49 +00:00
adjust hpp per farm query to take feed and ovk
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 = "<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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user