Files
lti-api/internal/common/service/common.hppv2.service.go
T
2026-06-06 10:29:33 +07:00

1962 lines
59 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
const (
hppV2ComponentPakan = "PAKAN"
hppV2ComponentOvk = "OVK"
hppV2ComponentDocChickin = "DOC_CHICKIN"
hppV2ComponentDirectPulletPurchase = "DIRECT_PULLET_PURCHASE"
hppV2ComponentBopRegular = "BOP_REGULAR"
hppV2ComponentBopEksp = "BOP_EKSPEDISI"
hppV2ComponentManualPulletCost = "MANUAL_PULLET_COST"
hppV2ComponentRecordingStockRoute = "RECORDING_STOCK_ROUTE"
hppV2ComponentDepreciation = "DEPRECIATION"
hppV2PartGrowingNormal = "growing_normal"
hppV2PartGrowingCutover = "growing_cutover"
hppV2PartLayingNormal = "laying_normal"
hppV2PartLayingCutover = "laying_cutover"
hppV2PartGrowingDirect = "growing_direct"
hppV2PartGrowingFarm = "growing_farm"
hppV2PartLayingDirect = "laying_direct"
hppV2PartLayingFarm = "laying_farm"
hppV2PartManualCutover = "manual_cutover"
hppV2PartRecordingStockRoute = "recording_stock_route"
hppV2PartDepreciationNormal = "normal_transfer"
hppV2PartDepreciationCutover = "manual_cutover"
hppV2PartDepreciationFarmSnapshot = "farm_snapshot"
hppV2ProrationPopulation = "growing_population_share"
hppV2ProrationEggWeight = "laying_egg_weight_share"
hppV2ProrationEggPiece = "laying_egg_piece_share"
hppV2ScopePulletCost = "pullet_cost"
hppV2ScopeProductionCost = "production_cost"
hppV2CutoverFlagPakan = string(utils.FlagPakan)
hppV2CutoverFlagOvk = "OVK"
)
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)
GetCostOvk(projectFlockKandangId uint, endDate *time.Time) (float64, error)
GetCostDocChickin(projectFlockKandangId uint, endDate *time.Time) (float64, error)
GetCostDirectPulletPurchase(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)
GetDocChickinBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error)
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)
}
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) {
breakdown, err := s.CalculateHppBreakdown(projectFlockKandangId, date)
if err != nil {
return nil, err
}
if breakdown == nil {
return &HppCostResponse{}, nil
}
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
}
contextRow, err := s.hppRepo.GetProjectFlockKandangContext(context.Background(), projectFlockKandangId)
if err != nil {
return nil, err
}
pakanComponent, err := s.GetPakanBreakdown(projectFlockKandangId, &endOfDay)
if err != nil {
return nil, err
}
totalPulletCost := 0.0
totalProductionCost := 0.0
components := make([]HppV2Component, 0, 8)
appendComponent := func(requestedCode string, component *HppV2Component) {
pulletBefore := totalPulletCost
productionBefore := totalProductionCost
if component == nil || (component.Total == 0 && len(component.Parts) == 0) {
utils.Log.Infof(
"HPP v2 component skipped: project_flock_kandang_id=%d period_date=%s component=%s reason=empty_or_nil total_pullet_cost=%.2f total_production_cost=%.2f",
projectFlockKandangId,
startOfDay.Format("2006-01-02"),
requestedCode,
totalPulletCost,
totalProductionCost,
)
return
}
pulletAdded := componentScopeTotal(component, hppV2ScopePulletCost)
productionAdded := componentScopeTotal(component, hppV2ScopeProductionCost)
components = append(components, *component)
totalPulletCost += pulletAdded
totalProductionCost += productionAdded
utils.Log.Infof(
"HPP v2 component applied: project_flock_kandang_id=%d period_date=%s component=%s component_total=%.2f pullet_added=%.2f production_added=%.2f total_pullet_before=%.2f total_pullet_after=%.2f total_production_before=%.2f total_production_after=%.2f parts_count=%d",
projectFlockKandangId,
startOfDay.Format("2006-01-02"),
component.Code,
component.Total,
pulletAdded,
productionAdded,
pulletBefore,
totalPulletCost,
productionBefore,
totalProductionCost,
len(component.Parts),
)
}
appendComponent(hppV2ComponentPakan, pakanComponent)
ovkComponent, err := s.GetOvkBreakdown(projectFlockKandangId, &endOfDay)
if err != nil {
return nil, err
}
appendComponent(hppV2ComponentOvk, ovkComponent)
docComponent, err := s.GetDocChickinBreakdown(projectFlockKandangId, &endOfDay)
if err != nil {
return nil, err
}
appendComponent(hppV2ComponentDocChickin, docComponent)
directPulletComponent, err := s.GetDirectPulletPurchaseBreakdown(projectFlockKandangId, &endOfDay)
if err != nil {
return nil, err
}
appendComponent(hppV2ComponentDirectPulletPurchase, directPulletComponent)
bopRegularComponent, err := s.GetBopRegularBreakdown(projectFlockKandangId, &endOfDay)
if err != nil {
return nil, err
}
appendComponent(hppV2ComponentBopRegular, bopRegularComponent)
bopEkspedisiComponent, err := s.GetBopEkspedisiBreakdown(projectFlockKandangId, &endOfDay)
if err != nil {
return nil, err
}
appendComponent(hppV2ComponentBopEksp, bopEkspedisiComponent)
manualPulletComponent, err := s.getManualPulletCostComponent(projectFlockKandangId, contextRow, startOfDay)
if err != nil {
return nil, err
}
appendComponent(hppV2ComponentManualPulletCost, manualPulletComponent)
recordingStockRouteComponent, err := s.getRecordingStockRouteComponent(projectFlockKandangId, contextRow, startOfDay)
if err != nil {
return nil, err
}
appendComponent(hppV2ComponentRecordingStockRoute, recordingStockRouteComponent)
depreciationComponent, err := s.getDepreciationComponent(projectFlockKandangId, contextRow, startOfDay, endOfDay, totalPulletCost)
if err != nil {
return nil, err
}
depreciationCostToProduction := componentScopeTotal(depreciationComponent, hppV2ScopeProductionCost)
depreciationSource := ""
if depreciationComponent != nil && len(depreciationComponent.Parts) > 0 {
depreciationSource = depreciationComponent.Parts[0].Code
}
productionCostBeforeDepreciation := totalProductionCost
appendComponent(hppV2ComponentDepreciation, depreciationComponent)
utils.Log.Infof(
"HPP v2 depreciation cost applied: project_flock_kandang_id=%d period_date=%s depreciation_source=%s depreciation_cost=%.2f production_cost_before=%.2f production_cost_after=%.2f",
projectFlockKandangId,
startOfDay.Format("2006-01-02"),
depreciationSource,
depreciationCostToProduction,
productionCostBeforeDepreciation,
totalProductionCost,
)
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,
HouseType: contextRow.HouseType,
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),
},
TotalPulletCost: totalPulletCost,
TotalProductionCost: totalProductionCost,
Components: components,
Hpp: *hppCost,
}, nil
}
func (s *hppV2Service) GetCostPakan(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
component, err := s.GetPakanBreakdown(projectFlockKandangId, endDate)
if err != nil {
return 0, err
}
if component == nil {
return 0, nil
}
return component.Total, 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) GetCostDocChickin(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
component, err := s.GetDocChickinBreakdown(projectFlockKandangId, endDate)
if err != nil {
return 0, err
}
if component == nil {
return 0, nil
}
return component.Total, nil
}
func (s *hppV2Service) GetCostDirectPulletPurchase(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
component, err := s.GetDirectPulletPurchaseBreakdown(projectFlockKandangId, endDate)
if err != nil {
return 0, err
}
if component == nil {
return 0, nil
}
return component.Total, nil
}
func (s *hppV2Service) GetDocChickinBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) {
if s.hppRepo == nil {
return &HppV2Component{
Code: hppV2ComponentDocChickin,
Title: "DOC Chick-in",
Scopes: []string{hppV2ScopePulletCost},
Parts: []HppV2ComponentPart{},
}, nil
}
contextRow, err := s.hppRepo.GetProjectFlockKandangContext(context.Background(), projectFlockKandangId)
if err != nil {
return nil, err
}
part, err := s.buildGrowingChickinPart(projectFlockKandangId, contextRow, endDate, []string{string(utils.FlagDOC)}, false, hppV2PartGrowingDirect, "Growing DOC")
if err != nil {
return nil, err
}
parts := make([]HppV2ComponentPart, 0, 1)
total := 0.0
if part != nil {
parts = append(parts, *part)
total += part.Total
}
return &HppV2Component{
Code: hppV2ComponentDocChickin,
Title: "DOC Chick-in",
Scopes: []string{hppV2ScopePulletCost},
Total: total,
Parts: parts,
}, nil
}
func (s *hppV2Service) GetDirectPulletPurchaseBreakdown(projectFlockKandangId uint, endDate *time.Time) (*HppV2Component, error) {
part, err := s.buildLayingChickinPart(projectFlockKandangId, endDate, []string{string(utils.FlagPullet), string(utils.FlagLayer)}, true, hppV2PartLayingDirect, "Laying Direct Pullet")
if err != nil {
return nil, err
}
parts := make([]HppV2ComponentPart, 0, 1)
total := 0.0
if part != nil {
parts = append(parts, *part)
total += part.Total
}
return &HppV2Component{
Code: hppV2ComponentDirectPulletPurchase,
Title: "Direct Pullet Purchase",
Scopes: []string{hppV2ScopeProductionCost},
Total: total,
Parts: parts,
}, nil
}
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,
Scopes: []string{hppV2ScopePulletCost, hppV2ScopeProductionCost},
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, contextRow, endDate, config, false)
if err != nil {
return nil, err
}
if layingNormalPart != nil {
parts = append(parts, *layingNormalPart)
total += layingNormalPart.Total
}
layingCutoverPart, err := s.buildLayingUsagePart(projectFlockKandangId, contextRow, 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,
Scopes: []string{hppV2ScopePulletCost, hppV2ScopeProductionCost},
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,
Scopes: []string{hppV2ScopePulletCost, hppV2ScopeProductionCost},
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,
Scopes: []string{hppV2ScopePulletCost, hppV2ScopeProductionCost},
Total: total,
Parts: parts,
}, nil
}
func (s *hppV2Service) buildGrowingChickinPart(
projectFlockKandangId uint,
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
endDate *time.Time,
flagNames []string,
excludeTransferToLaying bool,
partCode string,
partTitle string,
) (*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
}
rows, err := s.hppRepo.ListChickinCostRowsByProductFlags(context.Background(), kandangIDsGrowing, flagNames, endDate, excludeTransferToLaying)
if err != nil {
return nil, err
}
return buildChickinPartFromRows(
rows,
partCode,
partTitle,
[]string{hppV2ScopePulletCost},
&HppV2Proration{
Basis: hppV2ProrationPopulation,
Numerator: transferTotalQty,
Denominator: totalPopulationFlockGrowing,
Ratio: ratio,
},
ratio,
), nil
}
func (s *hppV2Service) buildLayingChickinPart(
projectFlockKandangId uint,
endDate *time.Time,
flagNames []string,
excludeTransferToLaying bool,
partCode string,
partTitle string,
) (*HppV2ComponentPart, error) {
rows, err := s.hppRepo.ListChickinCostRowsByProductFlags(context.Background(), []uint{projectFlockKandangId}, flagNames, endDate, excludeTransferToLaying)
if err != nil {
return nil, err
}
return buildChickinPartFromRows(rows, partCode, partTitle, []string{hppV2ScopeProductionCost}, nil, 1), 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 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
}
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,
Scopes: []string{hppV2ScopePulletCost},
Total: baseTotal * ratio,
Proration: &HppV2Proration{
Basis: hppV2ProrationPopulation,
Numerator: transferTotalQty,
Denominator: totalPopulationFlockGrowing,
Ratio: ratio,
},
References: baseRows,
}, nil
}
func (s *hppV2Service) buildLayingUsagePart(
projectFlockKandangId uint,
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
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",
Scopes: []string{hppV2ScopeProductionCost},
Total: total,
References: references,
}, nil
}
// 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
}
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",
Scopes: []string{hppV2ScopeProductionCost},
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],
[]string{hppV2ScopePulletCost},
&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", []string{hppV2ScopeProductionCost}, 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
}
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 0, nil, err
}
farmPieces, farmWeight, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), farmPFKIDs, endDate)
if err != nil {
return 0, nil, err
}
basis := hppV2ProrationEggWeight
numerator := targetWeight
denominator := farmWeight
if denominator <= 0 {
basis = hppV2ProrationEggPiece
numerator = targetPieces
denominator = farmPieces
}
if denominator <= 0 {
return 0, nil, nil
}
ratio := numerator / denominator
if ratio <= 0 {
return 0, nil, 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(
projectFlockKandangId uint,
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
periodDate time.Time,
) (*HppV2Component, error) {
if s.hppRepo == nil || 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
}
manualInput, err := s.hppRepo.GetManualDepreciationInputByProjectFlockID(context.Background(), contextRow.ProjectFlockID)
if err != nil {
return nil, err
}
if manualInput == nil || manualInput.TotalCost <= 0 || manualInput.CutoverDate.IsZero() {
return nil, nil
}
if dateOnly(periodDate).Before(dateOnly(manualInput.CutoverDate)) {
return nil, nil
}
farmPFKIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), contextRow.ProjectFlockID)
if err != nil {
return nil, err
}
if len(farmPFKIDs) == 0 {
return nil, nil
}
totalPopulation, err := s.hppRepo.GetTotalPopulation(context.Background(), farmPFKIDs)
if err != nil {
return nil, err
}
if totalPopulation <= 0 {
return nil, nil
}
targetPopulation, err := s.hppRepo.GetTotalPopulation(context.Background(), []uint{projectFlockKandangId})
if err != nil {
return nil, err
}
if targetPopulation <= 0 {
return nil, nil
}
ratio := targetPopulation / totalPopulation
if ratio <= 0 {
return nil, nil
}
appliedTotal := manualInput.TotalCost * ratio
part := HppV2ComponentPart{
Code: hppV2PartManualCutover,
Title: "Manual Cut-over",
Scopes: []string{hppV2ScopePulletCost},
Total: appliedTotal,
Proration: &HppV2Proration{
Basis: hppV2ProrationPopulation,
Numerator: targetPopulation,
Denominator: totalPopulation,
Ratio: ratio,
},
Details: map[string]any{
"cutover_date": formatDateOnly(manualInput.CutoverDate),
"farm_total_cost": manualInput.TotalCost,
"target_population": targetPopulation,
"farm_population": totalPopulation,
},
References: []HppV2Reference{
{
Type: "farm_depreciation_manual_input",
ID: manualInput.ID,
Date: formatDateOnly(manualInput.CutoverDate),
Qty: 1,
Total: manualInput.TotalCost,
AppliedTotal: appliedTotal,
},
},
}
return &HppV2Component{
Code: hppV2ComponentManualPulletCost,
Title: "Manual Pullet Cost",
Scopes: []string{hppV2ScopePulletCost},
Total: appliedTotal,
Parts: []HppV2ComponentPart{part},
}, nil
}
func (s *hppV2Service) getRecordingStockRouteComponent(
projectFlockKandangId uint,
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
periodDate time.Time,
) (*HppV2Component, error) {
if s.hppRepo == nil || contextRow == nil || periodDate.IsZero() {
return nil, nil
}
farmTotalCost, err := s.hppRepo.GetRecordingStockRoutingAdjustmentCostByProjectFlockID(
context.Background(),
contextRow.ProjectFlockID,
periodDate,
)
if err != nil {
return nil, err
}
if farmTotalCost <= 0 {
return nil, nil
}
farmPFKIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), contextRow.ProjectFlockID)
if err != nil {
return nil, err
}
if len(farmPFKIDs) == 0 {
return nil, nil
}
totalPopulation, err := s.hppRepo.GetTotalPopulation(context.Background(), farmPFKIDs)
if err != nil {
return nil, err
}
if totalPopulation <= 0 {
return nil, nil
}
targetPopulation, err := s.hppRepo.GetTotalPopulation(context.Background(), []uint{projectFlockKandangId})
if err != nil {
return nil, err
}
if targetPopulation <= 0 {
return nil, nil
}
ratio := targetPopulation / totalPopulation
if ratio <= 0 {
return nil, nil
}
appliedTotal := farmTotalCost * ratio
if appliedTotal <= 0 {
return nil, nil
}
part := HppV2ComponentPart{
Code: hppV2PartRecordingStockRoute,
Title: "Recording Stock Route",
Scopes: []string{hppV2ScopePulletCost},
Total: appliedTotal,
Proration: &HppV2Proration{
Basis: hppV2ProrationPopulation,
Numerator: targetPopulation,
Denominator: totalPopulation,
Ratio: ratio,
},
Details: map[string]any{
"period_date": formatDateOnly(periodDate),
"farm_total_cost": farmTotalCost,
"target_population": targetPopulation,
"farm_population": totalPopulation,
"project_flock_id": contextRow.ProjectFlockID,
"project_flock_kandang_id": projectFlockKandangId,
},
References: []HppV2Reference{
{
Type: "recording_stock_route",
Date: formatDateOnly(periodDate),
Qty: 1,
Total: farmTotalCost,
AppliedTotal: appliedTotal,
},
},
}
return &HppV2Component{
Code: hppV2ComponentRecordingStockRoute,
Title: "Recording Stock Route",
Scopes: []string{hppV2ScopePulletCost},
Total: appliedTotal,
Parts: []HppV2ComponentPart{part},
}, nil
}
func (s *hppV2Service) getDepreciationComponent(
projectFlockKandangId uint,
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
periodDate time.Time,
endDate time.Time,
totalPulletCost float64,
) (*HppV2Component, error) {
if s.hppRepo == nil || contextRow == nil {
return nil, nil
}
snapshotPart, err := s.buildFarmSnapshotDepreciationPart(projectFlockKandangId, contextRow, periodDate, endDate)
if err != nil {
return nil, err
}
if snapshotPart != nil {
return &HppV2Component{
Code: hppV2ComponentDepreciation,
Title: "Depreciation",
Scopes: []string{hppV2ScopeProductionCost},
Total: snapshotPart.Total,
Parts: []HppV2ComponentPart{*snapshotPart},
}, nil
}
// Multi-source support: 1 target kandang bisa menerima dari MULTIPLE transfer terpisah
// (tiap transfer = 1 source kandang). Depresiasi per target = SUM dari per-transfer depresiasi.
// Setiap transfer dihitung dengan chick_in_date source-nya sendiri dan cost basis pro-rated
// berdasarkan qty share (transfer.qty / totalTransferQty).
transferInputs, err := s.hppRepo.GetAllTransferInputsByProjectFlockKandangID(context.Background(), projectFlockKandangId, periodDate)
if err != nil {
return nil, err
}
// Filter valid transfers (punya source flock id)
validTransfers := make([]commonRepo.HppV2LatestTransferInputRow, 0, len(transferInputs))
totalTransferQty := 0.0
for _, t := range transferInputs {
if t.SourceProjectFlockID == 0 {
continue
}
validTransfers = append(validTransfers, t)
totalTransferQty += t.TransferQty
}
if len(validTransfers) > 0 {
if totalPulletCost <= 0 {
return nil, nil
}
totalDepreciation := 0.0
parts := make([]HppV2ComponentPart, 0, len(validTransfers))
for i := range validTransfers {
t := validTransfers[i]
// Pro-rate cost basis per transfer berdasarkan qty share.
// CATATAN: pendekatan ini AKURAT kalau cost per ekor sama antar source flock.
// Kalau cost per ekor berbeda signifikan antar source, follow-up: refactor
// `buildGrowingUsagePart` untuk multi-source-flock cost computation.
transferCostBasis := totalPulletCost
if totalTransferQty > 0 && len(validTransfers) > 1 {
transferCostBasis = totalPulletCost * (t.TransferQty / totalTransferQty)
}
part, partErr := s.buildNormalTransferDepreciationPart(contextRow, &t, periodDate, transferCostBasis)
if partErr != nil {
return nil, partErr
}
if part == nil {
continue
}
totalDepreciation += part.Total
parts = append(parts, *part)
}
if len(parts) == 0 {
return nil, nil
}
return &HppV2Component{
Code: hppV2ComponentDepreciation,
Title: "Depreciation",
Scopes: []string{hppV2ScopeProductionCost},
Total: totalDepreciation,
Parts: parts,
}, nil
}
// Fallback: manual cut-over (kandang tanpa transfer record)
part, err := s.buildManualCutoverDepreciationPart(projectFlockKandangId, contextRow, periodDate, totalPulletCost)
if err != nil {
return nil, err
}
if part == nil {
return nil, nil
}
return &HppV2Component{
Code: hppV2ComponentDepreciation,
Title: "Depreciation",
Scopes: []string{hppV2ScopeProductionCost},
Total: part.Total,
Parts: []HppV2ComponentPart{*part},
}, nil
}
func (s *hppV2Service) buildFarmSnapshotDepreciationPart(
projectFlockKandangId uint,
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
periodDate time.Time,
endDate time.Time,
) (*HppV2ComponentPart, error) {
if contextRow == nil {
return nil, nil
}
snapshot, err := s.hppRepo.GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(context.Background(), contextRow.ProjectFlockID, periodDate)
if err != nil {
return nil, err
}
if snapshot == nil || snapshot.DepreciationValue <= 0 {
return nil, nil
}
farmPFKIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), contextRow.ProjectFlockID)
if err != nil {
return nil, err
}
if len(farmPFKIDs) == 0 {
return nil, nil
}
end := endDate
targetPieces, targetWeight, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, &end)
if err != nil {
return nil, err
}
farmPieces, farmWeight, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), farmPFKIDs, &end)
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
}
appliedDepreciation := snapshot.DepreciationValue * ratio
if appliedDepreciation <= 0 {
return nil, nil
}
appliedPulletCostDayN := snapshot.PulletCostDayNTotal * ratio
depreciationPercent := snapshot.DepreciationPercentEffective
if appliedPulletCostDayN > 0 {
depreciationPercent = (appliedDepreciation / appliedPulletCostDayN) * 100
}
return &HppV2ComponentPart{
Code: hppV2PartDepreciationFarmSnapshot,
Title: "Farm Snapshot",
Scopes: []string{hppV2ScopeProductionCost},
Total: appliedDepreciation,
Proration: &HppV2Proration{
Basis: basis,
Numerator: numerator,
Denominator: denominator,
Ratio: ratio,
},
Details: map[string]any{
"basis_total": snapshot.DepreciationValue,
"pullet_cost_day_n": appliedPulletCostDayN,
"depreciation_percent": depreciationPercent,
"snapshot_id": snapshot.ID,
"snapshot_period_date": formatDateOnly(snapshot.PeriodDate),
"snapshot_project_flock": snapshot.ProjectFlockID,
},
References: []HppV2Reference{
{
Type: "farm_depreciation_snapshot",
ID: snapshot.ID,
Date: formatDateOnly(snapshot.PeriodDate),
Qty: 1,
Total: snapshot.DepreciationValue,
AppliedTotal: appliedDepreciation,
},
},
}, nil
}
func (s *hppV2Service) buildNormalTransferDepreciationPart(
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
transferInput *commonRepo.HppV2LatestTransferInputRow,
periodDate time.Time,
totalPulletCost float64,
) (*HppV2ComponentPart, error) {
if contextRow == nil || transferInput == nil || totalPulletCost <= 0 {
return nil, nil
}
originDate, err := s.hppRepo.GetEarliestChickInDateByProjectFlockID(context.Background(), transferInput.SourceProjectFlockID)
if err != nil {
return nil, err
}
if originDate == nil || originDate.IsZero() {
return nil, nil
}
scheduleDay := DepreciationScheduleDay(*originDate, periodDate, contextRow.HouseType)
if scheduleDay <= 0 {
return nil, nil
}
houseType := NormalizeDepreciationHouseType(contextRow.HouseType)
multiplicationByHouseType, effectiveDates, err := s.hppRepo.GetMultiplicationPercentages(context.Background(), []string{houseType}, scheduleDay, contextRow.ProjectFlockID)
if err != nil {
return nil, err
}
pulletCostDayN, depreciationValue, multiplicationPercentage := CalculateDepreciationAtDayN(
totalPulletCost,
scheduleDay,
contextRow.HouseType,
multiplicationByHouseType,
)
if depreciationValue <= 0 && pulletCostDayN <= 0 {
return nil, nil
}
totalValueAfter := pulletCostDayN * multiplicationPercentage
depreciationPercent := (1.0 - multiplicationPercentage) * 100.0
var standardEffectiveDate string
if ed, ok := effectiveDates[houseType]; ok && ed != nil {
standardEffectiveDate = formatDateOnly(*ed)
}
return &HppV2ComponentPart{
Code: hppV2PartDepreciationNormal,
Title: "Normal Transfer",
Scopes: []string{hppV2ScopeProductionCost},
Total: depreciationValue,
Details: map[string]any{
"basis_total": totalPulletCost,
"pullet_cost_day_n": pulletCostDayN,
"multiplication_percentage": multiplicationPercentage,
"total_value_pullet_after_depreciation": totalValueAfter,
"depreciation_percent": depreciationPercent,
"schedule_day": scheduleDay,
"origin_date": formatDateOnly(*originDate),
"transfer_date": formatDateOnly(transferInput.TransferDate),
"source_project_flock_id": transferInput.SourceProjectFlockID,
"standard_effective_date": standardEffectiveDate,
"kandang_population": transferInput.TransferQty,
},
References: []HppV2Reference{
{
Type: "laying_transfer",
ID: transferInput.TransferID,
Date: formatDateOnly(transferInput.TransferDate),
Qty: transferInput.TransferQty,
Total: totalPulletCost,
AppliedTotal: depreciationValue,
},
},
}, nil
}
func (s *hppV2Service) buildManualCutoverDepreciationPart(
projectFlockKandangId uint,
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
periodDate time.Time,
totalPulletCost float64,
) (*HppV2ComponentPart, error) {
if contextRow == nil {
return nil, nil
}
manualInput, err := s.hppRepo.GetManualDepreciationInputByProjectFlockID(context.Background(), contextRow.ProjectFlockID)
if err != nil {
return nil, err
}
if manualInput == nil || manualInput.TotalCost <= 0 || manualInput.CutoverDate.IsZero() {
return nil, nil
}
if dateOnly(periodDate).Before(dateOnly(manualInput.CutoverDate)) {
return nil, nil
}
populations, err := s.hppRepo.GetChickinPopulationByPFKForFarm(context.Background(), contextRow.ProjectFlockID)
if err != nil {
return nil, err
}
var totalPopulation float64
for _, qty := range populations {
totalPopulation += qty
}
kandangPopulation := populations[projectFlockKandangId]
if totalPopulation <= 0 || kandangPopulation <= 0 {
return nil, nil
}
populationShare := kandangPopulation / totalPopulation
basis := manualInput.TotalCost * populationShare
originDate, err := s.hppRepo.GetEarliestChickInDateByProjectFlockID(context.Background(), contextRow.ProjectFlockID)
if err != nil {
return nil, err
}
if originDate == nil || originDate.IsZero() {
return nil, nil
}
// Hitung schedule day relatif terhadap cutover_date, bukan dari chick_in_date.
// Ini menangani kasus cut-over flock yang belum 175 hari pada period date,
// karena bisnis sudah menetapkan cutover_date sebagai awal depresiasi.
// Rumus setara secara matematis dengan DepreciationScheduleDay ketika flock >= 175 hari.
cutoverScheduleDay := DepreciationScheduleDay(*originDate, manualInput.CutoverDate, contextRow.HouseType)
startDay := 1
if cutoverScheduleDay > 0 {
startDay = cutoverScheduleDay
}
daysSinceCutover := int(dateOnly(periodDate).Sub(dateOnly(manualInput.CutoverDate)).Hours() / 24)
reportScheduleDay := startDay + daysSinceCutover
if reportScheduleDay <= 0 {
return nil, nil
}
houseType := NormalizeDepreciationHouseType(contextRow.HouseType)
multiplicationByHouseType, effectiveDates, err := s.hppRepo.GetMultiplicationPercentages(context.Background(), []string{houseType}, reportScheduleDay, contextRow.ProjectFlockID)
if err != nil {
return nil, err
}
pulletCostDayN, depreciationValue, multiplicationPercentage := CalculateDepreciationFromDayRange(
basis,
startDay,
reportScheduleDay,
contextRow.HouseType,
multiplicationByHouseType,
)
if depreciationValue <= 0 && pulletCostDayN <= 0 {
return nil, nil
}
totalValueAfter := pulletCostDayN * multiplicationPercentage
depreciationPercent := (1.0 - multiplicationPercentage) * 100.0
_ = totalPulletCost
var standardEffectiveDate string
if ed, ok := effectiveDates[houseType]; ok && ed != nil {
standardEffectiveDate = formatDateOnly(*ed)
}
return &HppV2ComponentPart{
Code: hppV2PartDepreciationCutover,
Title: "Manual Cut-over",
Scopes: []string{hppV2ScopeProductionCost},
Total: depreciationValue,
Details: map[string]any{
"basis_total": basis,
"manual_input_total": manualInput.TotalCost,
"population_share": populationShare,
"pullet_cost_day_n": pulletCostDayN,
"multiplication_percentage": multiplicationPercentage,
"total_value_pullet_after_depreciation": totalValueAfter,
"depreciation_percent": depreciationPercent,
"schedule_day": reportScheduleDay,
"start_schedule_day": startDay,
"origin_date": formatDateOnly(*originDate),
"cutover_date": formatDateOnly(manualInput.CutoverDate),
"manual_input_id": manualInput.ID,
"project_flock_kandang": projectFlockKandangId,
"standard_effective_date": standardEffectiveDate,
"kandang_population": kandangPopulation,
},
References: []HppV2Reference{
{
Type: "farm_depreciation_manual_input",
ID: manualInput.ID,
Date: formatDateOnly(manualInput.CutoverDate),
Qty: 1,
Total: manualInput.TotalCost,
AppliedTotal: depreciationValue,
},
},
}, nil
}
func (s *hppV2Service) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) {
utils.Log.Infof(
"GetHppEstimationDanRealisasi started: project_flock_kandang_id=%d total_production_cost=%.2f start_date=%s end_date=%s",
projectFlockKandangId,
totalProductionCost,
formatTimePtr(startDate),
formatTimePtr(endDate),
)
if s.hppRepo == nil {
utils.Log.Warnf(
"GetHppEstimationDanRealisasi skipped: hpp repository is nil (project_flock_kandang_id=%d)",
projectFlockKandangId,
)
return &HppCostResponse{}, nil
}
recordingQty, recordingWeight, adjustmentQty, adjustmentWeight, err := s.hppRepo.GetEggProduksiBreakdownByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil {
utils.Log.WithError(err).Errorf(
"GetHppEstimationDanRealisasi failed to get estimation egg production: project_flock_kandang_id=%d end_date=%s",
projectFlockKandangId,
formatTimePtr(endDate),
)
return nil, err
}
estimPieces := recordingQty + adjustmentQty
estimWeightKg := recordingWeight + adjustmentWeight
realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, startDate, endDate)
if err != nil {
utils.Log.WithError(err).Errorf(
"GetHppEstimationDanRealisasi failed to get realization egg sales: project_flock_kandang_id=%d start_date=%s end_date=%s",
projectFlockKandangId,
formatTimePtr(startDate),
formatTimePtr(endDate),
)
return nil, err
}
estimation := HppCostDetail{
Total: totalProductionCost,
Kg: estimWeightKg,
Butir: estimPieces,
}
if estimWeightKg > 0 {
estimation.HargaKg = roundToTwoDecimals(totalProductionCost / estimWeightKg)
}
if estimPieces > 0 {
estimation.HargaButir = roundToTwoDecimals(totalProductionCost / estimPieces)
}
real := HppCostDetail{
Total: totalProductionCost,
Kg: realWeightKg,
Butir: realPieces,
}
if realWeightKg > 0 {
real.HargaKg = roundToTwoDecimals(totalProductionCost / realWeightKg)
}
if realPieces > 0 {
real.HargaButir = roundToTwoDecimals(totalProductionCost / realPieces)
}
utils.Log.Infof(
"GetHppEstimationDanRealisasi success: project_flock_kandang_id=%d estimation_butir=%.2f estimation_kg=%.2f estimation_harga_butir=%.2f estimation_harga_kg=%.2f real_butir=%.2f real_kg=%.2f real_harga_butir=%.2f real_harga_kg=%.2f totalProductionCost=%.2f",
projectFlockKandangId,
estimation.Butir,
estimation.Kg,
estimation.HargaButir,
estimation.HargaKg,
real.Butir,
real.Kg,
real.HargaButir,
real.HargaKg,
totalProductionCost,
)
return &HppCostResponse{
Estimation: estimation,
Real: real,
DebugValues: &HppCostDebugValues{
RecordingEggQty: recordingQty,
RecordingEggWeight: recordingWeight,
AdjustmentEggQty: adjustmentQty,
AdjustmentEggWeight: adjustmentWeight,
SoldEggQty: realPieces,
SoldEggWeight: realWeightKg,
},
}, 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,
scopes []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,
Scopes: append([]string{}, scopes...),
Total: total,
Proration: proration,
References: references,
}
}
func buildChickinPartFromRows(
rows []commonRepo.HppV2ChickinCostRow,
code string,
title string,
scopes []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
projectFlockKandangID := row.ProjectFlockKandangID
references = append(references, HppV2Reference{
Type: "project_chickin",
ID: row.ProjectChickinID,
StockableType: row.StockableType,
ProjectFlockKandangID: &projectFlockKandangID,
ProductID: row.SourceProductID,
ProductName: row.SourceProductName,
Date: row.ChickInDate.Format("2006-01-02"),
Qty: row.Qty,
UnitPrice: row.UnitPrice,
Total: row.TotalCost,
AppliedTotal: row.TotalCost * ratio,
})
}
if total == 0 {
return nil
}
return &HppV2ComponentPart{
Code: code,
Title: title,
Scopes: append([]string{}, scopes...),
Total: total,
Proration: proration,
References: references,
}
}
func componentHasScope(component *HppV2Component, scope string) bool {
if component == nil || scope == "" {
return false
}
for _, candidate := range component.Scopes {
if candidate == scope {
return true
}
}
return false
}
func componentScopeTotal(component *HppV2Component, scope string) float64 {
if component == nil || scope == "" {
return 0
}
total := 0.0
hasPartScopes := false
for _, part := range component.Parts {
if len(part.Scopes) == 0 {
continue
}
hasPartScopes = true
if partHasScope(&part, scope) {
total += part.Total
}
}
if hasPartScopes {
return total
}
if componentHasScope(component, scope) {
return component.Total
}
return 0
}
func partHasScope(part *HppV2ComponentPart, scope string) bool {
if part == nil || scope == "" {
return false
}
for _, candidate := range part.Scopes {
if candidate == scope {
return true
}
}
return false
}
func dateOnly(value time.Time) time.Time {
return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, time.UTC)
}
func formatDateOnly(value time.Time) string {
return dateOnly(value).Format("2006-01-02")
}