feat: manual pullet cost

This commit is contained in:
Adnan Zahir
2026-04-19 15:10:53 +07:00
parent a2ae139fae
commit 69d6fc165a
13 changed files with 857 additions and 33 deletions
@@ -2,6 +2,7 @@ package repository
import (
"context"
"errors"
"fmt"
"time"
@@ -72,9 +73,29 @@ type HppV2ChickinCostRow struct {
TotalCost float64
}
type HppV2LatestTransferInputRow struct {
ProjectFlockKandangID uint
SourceProjectFlockID uint
TransferDate time.Time
TransferQty float64
TransferID uint
}
type HppV2ManualDepreciationInputRow struct {
ID uint
ProjectFlockID uint
TotalCost float64
CutoverDate time.Time
Note *string
}
type HppV2CostRepository interface {
GetProjectFlockKandangContext(ctx context.Context, projectFlockKandangId uint) (*HppV2ProjectFlockKandangContext, error)
GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error)
GetLatestTransferInputByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint, period time.Time) (*HppV2LatestTransferInputRow, error)
GetManualDepreciationInputByProjectFlockID(ctx context.Context, projectFlockID uint) (*HppV2ManualDepreciationInputRow, error)
GetEarliestChickInDateByProjectFlockID(ctx context.Context, projectFlockID uint) (*time.Time, error)
GetDepreciationPercents(ctx context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, error)
ListUsageCostRowsByProductFlags(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string, date *time.Time) ([]HppV2UsageCostRow, error)
ListAdjustmentCostRowsByProductFlags(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string, date *time.Time) ([]HppV2AdjustmentCostRow, error)
ListExpenseRealizationRowsByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time, ekspedisi bool) ([]HppV2ExpenseCostRow, error)
@@ -136,6 +157,149 @@ func (r *HppV2RepositoryImpl) GetProjectFlockKandangIDs(ctx context.Context, pro
return ids, nil
}
func (r *HppV2RepositoryImpl) GetLatestTransferInputByProjectFlockKandangID(
ctx context.Context,
projectFlockKandangId uint,
period time.Time,
) (*HppV2LatestTransferInputRow, error) {
var row HppV2LatestTransferInputRow
query := `
WITH latest_transfer_approval AS (
SELECT a.approvable_id, a.action
FROM approvals a
JOIN (
SELECT approvable_id, MAX(action_at) AS latest_action_at
FROM approvals
WHERE approvable_type = @approval_type
GROUP BY approvable_id
) la
ON la.approvable_id = a.approvable_id
AND la.latest_action_at = a.action_at
WHERE a.approvable_type = @approval_type
),
approved_transfers AS (
SELECT
lt.id,
lt.from_project_flock_id,
COALESCE(DATE(lt.effective_move_date), DATE(lt.economic_cutoff_date), DATE(lt.transfer_date)) AS effective_date
FROM laying_transfers lt
JOIN latest_transfer_approval lta ON lta.approvable_id = lt.id
WHERE lt.deleted_at IS NULL
AND lt.executed_at IS NOT NULL
AND lta.action = 'APPROVED'
)
SELECT
ltt.target_project_flock_kandang_id AS project_flock_kandang_id,
at.from_project_flock_id AS source_project_flock_id,
at.effective_date AS transfer_date,
ltt.total_qty AS transfer_qty,
at.id AS transfer_id
FROM laying_transfer_targets ltt
JOIN approved_transfers at ON at.id = ltt.laying_transfer_id
WHERE ltt.deleted_at IS NULL
AND ltt.target_project_flock_kandang_id = @project_flock_kandang_id
AND at.effective_date <= DATE(@period_date)
ORDER BY at.effective_date DESC, at.id DESC
LIMIT 1
`
err := r.db.WithContext(ctx).Raw(query, map[string]any{
"approval_type": utils.ApprovalWorkflowTransferToLaying.String(),
"project_flock_kandang_id": projectFlockKandangId,
"period_date": period,
}).Scan(&row).Error
if err != nil {
return nil, err
}
if row.TransferID == 0 {
return nil, nil
}
return &row, nil
}
func (r *HppV2RepositoryImpl) GetManualDepreciationInputByProjectFlockID(
ctx context.Context,
projectFlockID uint,
) (*HppV2ManualDepreciationInputRow, error) {
var row HppV2ManualDepreciationInputRow
err := r.db.WithContext(ctx).
Table("farm_depreciation_manual_inputs").
Select("id, project_flock_id, total_cost, cutover_date, note").
Where("project_flock_id = ?", projectFlockID).
Limit(1).
Take(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
func (r *HppV2RepositoryImpl) GetEarliestChickInDateByProjectFlockID(ctx context.Context, projectFlockID uint) (*time.Time, error) {
type row struct {
ChickInDate *time.Time
}
var selected row
err := r.db.WithContext(ctx).
Table("project_chickins AS pc").
Select("MIN(pc.chick_in_date) AS chick_in_date").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pc.project_flock_kandang_id").
Where("pc.deleted_at IS NULL").
Where("pfk.project_flock_id = ?", projectFlockID).
Scan(&selected).Error
if err != nil {
return nil, err
}
if selected.ChickInDate == nil || selected.ChickInDate.IsZero() {
return nil, nil
}
return selected.ChickInDate, nil
}
func (r *HppV2RepositoryImpl) GetDepreciationPercents(
ctx context.Context,
houseTypes []string,
maxDay int,
) (map[string]map[int]float64, error) {
result := make(map[string]map[int]float64)
if len(houseTypes) == 0 || maxDay <= 0 {
return result, nil
}
type row struct {
HouseType string
Day int
DepreciationPercent float64
}
rows := make([]row, 0)
err := r.db.WithContext(ctx).
Table("house_depreciation_standards").
Select("house_type::text AS house_type, day, depreciation_percent").
Where("house_type::text IN ?", houseTypes).
Where("day <= ?", maxDay).
Order("house_type ASC, day ASC").
Scan(&rows).Error
if err != nil {
return nil, err
}
for _, item := range rows {
if _, exists := result[item.HouseType]; !exists {
result[item.HouseType] = make(map[int]float64)
}
result[item.HouseType][item.Day] = item.DepreciationPercent
}
return result, nil
}
func (r *HppV2RepositoryImpl) ListUsageCostRowsByProductFlags(
ctx context.Context,
projectFlockKandangIDs []uint,
@@ -49,7 +49,23 @@ func CalculateDepreciationAtDayN(
houseType string,
percentByHouseType map[string]map[int]float64,
) (float64, float64, float64) {
if initialPulletCost <= 0 || dayN <= 0 {
return CalculateDepreciationFromDayRange(initialPulletCost, 1, dayN, houseType, percentByHouseType)
}
func CalculateDepreciationFromDayRange(
initialPulletCost float64,
startDay int,
endDay int,
houseType string,
percentByHouseType map[string]map[int]float64,
) (float64, float64, float64) {
if initialPulletCost <= 0 || endDay <= 0 {
return 0, 0, 0
}
if startDay <= 0 {
startDay = 1
}
if endDay < startDay {
return 0, 0, 0
}
@@ -63,10 +79,10 @@ func CalculateDepreciationAtDayN(
pulletCostDayN := 0.0
depreciationValue := 0.0
depreciationPercent := 0.0
for day := 1; day <= dayN; day++ {
for day := startDay; day <= endDay; day++ {
pct := housePercent[day]
dep := current * (pct / 100)
if day == dayN {
if day == endDay {
pulletCostDayN = current
depreciationValue = dep
depreciationPercent = pct
@@ -43,6 +43,27 @@ func TestCalculateDepreciationAtDayN_UsesRemainingBasisRecursively(t *testing.T)
}
}
func TestCalculateDepreciationFromDayRange_StartsFromProvidedScheduleDay(t *testing.T) {
percentByHouseType := map[string]map[int]float64{
"close_house": {
1: 10,
2: 20,
3: 5,
},
}
pulletCostDayN, depreciationValue, depreciationPercent := CalculateDepreciationFromDayRange(1000, 2, 3, "close_house", percentByHouseType)
if pulletCostDayN != 800 {
t.Fatalf("expected remaining basis entering day 3 to be 800, got %v", pulletCostDayN)
}
if depreciationValue != 40 {
t.Fatalf("expected day 3 depreciation to be 40, got %v", depreciationValue)
}
if depreciationPercent != 5 {
t.Fatalf("expected day 3 depreciation percent to be 5, got %v", depreciationPercent)
}
}
func mustDepreciationDate(t *testing.T, raw string) time.Time {
t.Helper()
@@ -29,8 +29,10 @@ type HppV2Reference struct {
type HppV2ComponentPart struct {
Code string `json:"code"`
Title string `json:"title"`
Scopes []string `json:"scopes,omitempty"`
Total float64 `json:"total"`
Proration *HppV2Proration `json:"proration,omitempty"`
Details map[string]any `json:"details,omitempty"`
References []HppV2Reference `json:"references,omitempty"`
}
+368 -12
View File
@@ -15,6 +15,8 @@ const (
hppV2ComponentDirectPulletPurchase = "DIRECT_PULLET_PURCHASE"
hppV2ComponentBopRegular = "BOP_REGULAR"
hppV2ComponentBopEksp = "BOP_EKSPEDISI"
hppV2ComponentManualPulletCost = "MANUAL_PULLET_COST"
hppV2ComponentDepreciation = "DEPRECIATION"
hppV2PartGrowingNormal = "growing_normal"
hppV2PartGrowingCutover = "growing_cutover"
hppV2PartLayingNormal = "laying_normal"
@@ -23,6 +25,9 @@ const (
hppV2PartGrowingFarm = "growing_farm"
hppV2PartLayingDirect = "laying_direct"
hppV2PartLayingFarm = "laying_farm"
hppV2PartManualCutover = "manual_cutover"
hppV2PartDepreciationNormal = "normal_transfer"
hppV2PartDepreciationCutover = "manual_cutover"
hppV2ProrationPopulation = "growing_population_share"
hppV2ProrationEggWeight = "laying_egg_weight_share"
hppV2ProrationEggPiece = "laying_egg_piece_share"
@@ -109,18 +114,14 @@ func (s *hppV2Service) CalculateHppBreakdown(projectFlockKandangId uint, date *t
totalPulletCost := 0.0
totalProductionCost := 0.0
components := make([]HppV2Component, 0, 6)
components := make([]HppV2Component, 0, 8)
appendComponent := func(component *HppV2Component) {
if component == nil || (component.Total == 0 && len(component.Parts) == 0) {
return
}
components = append(components, *component)
if componentHasScope(component, hppV2ScopePulletCost) {
totalPulletCost += component.Total
}
if componentHasScope(component, hppV2ScopeProductionCost) {
totalProductionCost += component.Total
}
totalPulletCost += componentScopeTotal(component, hppV2ScopePulletCost)
totalProductionCost += componentScopeTotal(component, hppV2ScopeProductionCost)
}
appendComponent(pakanComponent)
@@ -154,6 +155,18 @@ func (s *hppV2Service) CalculateHppBreakdown(projectFlockKandangId uint, date *t
}
appendComponent(bopEkspedisiComponent)
manualPulletComponent, err := s.getManualPulletCostComponent(projectFlockKandangId, contextRow, startOfDay)
if err != nil {
return nil, err
}
appendComponent(manualPulletComponent)
depreciationComponent, err := s.getDepreciationComponent(projectFlockKandangId, contextRow, startOfDay, totalPulletCost)
if err != nil {
return nil, err
}
appendComponent(depreciationComponent)
hppCost, err := s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, &startOfDay, &endOfDay)
if err != nil {
return nil, err
@@ -527,6 +540,7 @@ func (s *hppV2Service) buildGrowingChickinPart(
rows,
partCode,
partTitle,
[]string{hppV2ScopePulletCost},
&HppV2Proration{
Basis: hppV2ProrationPopulation,
Numerator: transferTotalQty,
@@ -550,7 +564,7 @@ func (s *hppV2Service) buildLayingChickinPart(
return nil, err
}
return buildChickinPartFromRows(rows, partCode, partTitle, nil, 1), nil
return buildChickinPartFromRows(rows, partCode, partTitle, []string{hppV2ScopeProductionCost}, nil, 1), nil
}
func (s *hppV2Service) buildGrowingUsagePart(
@@ -653,9 +667,10 @@ func (s *hppV2Service) buildGrowingUsagePart(
}
return &HppV2ComponentPart{
Code: partCode,
Title: partTitle,
Total: baseTotal * ratio,
Code: partCode,
Title: partTitle,
Scopes: []string{hppV2ScopePulletCost},
Total: baseTotal * ratio,
Proration: &HppV2Proration{
Basis: hppV2ProrationPopulation,
Numerator: transferTotalQty,
@@ -703,6 +718,7 @@ func (s *hppV2Service) buildLayingUsagePart(
return &HppV2ComponentPart{
Code: hppV2PartLayingCutover,
Title: "Laying Cut-over",
Scopes: []string{hppV2ScopeProductionCost},
Total: total,
References: references,
}, nil
@@ -741,6 +757,7 @@ func (s *hppV2Service) buildLayingUsagePart(
return &HppV2ComponentPart{
Code: hppV2PartLayingNormal,
Title: "Laying",
Scopes: []string{hppV2ScopeProductionCost},
Total: total,
References: references,
}, nil
@@ -818,6 +835,7 @@ func (s *hppV2Service) buildGrowingExpensePart(
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,
@@ -838,7 +856,7 @@ func (s *hppV2Service) buildLayingExpenseDirectPart(
return nil, err
}
return buildExpensePartFromRows(rows, hppV2PartLayingDirect, "Laying Direct", nil, 1), nil
return buildExpensePartFromRows(rows, hppV2PartLayingDirect, "Laying Direct", []string{hppV2ScopeProductionCost}, nil, 1), nil
}
func (s *hppV2Service) buildLayingExpenseFarmPart(
@@ -893,6 +911,7 @@ func (s *hppV2Service) buildLayingExpenseFarmPart(
rows,
hppV2PartLayingFarm,
"Laying Farm",
[]string{hppV2ScopeProductionCost},
&HppV2Proration{
Basis: basis,
Numerator: numerator,
@@ -903,6 +922,294 @@ func (s *hppV2Service) buildLayingExpenseFarmPart(
), nil
}
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) getDepreciationComponent(
projectFlockKandangId uint,
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
periodDate time.Time,
totalPulletCost float64,
) (*HppV2Component, error) {
if s.hppRepo == nil || contextRow == nil || totalPulletCost <= 0 {
return nil, nil
}
transferInput, err := s.hppRepo.GetLatestTransferInputByProjectFlockKandangID(context.Background(), projectFlockKandangId, periodDate)
if err != nil {
return nil, err
}
var part *HppV2ComponentPart
if transferInput != nil && transferInput.SourceProjectFlockID > 0 {
part, err = s.buildNormalTransferDepreciationPart(contextRow, transferInput, periodDate, totalPulletCost)
if err != nil {
return nil, err
}
} else {
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) 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)
percentByHouseType, err := s.hppRepo.GetDepreciationPercents(context.Background(), []string{houseType}, scheduleDay)
if err != nil {
return nil, err
}
pulletCostDayN, depreciationValue, depreciationPercent := CalculateDepreciationAtDayN(
totalPulletCost,
scheduleDay,
contextRow.HouseType,
percentByHouseType,
)
if depreciationValue <= 0 {
return nil, nil
}
return &HppV2ComponentPart{
Code: hppV2PartDepreciationNormal,
Title: "Normal Transfer",
Scopes: []string{hppV2ScopeProductionCost},
Total: depreciationValue,
Details: map[string]any{
"basis_total": totalPulletCost,
"pullet_cost_day_n": pulletCostDayN,
"depreciation_percent": depreciationPercent,
"schedule_day": scheduleDay,
"origin_date": formatDateOnly(*originDate),
"transfer_date": formatDateOnly(transferInput.TransferDate),
"source_project_flock_id": transferInput.SourceProjectFlockID,
},
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 || totalPulletCost <= 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
}
originDate, err := s.hppRepo.GetEarliestChickInDateByProjectFlockID(context.Background(), contextRow.ProjectFlockID)
if err != nil {
return nil, err
}
if originDate == nil || originDate.IsZero() {
return nil, nil
}
reportScheduleDay := DepreciationScheduleDay(*originDate, periodDate, contextRow.HouseType)
if reportScheduleDay <= 0 {
return nil, nil
}
cutoverScheduleDay := DepreciationScheduleDay(*originDate, manualInput.CutoverDate, contextRow.HouseType)
startDay := 1
if cutoverScheduleDay > 0 {
startDay = cutoverScheduleDay
}
houseType := NormalizeDepreciationHouseType(contextRow.HouseType)
percentByHouseType, err := s.hppRepo.GetDepreciationPercents(context.Background(), []string{houseType}, reportScheduleDay)
if err != nil {
return nil, err
}
pulletCostDayN, depreciationValue, depreciationPercent := CalculateDepreciationFromDayRange(
totalPulletCost,
startDay,
reportScheduleDay,
contextRow.HouseType,
percentByHouseType,
)
if depreciationValue <= 0 {
return nil, nil
}
return &HppV2ComponentPart{
Code: hppV2PartDepreciationCutover,
Title: "Manual Cut-over",
Scopes: []string{hppV2ScopeProductionCost},
Total: depreciationValue,
Details: map[string]any{
"basis_total": totalPulletCost,
"pullet_cost_day_n": pulletCostDayN,
"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,
},
References: []HppV2Reference{
{
Type: "farm_depreciation_manual_input",
ID: manualInput.ID,
Date: formatDateOnly(manualInput.CutoverDate),
Qty: 1,
Total: totalPulletCost,
AppliedTotal: depreciationValue,
},
},
}, nil
}
func (s *hppV2Service) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) {
if s.hppRepo == nil {
return &HppCostResponse{}, nil
@@ -975,6 +1282,7 @@ func buildExpensePartFromRows(
rows []commonRepo.HppV2ExpenseCostRow,
code string,
title string,
scopes []string,
proration *HppV2Proration,
ratio float64,
) *HppV2ComponentPart {
@@ -1005,6 +1313,7 @@ func buildExpensePartFromRows(
return &HppV2ComponentPart{
Code: code,
Title: title,
Scopes: append([]string{}, scopes...),
Total: total,
Proration: proration,
References: references,
@@ -1015,6 +1324,7 @@ func buildChickinPartFromRows(
rows []commonRepo.HppV2ChickinCostRow,
code string,
title string,
scopes []string,
proration *HppV2Proration,
ratio float64,
) *HppV2ComponentPart {
@@ -1048,6 +1358,7 @@ func buildChickinPartFromRows(
return &HppV2ComponentPart{
Code: code,
Title: title,
Scopes: append([]string{}, scopes...),
Total: total,
Proration: proration,
References: references,
@@ -1065,3 +1376,48 @@ func componentHasScope(component *HppV2Component, scope string) bool {
}
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, value.Location())
}
func formatDateOnly(value time.Time) string {
return dateOnly(value).Format("2006-01-02")
}
@@ -15,6 +15,10 @@ import (
type hppV2RepoStub struct {
contextByPFK map[uint]*commonRepo.HppV2ProjectFlockKandangContext
pfkIDsByProject map[uint][]uint
latestTransferByPFK map[uint]*commonRepo.HppV2LatestTransferInputRow
manualInputByProject map[uint]*commonRepo.HppV2ManualDepreciationInputRow
chickInDateByProject map[uint]*time.Time
depreciationByHouse map[string]map[int]float64
usageRowsByKey map[string][]commonRepo.HppV2UsageCostRow
adjustRowsByKey map[string][]commonRepo.HppV2AdjustmentCostRow
chickinRowsByKey map[string][]commonRepo.HppV2ChickinCostRow
@@ -47,6 +51,35 @@ func (s *hppV2RepoStub) GetProjectFlockKandangIDs(_ context.Context, projectFloc
return append([]uint{}, s.pfkIDsByProject[projectFlockId]...), nil
}
func (s *hppV2RepoStub) GetLatestTransferInputByProjectFlockKandangID(_ context.Context, projectFlockKandangId uint, _ time.Time) (*commonRepo.HppV2LatestTransferInputRow, error) {
return s.latestTransferByPFK[projectFlockKandangId], nil
}
func (s *hppV2RepoStub) GetManualDepreciationInputByProjectFlockID(_ context.Context, projectFlockID uint) (*commonRepo.HppV2ManualDepreciationInputRow, error) {
return s.manualInputByProject[projectFlockID], nil
}
func (s *hppV2RepoStub) GetEarliestChickInDateByProjectFlockID(_ context.Context, projectFlockID uint) (*time.Time, error) {
return s.chickInDateByProject[projectFlockID], nil
}
func (s *hppV2RepoStub) GetDepreciationPercents(_ context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, error) {
result := make(map[string]map[int]float64)
for _, houseType := range houseTypes {
source := s.depreciationByHouse[houseType]
if len(source) == 0 {
continue
}
result[houseType] = make(map[int]float64)
for day, pct := range source {
if day <= maxDay {
result[houseType][day] = pct
}
}
}
return result, nil
}
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
}
@@ -161,8 +194,11 @@ func TestHppV2CalculateHppBreakdown_ComposesPakanSlices(t *testing.T) {
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 got := result.TotalPulletCost; got != 1150 {
t.Fatalf("expected total pullet cost 1150, got %v", got)
}
if got := result.TotalProductionCost; got != 1800 {
t.Fatalf("expected total production cost 1800, got %v", got)
}
if len(result.Components) != 1 {
t.Fatalf("expected 1 component, got %d", len(result.Components))
@@ -190,11 +226,11 @@ func TestHppV2CalculateHppBreakdown_ComposesPakanSlices(t *testing.T) {
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.Estimation.HargaKg != 180 {
t.Fatalf("expected estimation harga/kg 180, 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)
if result.Hpp.Real.HargaKg != 450 {
t.Fatalf("expected real harga/kg 450, got %v", result.Hpp.Real.HargaKg)
}
}
@@ -336,11 +372,14 @@ func TestHppV2CalculateHppBreakdown_IncludesOvkComponent(t *testing.T) {
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.TotalPulletCost != 250 {
t.Fatalf("expected total pullet cost 250, got %v", result.TotalPulletCost)
}
if result.Hpp.Estimation.HargaKg != 79.17 {
t.Fatalf("expected estimation harga/kg 79.17, got %v", result.Hpp.Estimation.HargaKg)
if result.TotalProductionCost != 700 {
t.Fatalf("expected total production cost 700, got %v", result.TotalProductionCost)
}
if result.Hpp.Estimation.HargaKg != 58.33 {
t.Fatalf("expected estimation harga/kg 58.33, got %v", result.Hpp.Estimation.HargaKg)
}
}
@@ -503,11 +542,204 @@ func TestHppV2CalculateHppBreakdown_IncludesBopRegularAndEkspedisi(t *testing.T)
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.TotalPulletCost != 190 {
t.Fatalf("expected total pullet cost 190, got %v", result.TotalPulletCost)
}
if result.Hpp.Estimation.HargaKg != 119.33 {
t.Fatalf("expected estimation harga/kg 119.33, got %v", result.Hpp.Estimation.HargaKg)
if result.TotalProductionCost != 168 {
t.Fatalf("expected total production cost 168, got %v", result.TotalProductionCost)
}
if result.Hpp.Estimation.HargaKg != 56 {
t.Fatalf("expected estimation harga/kg 56, got %v", result.Hpp.Estimation.HargaKg)
}
}
func TestHppV2CalculateHppBreakdown_AddsDepreciationForNormalTransfer(t *testing.T) {
sourceChickIn := mustTime(t, "2026-01-01")
reportDate := sourceChickIn.AddDate(0, 0, 154)
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
50: {
ProjectFlockKandangID: 50,
ProjectFlockID: 10,
ProjectFlockCategory: "LAYING",
KandangID: 500,
KandangName: "Kandang F",
LocationID: 21,
HouseType: "close_house",
},
},
pfkIDsByProject: map[uint][]uint{
11: {501},
},
latestTransferByPFK: map[uint]*commonRepo.HppV2LatestTransferInputRow{
50: {
ProjectFlockKandangID: 50,
SourceProjectFlockID: 11,
TransferDate: mustTime(t, "2026-05-20"),
TransferQty: 100,
TransferID: 701,
},
},
chickInDateByProject: map[uint]*time.Time{
11: &sourceChickIn,
},
depreciationByHouse: map[string]map[int]float64{
"close_house": {
1: 10,
},
},
usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{
stubKey([]uint{501}, []string{"PAKAN"}): {
{StockableType: "purchase_items", StockableID: 9301, SourceProductID: 41, SourceProductName: "Pakan Growing", Qty: 25, UnitPrice: 40, TotalCost: 1000},
},
},
totalPopulationByKey: map[string]float64{
stubKey([]uint{501}, nil): 100,
},
transferSummaryByPFK: map[uint]struct {
projectFlockID uint
totalQty float64
}{
50: {projectFlockID: 11, totalQty: 100},
},
eggProductionByPFK: map[uint]struct {
pieces float64
kg float64
}{
50: {pieces: 20, kg: 10},
},
}
svc := NewHppV2Service(repo)
result, err := svc.CalculateHppBreakdown(50, &reportDate)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.TotalPulletCost != 1000 {
t.Fatalf("expected total pullet cost 1000, got %v", result.TotalPulletCost)
}
if result.TotalProductionCost != 100 {
t.Fatalf("expected total production cost 100, got %v", result.TotalProductionCost)
}
var depreciation *HppV2Component
for i := range result.Components {
if result.Components[i].Code == hppV2ComponentDepreciation {
depreciation = &result.Components[i]
break
}
}
if depreciation == nil {
t.Fatal("expected depreciation component")
}
if depreciation.Total != 100 {
t.Fatalf("expected depreciation total 100, got %v", depreciation.Total)
}
if len(depreciation.Parts) != 1 {
t.Fatalf("expected single depreciation part, got %d", len(depreciation.Parts))
}
if depreciation.Parts[0].Details["schedule_day"] != 1 {
t.Fatalf("expected schedule day 1, got %+v", depreciation.Parts[0].Details)
}
if depreciation.Parts[0].Details["origin_date"] != "2026-01-01" {
t.Fatalf("expected origin date 2026-01-01, got %+v", depreciation.Parts[0].Details)
}
if result.Hpp.Estimation.HargaKg != 10 {
t.Fatalf("expected estimation harga/kg 10, got %v", result.Hpp.Estimation.HargaKg)
}
}
func TestHppV2CalculateHppBreakdown_AddsDepreciationForManualCutoverFromCutoverDate(t *testing.T) {
originDate := mustTime(t, "2026-01-01")
cutoverDate := originDate.AddDate(0, 0, 155)
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
60: {
ProjectFlockKandangID: 60,
ProjectFlockID: 12,
ProjectFlockCategory: "LAYING",
KandangID: 600,
KandangName: "Kandang G",
LocationID: 22,
HouseType: "close_house",
},
},
pfkIDsByProject: map[uint][]uint{
12: {60},
},
manualInputByProject: map[uint]*commonRepo.HppV2ManualDepreciationInputRow{
12: {
ID: 801,
ProjectFlockID: 12,
TotalCost: 1000,
CutoverDate: cutoverDate,
},
},
chickInDateByProject: map[uint]*time.Time{
12: &originDate,
},
depreciationByHouse: map[string]map[int]float64{
"close_house": {
1: 10,
2: 20,
},
},
totalPopulationByKey: map[string]float64{
stubKey([]uint{60}, nil): 100,
},
eggProductionByPFK: map[uint]struct {
pieces float64
kg float64
}{
60: {pieces: 20, kg: 10},
},
}
svc := NewHppV2Service(repo)
result, err := svc.CalculateHppBreakdown(60, &cutoverDate)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.TotalPulletCost != 1000 {
t.Fatalf("expected total pullet cost 1000, got %v", result.TotalPulletCost)
}
if result.TotalProductionCost != 200 {
t.Fatalf("expected total production cost 200, got %v", result.TotalProductionCost)
}
componentTotals := map[string]float64{}
for _, component := range result.Components {
componentTotals[component.Code] = component.Total
}
if componentTotals[hppV2ComponentManualPulletCost] != 1000 {
t.Fatalf("expected manual pullet cost 1000, got %v", componentTotals[hppV2ComponentManualPulletCost])
}
if componentTotals[hppV2ComponentDepreciation] != 200 {
t.Fatalf("expected depreciation 200, got %v", componentTotals[hppV2ComponentDepreciation])
}
var depreciation *HppV2Component
for i := range result.Components {
if result.Components[i].Code == hppV2ComponentDepreciation {
depreciation = &result.Components[i]
break
}
}
if depreciation == nil || len(depreciation.Parts) != 1 {
t.Fatalf("expected one depreciation part, got %+v", depreciation)
}
if depreciation.Parts[0].Details["schedule_day"] != 2 {
t.Fatalf("expected schedule day 2, got %+v", depreciation.Parts[0].Details)
}
if depreciation.Parts[0].Details["start_schedule_day"] != 2 {
t.Fatalf("expected start schedule day 2, got %+v", depreciation.Parts[0].Details)
}
if result.Hpp.Estimation.HargaKg != 20 {
t.Fatalf("expected estimation harga/kg 20, got %v", result.Hpp.Estimation.HargaKg)
}
}