From 69d6fc165a839bb82b933d639717e9eabf5b17c1 Mon Sep 17 00:00:00 2001 From: Adnan Zahir Date: Sun, 19 Apr 2026 15:10:53 +0700 Subject: [PATCH] feat: manual pullet cost --- .../repository/common.hppv2.repository.go | 164 ++++++++ .../service/common.depreciation.service.go | 22 +- .../common.depreciation.service_test.go | 21 + internal/common/service/common.hppv2.model.go | 2 + .../common/service/common.hppv2.service.go | 380 +++++++++++++++++- .../service/common.hppv2.service_test.go | 260 +++++++++++- ...o_farm_depreciation_manual_inputs.down.sql | 4 + ..._to_farm_depreciation_manual_inputs.up.sql | 12 + .../farm_depreciation_manual_input.go | 1 + .../dto/repportExpenseDepreciation.dto.go | 1 + .../expense_depreciation.repository.go | 11 +- .../repports/services/repport.service.go | 11 + .../validations/repport.validation.go | 1 + 13 files changed, 857 insertions(+), 33 deletions(-) create mode 100644 internal/database/migrations/20260419103000_add_cutover_date_to_farm_depreciation_manual_inputs.down.sql create mode 100644 internal/database/migrations/20260419103000_add_cutover_date_to_farm_depreciation_manual_inputs.up.sql diff --git a/internal/common/repository/common.hppv2.repository.go b/internal/common/repository/common.hppv2.repository.go index 352ca11e..80fe7438 100644 --- a/internal/common/repository/common.hppv2.repository.go +++ b/internal/common/repository/common.hppv2.repository.go @@ -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, diff --git a/internal/common/service/common.depreciation.service.go b/internal/common/service/common.depreciation.service.go index b0cd9497..6f12e077 100644 --- a/internal/common/service/common.depreciation.service.go +++ b/internal/common/service/common.depreciation.service.go @@ -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 diff --git a/internal/common/service/common.depreciation.service_test.go b/internal/common/service/common.depreciation.service_test.go index c05935e6..6897f926 100644 --- a/internal/common/service/common.depreciation.service_test.go +++ b/internal/common/service/common.depreciation.service_test.go @@ -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() diff --git a/internal/common/service/common.hppv2.model.go b/internal/common/service/common.hppv2.model.go index 82c36ef0..faf7cb33 100644 --- a/internal/common/service/common.hppv2.model.go +++ b/internal/common/service/common.hppv2.model.go @@ -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"` } diff --git a/internal/common/service/common.hppv2.service.go b/internal/common/service/common.hppv2.service.go index 3c753c55..bebbc9b3 100644 --- a/internal/common/service/common.hppv2.service.go +++ b/internal/common/service/common.hppv2.service.go @@ -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") +} diff --git a/internal/common/service/common.hppv2.service_test.go b/internal/common/service/common.hppv2.service_test.go index f3dd0747..574bad8a 100644 --- a/internal/common/service/common.hppv2.service_test.go +++ b/internal/common/service/common.hppv2.service_test.go @@ -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) } } diff --git a/internal/database/migrations/20260419103000_add_cutover_date_to_farm_depreciation_manual_inputs.down.sql b/internal/database/migrations/20260419103000_add_cutover_date_to_farm_depreciation_manual_inputs.down.sql new file mode 100644 index 00000000..0dce0ea1 --- /dev/null +++ b/internal/database/migrations/20260419103000_add_cutover_date_to_farm_depreciation_manual_inputs.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_farm_depreciation_manual_inputs_cutover_date; + +ALTER TABLE farm_depreciation_manual_inputs + DROP COLUMN IF EXISTS cutover_date; diff --git a/internal/database/migrations/20260419103000_add_cutover_date_to_farm_depreciation_manual_inputs.up.sql b/internal/database/migrations/20260419103000_add_cutover_date_to_farm_depreciation_manual_inputs.up.sql new file mode 100644 index 00000000..20abc16e --- /dev/null +++ b/internal/database/migrations/20260419103000_add_cutover_date_to_farm_depreciation_manual_inputs.up.sql @@ -0,0 +1,12 @@ +ALTER TABLE farm_depreciation_manual_inputs + ADD COLUMN IF NOT EXISTS cutover_date DATE; + +UPDATE farm_depreciation_manual_inputs +SET cutover_date = COALESCE(cutover_date, DATE(created_at)) +WHERE cutover_date IS NULL; + +ALTER TABLE farm_depreciation_manual_inputs + ALTER COLUMN cutover_date SET NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_farm_depreciation_manual_inputs_cutover_date + ON farm_depreciation_manual_inputs (cutover_date); diff --git a/internal/entities/farm_depreciation_manual_input.go b/internal/entities/farm_depreciation_manual_input.go index 2e10ee56..ee4f9989 100644 --- a/internal/entities/farm_depreciation_manual_input.go +++ b/internal/entities/farm_depreciation_manual_input.go @@ -6,6 +6,7 @@ type FarmDepreciationManualInput struct { Id uint `gorm:"primaryKey"` ProjectFlockId uint `gorm:"not null;uniqueIndex:idx_farm_depreciation_manual_inputs_unique"` TotalCost float64 `gorm:"type:numeric(18,3);not null;default:0"` + CutoverDate time.Time `gorm:"type:date;not null"` Note *string `gorm:"type:text"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` diff --git a/internal/modules/repports/dto/repportExpenseDepreciation.dto.go b/internal/modules/repports/dto/repportExpenseDepreciation.dto.go index a968da9c..e7e3f4fd 100644 --- a/internal/modules/repports/dto/repportExpenseDepreciation.dto.go +++ b/internal/modules/repports/dto/repportExpenseDepreciation.dto.go @@ -30,6 +30,7 @@ type ExpenseDepreciationManualInputRowDTO struct { ProjectFlockID int64 `json:"project_flock_id"` FarmName string `json:"farm_name"` TotalCost float64 `json:"total_cost"` + CutoverDate string `json:"cutover_date"` Note *string `json:"note"` } diff --git a/internal/modules/repports/repositories/expense_depreciation.repository.go b/internal/modules/repports/repositories/expense_depreciation.repository.go index c9897a1a..7e058a0b 100644 --- a/internal/modules/repports/repositories/expense_depreciation.repository.go +++ b/internal/modules/repports/repositories/expense_depreciation.repository.go @@ -33,6 +33,7 @@ type FarmDepreciationManualInputRow struct { ProjectFlockID uint FarmName string TotalCost float64 + CutoverDate time.Time Note *string } @@ -271,6 +272,7 @@ func (r *expenseDepreciationRepository) GetLatestManualInputsByFarms( fdmi.project_flock_id AS project_flock_id, pf.flock_name AS farm_name, fdmi.total_cost AS total_cost, + fdmi.cutover_date AS cutover_date, fdmi.note AS note `). Joins("JOIN project_flocks AS pf ON pf.id = fdmi.project_flock_id"). @@ -308,9 +310,10 @@ func (r *expenseDepreciationRepository) UpsertManualInput(ctx context.Context, r {Name: "project_flock_id"}, }, DoUpdates: clause.Assignments(map[string]any{ - "total_cost": row.TotalCost, - "note": row.Note, - "updated_at": now, + "total_cost": row.TotalCost, + "cutover_date": row.CutoverDate, + "note": row.Note, + "updated_at": now, }), }). Create(row).Error @@ -320,7 +323,7 @@ func (r *expenseDepreciationRepository) UpsertManualInput(ctx context.Context, r return r.db.WithContext(ctx). Table("farm_depreciation_manual_inputs"). - Select("id, project_flock_id, total_cost, note"). + Select("id, project_flock_id, total_cost, cutover_date, note"). Where("project_flock_id = ?", row.ProjectFlockId). Take(row).Error } diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 87a0605a..edd90f04 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -347,6 +347,7 @@ func (s *repportService) GetExpenseDepreciationManualInputs(ctx *fiber.Ctx) ([]d ProjectFlockID: int64(row.ProjectFlockID), FarmName: row.FarmName, TotalCost: row.TotalCost, + CutoverDate: row.CutoverDate.Format("2006-01-02"), Note: row.Note, }) } @@ -397,10 +398,19 @@ func (s *repportService) UpsertExpenseDepreciationManualInput(ctx *fiber.Ctx, re if s.ExpenseDepreciationRepo == nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "expense depreciation repository is not configured") } + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration") + } + cutoverDate, err := time.ParseInLocation("2006-01-02", req.CutoverDate, location) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "cutover_date must follow format YYYY-MM-DD") + } row := entity.FarmDepreciationManualInput{ ProjectFlockId: req.ProjectFlockID, TotalCost: req.TotalCost, + CutoverDate: cutoverDate, Note: req.Note, } if err := s.ExpenseDepreciationRepo.UpsertManualInput(ctx.Context(), &row); err != nil { @@ -411,6 +421,7 @@ func (s *repportService) UpsertExpenseDepreciationManualInput(ctx *fiber.Ctx, re ID: int64(row.Id), ProjectFlockID: int64(row.ProjectFlockId), TotalCost: row.TotalCost, + CutoverDate: row.CutoverDate.Format("2006-01-02"), Note: row.Note, } diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index 27f1d741..f34e2702 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -92,6 +92,7 @@ type ExpenseDepreciationQuery struct { type ExpenseDepreciationManualInputUpsert struct { ProjectFlockID uint `json:"project_flock_id" validate:"required,gt=0"` TotalCost float64 `json:"total_cost" validate:"required,gte=0"` + CutoverDate string `json:"cutover_date" validate:"required,datetime=2006-01-02"` Note *string `json:"note" validate:"omitempty,max=1000"` }