feat: reimplement with plan hppv2 flow and logics

This commit is contained in:
Adnan Zahir
2026-04-19 14:06:42 +07:00
parent 187e497f97
commit 58fbceea24
9 changed files with 1864 additions and 52 deletions
@@ -0,0 +1,56 @@
package service
type HppV2DateWindow struct {
Start string `json:"start"`
End string `json:"end"`
}
type HppV2Proration struct {
Basis string `json:"basis"`
Numerator float64 `json:"numerator"`
Denominator float64 `json:"denominator"`
Ratio float64 `json:"ratio"`
}
type HppV2Reference struct {
Type string `json:"type"`
ID uint `json:"id"`
StockableType string `json:"stockable_type,omitempty"`
ProjectFlockKandangID *uint `json:"project_flock_kandang_id,omitempty"`
ProductID uint `json:"product_id,omitempty"`
ProductName string `json:"product_name,omitempty"`
Date string `json:"date,omitempty"`
Qty float64 `json:"qty"`
UnitPrice float64 `json:"unit_price"`
Total float64 `json:"total"`
AppliedTotal float64 `json:"applied_total"`
}
type HppV2ComponentPart struct {
Code string `json:"code"`
Title string `json:"title"`
Total float64 `json:"total"`
Proration *HppV2Proration `json:"proration,omitempty"`
References []HppV2Reference `json:"references,omitempty"`
}
type HppV2Component struct {
Code string `json:"code"`
Title string `json:"title"`
Total float64 `json:"total"`
Parts []HppV2ComponentPart `json:"parts"`
}
type HppV2Breakdown struct {
ProjectFlockKandangID uint `json:"project_flock_kandang_id"`
ProjectFlockID uint `json:"project_flock_id"`
ProjectFlockCategory string `json:"project_flock_category,omitempty"`
KandangID uint `json:"kandang_id,omitempty"`
KandangName string `json:"kandang_name,omitempty"`
LocationID uint `json:"location_id,omitempty"`
PeriodDate string `json:"period_date"`
Window HppV2DateWindow `json:"window"`
TotalProductionCost float64 `json:"total_production_cost"`
Components []HppV2Component `json:"components"`
Hpp HppCostResponse `json:"hpp"`
}
+707 -43
View File
@@ -5,13 +5,40 @@ import (
"time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
const (
hppV2ComponentPakan = "PAKAN"
hppV2ComponentOvk = "OVK"
hppV2ComponentBopRegular = "BOP_REGULAR"
hppV2ComponentBopEksp = "BOP_EKSPEDISI"
hppV2PartGrowingNormal = "growing_normal"
hppV2PartGrowingCutover = "growing_cutover"
hppV2PartLayingNormal = "laying_normal"
hppV2PartLayingCutover = "laying_cutover"
hppV2PartGrowingDirect = "growing_direct"
hppV2PartGrowingFarm = "growing_farm"
hppV2PartLayingDirect = "laying_direct"
hppV2PartLayingFarm = "laying_farm"
hppV2ProrationPopulation = "growing_population_share"
hppV2ProrationEggWeight = "laying_egg_weight_share"
hppV2ProrationEggPiece = "laying_egg_piece_share"
hppV2CutoverFlagPakan = "PAKAN-CUTOVER"
hppV2CutoverFlagOvk = "OVK-CUTOVER"
)
type HppV2Service interface {
CalculateHppCost(projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error)
CalculateHppBreakdown(projectFlockKandangId uint, date *time.Time) (*HppV2Breakdown, error)
GetCostPakan(projectFlockKandangId uint, endDate *time.Time) (float64, error)
GetFeedGrowing(projectFlockKandangId uint, endDate *time.Time) (float64, error)
GetFeedLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error)
GetCostOvk(projectFlockKandangId uint, endDate *time.Time) (float64, error)
GetCostBopRegular(projectFlockKandangId uint, endDate *time.Time) (float64, error)
GetCostBopEkspedisi(projectFlockKandangId uint, endDate *time.Time) (float64, error)
GetPakanBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error)
GetOvkBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error)
GetBopRegularBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error)
GetBopEkspedisiBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error)
GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error)
}
@@ -19,101 +46,676 @@ type hppV2Service struct {
hppRepo commonRepo.HppV2CostRepository
}
type hppV2StockComponentConfig struct {
Code string
Title string
NormalFlags []string
CutoverFlags []string
}
type hppV2ExpenseComponentConfig struct {
Code string
Title string
Ekspedisi bool
}
func NewHppV2Service(hppRepo commonRepo.HppV2CostRepository) HppV2Service {
return &hppV2Service{hppRepo: hppRepo}
}
func (s *hppV2Service) CalculateHppCost(projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error) {
if date == nil {
now := time.Now()
date = &now
breakdown, err := s.CalculateHppBreakdown(projectFlockKandangId, date)
if err != nil {
return nil, err
}
if breakdown == nil {
return &HppCostResponse{}, nil
}
location, err := time.LoadLocation("Asia/Jakarta")
result := breakdown.Hpp
return &result, nil
}
func (s *hppV2Service) CalculateHppBreakdown(projectFlockKandangId uint, date *time.Time) (*HppV2Breakdown, error) {
if s.hppRepo == nil {
return &HppV2Breakdown{
ProjectFlockKandangID: projectFlockKandangId,
Hpp: HppCostResponse{},
}, nil
}
startOfDay, endOfDay, err := hppV2DayWindow(date)
if err != nil {
return nil, err
}
startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location)
endOfDay := startOfDay.Add(24 * time.Hour)
pakan, err := s.GetCostPakan(projectFlockKandangId, &endOfDay)
contextRow, err := s.hppRepo.GetProjectFlockKandangContext(context.Background(), projectFlockKandangId)
if err != nil {
return nil, err
}
result, err := s.GetHppEstimationDanRealisasi(pakan, projectFlockKandangId, &startOfDay, &endOfDay)
pakanComponent, err := s.GetPakanBreakdown(projectFlockKandangId, &endOfDay)
if err != nil {
return nil, err
}
return result, nil
totalProductionCost := 0.0
components := make([]HppV2Component, 0, 4)
if pakanComponent != nil && (pakanComponent.Total > 0 || len(pakanComponent.Parts) > 0) {
totalProductionCost += pakanComponent.Total
components = append(components, *pakanComponent)
}
ovkComponent, err := s.GetOvkBreakdown(projectFlockKandangId, &endOfDay)
if err != nil {
return nil, err
}
if ovkComponent != nil && (ovkComponent.Total > 0 || len(ovkComponent.Parts) > 0) {
totalProductionCost += ovkComponent.Total
components = append(components, *ovkComponent)
}
bopRegularComponent, err := s.GetBopRegularBreakdown(projectFlockKandangId, &endOfDay)
if err != nil {
return nil, err
}
if bopRegularComponent != nil && (bopRegularComponent.Total > 0 || len(bopRegularComponent.Parts) > 0) {
totalProductionCost += bopRegularComponent.Total
components = append(components, *bopRegularComponent)
}
bopEkspedisiComponent, err := s.GetBopEkspedisiBreakdown(projectFlockKandangId, &endOfDay)
if err != nil {
return nil, err
}
if bopEkspedisiComponent != nil && (bopEkspedisiComponent.Total > 0 || len(bopEkspedisiComponent.Parts) > 0) {
totalProductionCost += bopEkspedisiComponent.Total
components = append(components, *bopEkspedisiComponent)
}
hppCost, err := s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, &startOfDay, &endOfDay)
if err != nil {
return nil, err
}
if hppCost == nil {
hppCost = &HppCostResponse{}
}
return &HppV2Breakdown{
ProjectFlockKandangID: projectFlockKandangId,
ProjectFlockID: contextRow.ProjectFlockID,
ProjectFlockCategory: contextRow.ProjectFlockCategory,
KandangID: contextRow.KandangID,
KandangName: contextRow.KandangName,
LocationID: contextRow.LocationID,
PeriodDate: startOfDay.Format("2006-01-02"),
Window: HppV2DateWindow{
Start: startOfDay.Format(time.RFC3339),
End: endOfDay.Format(time.RFC3339),
},
TotalProductionCost: totalProductionCost,
Components: components,
Hpp: *hppCost,
}, nil
}
func (s *hppV2Service) GetCostPakan(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
feedGrowing, err := s.GetFeedGrowing(projectFlockKandangId, endDate)
component, err := s.GetPakanBreakdown(projectFlockKandangId, endDate)
if err != nil {
return 0, err
}
feedLaying, err := s.GetFeedLaying(projectFlockKandangId, endDate)
if err != nil {
return 0, err
if component == nil {
return 0, nil
}
pakan := feedGrowing + feedLaying
return pakan, nil
return component.Total, nil
}
func (s *hppV2Service) GetFeedGrowing(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
if s.hppRepo == nil {
func (s *hppV2Service) GetPakanBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) {
return s.getStockUsageComponent(projectFlockKandangId, endDate, hppV2StockComponentConfig{
Code: hppV2ComponentPakan,
Title: "Pakan",
NormalFlags: []string{string(utils.FlagPakan)},
CutoverFlags: []string{hppV2CutoverFlagPakan},
})
}
func (s *hppV2Service) GetCostOvk(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
component, err := s.GetOvkBreakdown(projectFlockKandangId, endDate)
if err != nil {
return 0, err
}
if component == nil {
return 0, nil
}
return component.Total, nil
}
func (s *hppV2Service) GetOvkBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) {
return s.getStockUsageComponent(projectFlockKandangId, endDate, hppV2StockComponentConfig{
Code: hppV2ComponentOvk,
Title: "OVK",
NormalFlags: []string{
string(utils.FlagOVK),
string(utils.FlagObat),
string(utils.FlagVitamin),
string(utils.FlagKimia),
},
CutoverFlags: []string{hppV2CutoverFlagOvk},
})
}
func (s *hppV2Service) GetCostBopRegular(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
component, err := s.GetBopRegularBreakdown(projectFlockKandangId, endDate)
if err != nil {
return 0, err
}
if component == nil {
return 0, nil
}
return component.Total, nil
}
func (s *hppV2Service) GetCostBopEkspedisi(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
component, err := s.GetBopEkspedisiBreakdown(projectFlockKandangId, endDate)
if err != nil {
return 0, err
}
if component == nil {
return 0, nil
}
return component.Total, nil
}
func (s *hppV2Service) GetBopRegularBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) {
return s.getExpenseComponent(projectFlockKandangId, endDate, hppV2ExpenseComponentConfig{
Code: hppV2ComponentBopRegular,
Title: "BOP Regular",
Ekspedisi: false,
})
}
func (s *hppV2Service) GetBopEkspedisiBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) {
return s.getExpenseComponent(projectFlockKandangId, endDate, hppV2ExpenseComponentConfig{
Code: hppV2ComponentBopEksp,
Title: "BOP Ekspedisi",
Ekspedisi: true,
})
}
func (s *hppV2Service) getStockUsageComponent(projectFlockKandangId uint, endDate *time.Time, config hppV2StockComponentConfig) (*HppV2Component, error) {
if s.hppRepo == nil {
return &HppV2Component{
Code: config.Code,
Title: config.Title,
Parts: []HppV2ComponentPart{},
}, nil
}
contextRow, err := s.hppRepo.GetProjectFlockKandangContext(context.Background(), projectFlockKandangId)
if err != nil {
return nil, err
}
parts := make([]HppV2ComponentPart, 0, 4)
total := 0.0
growingPart, err := s.buildGrowingUsagePart(projectFlockKandangId, contextRow, endDate, config, false)
if err != nil {
return nil, err
}
if growingPart != nil {
parts = append(parts, *growingPart)
total += growingPart.Total
}
growingCutoverPart, err := s.buildGrowingUsagePart(projectFlockKandangId, contextRow, endDate, config, true)
if err != nil {
return nil, err
}
if growingCutoverPart != nil {
parts = append(parts, *growingCutoverPart)
total += growingCutoverPart.Total
}
layingNormalPart, err := s.buildLayingUsagePart(projectFlockKandangId, endDate, config, false)
if err != nil {
return nil, err
}
if layingNormalPart != nil {
parts = append(parts, *layingNormalPart)
total += layingNormalPart.Total
}
layingCutoverPart, err := s.buildLayingUsagePart(projectFlockKandangId, endDate, config, true)
if err != nil {
return nil, err
}
if layingCutoverPart != nil {
parts = append(parts, *layingCutoverPart)
total += layingCutoverPart.Total
}
return &HppV2Component{
Code: config.Code,
Title: config.Title,
Total: total,
Parts: parts,
}, nil
}
func (s *hppV2Service) getExpenseComponent(projectFlockKandangId uint, endDate *time.Time, config hppV2ExpenseComponentConfig) (*HppV2Component, error) {
if s.hppRepo == nil {
return &HppV2Component{
Code: config.Code,
Title: config.Title,
Parts: []HppV2ComponentPart{},
}, nil
}
contextRow, err := s.hppRepo.GetProjectFlockKandangContext(context.Background(), projectFlockKandangId)
if err != nil {
return nil, err
}
parts := make([]HppV2ComponentPart, 0, 4)
total := 0.0
growingDirect, err := s.buildGrowingExpenseDirectPart(projectFlockKandangId, contextRow, endDate, config)
if err != nil {
return nil, err
}
if growingDirect != nil {
parts = append(parts, *growingDirect)
total += growingDirect.Total
}
growingFarm, err := s.buildGrowingExpenseFarmPart(projectFlockKandangId, contextRow, endDate, config)
if err != nil {
return nil, err
}
if growingFarm != nil {
parts = append(parts, *growingFarm)
total += growingFarm.Total
}
layingDirect, err := s.buildLayingExpenseDirectPart(projectFlockKandangId, endDate, config)
if err != nil {
return nil, err
}
if layingDirect != nil {
parts = append(parts, *layingDirect)
total += layingDirect.Total
}
layingFarm, err := s.buildLayingExpenseFarmPart(projectFlockKandangId, contextRow, endDate, config)
if err != nil {
return nil, err
}
if layingFarm != nil {
parts = append(parts, *layingFarm)
total += layingFarm.Total
}
return &HppV2Component{
Code: config.Code,
Title: config.Title,
Total: total,
Parts: parts,
}, nil
}
func (s *hppV2Service) buildGrowingUsagePart(
projectFlockKandangId uint,
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
endDate *time.Time,
config hppV2StockComponentConfig,
cutover bool,
) (*HppV2ComponentPart, error) {
if contextRow == nil {
return nil, nil
}
sourceProjectFlockID, transferTotalQty, err := s.hppRepo.GetTransferSourceSummary(context.Background(), projectFlockKandangId)
if err != nil {
return 0, err
return nil, err
}
if sourceProjectFlockID == 0 || transferTotalQty <= 0 {
return 0, nil
return nil, nil
}
kandangIDsGrowing, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID)
if err != nil {
return 0, err
return nil, err
}
if len(kandangIDsGrowing) == 0 {
return 0, nil
}
feedUsageCostGrowing, err := s.hppRepo.GetFeedUsageCost(context.Background(), kandangIDsGrowing, endDate)
if err != nil {
return 0, err
return nil, nil
}
totalPopulationFlockGrowing, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDsGrowing)
if err != nil {
return 0, err
return nil, err
}
if totalPopulationFlockGrowing == 0 {
return 0, nil
return nil, nil
}
result := feedUsageCostGrowing * (transferTotalQty / totalPopulationFlockGrowing)
return result, nil
ratio := transferTotalQty / totalPopulationFlockGrowing
if ratio <= 0 {
return nil, nil
}
partCode := hppV2PartGrowingNormal
partTitle := "Growing"
baseRows := make([]HppV2Reference, 0)
baseTotal := 0.0
if cutover {
partCode = hppV2PartGrowingCutover
partTitle = "Growing Cut-over"
rows, err := s.hppRepo.ListAdjustmentCostRowsByProductFlags(context.Background(), kandangIDsGrowing, config.CutoverFlags, endDate)
if err != nil {
return nil, err
}
for _, row := range rows {
rowTotal := adjustmentRowTotalCost(row)
baseTotal += rowTotal
baseRows = append(baseRows, HppV2Reference{
Type: "adjustment_stock",
ID: row.AdjustmentID,
ProjectFlockKandangID: row.ProjectFlockKandangID,
ProductID: row.ProductID,
ProductName: row.ProductName,
Date: row.CreatedAt.Format("2006-01-02"),
Qty: row.Qty,
UnitPrice: row.Price,
Total: rowTotal,
AppliedTotal: rowTotal * ratio,
})
}
} else {
rows, err := s.hppRepo.ListUsageCostRowsByProductFlags(context.Background(), kandangIDsGrowing, config.NormalFlags, endDate)
if err != nil {
return nil, err
}
for _, row := range rows {
baseTotal += row.TotalCost
refDate := row.LastUsedAt
if refDate.IsZero() {
refDate = row.FirstUsedAt
}
baseRows = append(baseRows, HppV2Reference{
Type: "stock_allocation",
ID: row.StockableID,
StockableType: row.StockableType,
ProductID: row.SourceProductID,
ProductName: row.SourceProductName,
Date: refDate.Format("2006-01-02"),
Qty: row.Qty,
UnitPrice: row.UnitPrice,
Total: row.TotalCost,
AppliedTotal: row.TotalCost * ratio,
})
}
}
if baseTotal == 0 {
return nil, nil
}
return &HppV2ComponentPart{
Code: partCode,
Title: partTitle,
Total: baseTotal * ratio,
Proration: &HppV2Proration{
Basis: hppV2ProrationPopulation,
Numerator: transferTotalQty,
Denominator: totalPopulationFlockGrowing,
Ratio: ratio,
},
References: baseRows,
}, nil
}
func (s *hppV2Service) GetFeedLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
if s.hppRepo == nil {
return 0, nil
func (s *hppV2Service) buildLayingUsagePart(
projectFlockKandangId uint,
endDate *time.Time,
config hppV2StockComponentConfig,
cutover bool,
) (*HppV2ComponentPart, error) {
if cutover {
rows, err := s.hppRepo.ListAdjustmentCostRowsByProductFlags(context.Background(), []uint{projectFlockKandangId}, config.CutoverFlags, endDate)
if err != nil {
return nil, err
}
total := 0.0
references := make([]HppV2Reference, 0, len(rows))
for _, row := range rows {
rowTotal := adjustmentRowTotalCost(row)
total += rowTotal
references = append(references, HppV2Reference{
Type: "adjustment_stock",
ID: row.AdjustmentID,
ProjectFlockKandangID: row.ProjectFlockKandangID,
ProductID: row.ProductID,
ProductName: row.ProductName,
Date: row.CreatedAt.Format("2006-01-02"),
Qty: row.Qty,
UnitPrice: row.Price,
Total: rowTotal,
AppliedTotal: rowTotal,
})
}
if total == 0 {
return nil, nil
}
return &HppV2ComponentPart{
Code: hppV2PartLayingCutover,
Title: "Laying Cut-over",
Total: total,
References: references,
}, nil
}
result, err := s.hppRepo.GetFeedUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate)
rows, err := s.hppRepo.ListUsageCostRowsByProductFlags(context.Background(), []uint{projectFlockKandangId}, config.NormalFlags, endDate)
if err != nil {
return 0, err
return nil, err
}
return result, nil
total := 0.0
references := make([]HppV2Reference, 0, len(rows))
for _, row := range rows {
total += row.TotalCost
refDate := row.LastUsedAt
if refDate.IsZero() {
refDate = row.FirstUsedAt
}
references = append(references, HppV2Reference{
Type: "stock_allocation",
ID: row.StockableID,
StockableType: row.StockableType,
ProductID: row.SourceProductID,
ProductName: row.SourceProductName,
Date: refDate.Format("2006-01-02"),
Qty: row.Qty,
UnitPrice: row.UnitPrice,
Total: row.TotalCost,
AppliedTotal: row.TotalCost,
})
}
if total == 0 {
return nil, nil
}
return &HppV2ComponentPart{
Code: hppV2PartLayingNormal,
Title: "Laying",
Total: total,
References: references,
}, nil
}
func (s *hppV2Service) buildGrowingExpenseDirectPart(
projectFlockKandangId uint,
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
endDate *time.Time,
config hppV2ExpenseComponentConfig,
) (*HppV2ComponentPart, error) {
return s.buildGrowingExpensePart(projectFlockKandangId, contextRow, endDate, config, false)
}
func (s *hppV2Service) buildGrowingExpenseFarmPart(
projectFlockKandangId uint,
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
endDate *time.Time,
config hppV2ExpenseComponentConfig,
) (*HppV2ComponentPart, error) {
return s.buildGrowingExpensePart(projectFlockKandangId, contextRow, endDate, config, true)
}
func (s *hppV2Service) buildGrowingExpensePart(
projectFlockKandangId uint,
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
endDate *time.Time,
config hppV2ExpenseComponentConfig,
farmLevel bool,
) (*HppV2ComponentPart, error) {
if contextRow == nil {
return nil, nil
}
sourceProjectFlockID, transferTotalQty, err := s.hppRepo.GetTransferSourceSummary(context.Background(), projectFlockKandangId)
if err != nil {
return nil, err
}
if sourceProjectFlockID == 0 || transferTotalQty <= 0 {
return nil, nil
}
kandangIDsGrowing, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID)
if err != nil {
return nil, err
}
if len(kandangIDsGrowing) == 0 {
return nil, nil
}
totalPopulationFlockGrowing, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDsGrowing)
if err != nil {
return nil, err
}
if totalPopulationFlockGrowing <= 0 {
return nil, nil
}
ratio := transferTotalQty / totalPopulationFlockGrowing
if ratio <= 0 {
return nil, nil
}
var rows []commonRepo.HppV2ExpenseCostRow
if farmLevel {
rows, err = s.hppRepo.ListExpenseRealizationRowsByProjectFlockID(context.Background(), sourceProjectFlockID, endDate, config.Ekspedisi)
} else {
rows, err = s.hppRepo.ListExpenseRealizationRowsByProjectFlockKandangIDs(context.Background(), kandangIDsGrowing, endDate, config.Ekspedisi)
}
if err != nil {
return nil, err
}
return buildExpensePartFromRows(
rows,
map[bool]string{false: hppV2PartGrowingDirect, true: hppV2PartGrowingFarm}[farmLevel],
map[bool]string{false: "Growing Direct", true: "Growing Farm"}[farmLevel],
&HppV2Proration{
Basis: hppV2ProrationPopulation,
Numerator: transferTotalQty,
Denominator: totalPopulationFlockGrowing,
Ratio: ratio,
},
ratio,
), nil
}
func (s *hppV2Service) buildLayingExpenseDirectPart(
projectFlockKandangId uint,
endDate *time.Time,
config hppV2ExpenseComponentConfig,
) (*HppV2ComponentPart, error) {
rows, err := s.hppRepo.ListExpenseRealizationRowsByProjectFlockKandangIDs(context.Background(), []uint{projectFlockKandangId}, endDate, config.Ekspedisi)
if err != nil {
return nil, err
}
return buildExpensePartFromRows(rows, hppV2PartLayingDirect, "Laying Direct", nil, 1), nil
}
func (s *hppV2Service) buildLayingExpenseFarmPart(
projectFlockKandangId uint,
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
endDate *time.Time,
config hppV2ExpenseComponentConfig,
) (*HppV2ComponentPart, error) {
if contextRow == nil {
return nil, nil
}
rows, err := s.hppRepo.ListExpenseRealizationRowsByProjectFlockID(context.Background(), contextRow.ProjectFlockID, endDate, config.Ekspedisi)
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, nil
}
farmPFKIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), contextRow.ProjectFlockID)
if err != nil {
return nil, err
}
targetPieces, targetWeight, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil {
return nil, err
}
farmPieces, farmWeight, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), farmPFKIDs, endDate)
if err != nil {
return nil, err
}
basis := hppV2ProrationEggWeight
numerator := targetWeight
denominator := farmWeight
if denominator <= 0 {
basis = hppV2ProrationEggPiece
numerator = targetPieces
denominator = farmPieces
}
if denominator <= 0 {
return nil, nil
}
ratio := numerator / denominator
if ratio <= 0 {
return nil, nil
}
return buildExpensePartFromRows(
rows,
hppV2PartLayingFarm,
"Laying Farm",
&HppV2Proration{
Basis: basis,
Numerator: numerator,
Denominator: denominator,
Ratio: ratio,
},
ratio,
), nil
}
func (s *hppV2Service) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) {
@@ -155,9 +757,71 @@ func (s *hppV2Service) GetHppEstimationDanRealisasi(totalProductionCost float64,
real.HargaButir = roundToTwoDecimals(totalProductionCost / realPieces)
}
result := &HppCostResponse{
return &HppCostResponse{
Estimation: estimation,
Real: real,
}
return result, nil
}, nil
}
func hppV2DayWindow(date *time.Time) (time.Time, time.Time, error) {
if date == nil {
now := time.Now()
date = &now
}
location, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
return time.Time{}, time.Time{}, err
}
startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location)
endOfDay := startOfDay.Add(24 * time.Hour)
return startOfDay, endOfDay, nil
}
func adjustmentRowTotalCost(row commonRepo.HppV2AdjustmentCostRow) float64 {
if row.GrandTotal > 0 {
return row.GrandTotal
}
return row.Qty * row.Price
}
func buildExpensePartFromRows(
rows []commonRepo.HppV2ExpenseCostRow,
code string,
title string,
proration *HppV2Proration,
ratio float64,
) *HppV2ComponentPart {
if len(rows) == 0 {
return nil
}
total := 0.0
references := make([]HppV2Reference, 0, len(rows))
for _, row := range rows {
total += row.TotalCost * ratio
references = append(references, HppV2Reference{
Type: "expense_realization",
ID: row.ExpenseRealizationID,
ProductID: row.NonstockID,
ProductName: row.NonstockName,
Date: row.RealizationDate.Format("2006-01-02"),
Qty: row.Qty,
UnitPrice: row.Price,
Total: row.TotalCost,
AppliedTotal: row.TotalCost * ratio,
})
}
if total == 0 {
return nil
}
return &HppV2ComponentPart{
Code: code,
Title: title,
Total: total,
Proration: proration,
References: references,
}
}
@@ -0,0 +1,473 @@
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)
}