From a2ae139fae2a6f166082dbd13d7608c0be999c68 Mon Sep 17 00:00:00 2001 From: Adnan Zahir Date: Sun, 19 Apr 2026 14:52:01 +0700 Subject: [PATCH] feat: doc direct purchase cost --- .../repository/common.hppv2.repository.go | 189 ++++++++++ .../service/common.depreciation.service.go | 88 +++++ .../common.depreciation.service_test.go | 60 ++++ internal/common/service/common.hppv2.model.go | 10 +- .../common/service/common.hppv2.service.go | 332 +++++++++++++++--- .../service/common.hppv2.service_test.go | 83 +++++ .../repports/services/repport.service.go | 64 +--- 7 files changed, 718 insertions(+), 108 deletions(-) create mode 100644 internal/common/service/common.depreciation.service.go create mode 100644 internal/common/service/common.depreciation.service_test.go diff --git a/internal/common/repository/common.hppv2.repository.go b/internal/common/repository/common.hppv2.repository.go index ccba7120..352ca11e 100644 --- a/internal/common/repository/common.hppv2.repository.go +++ b/internal/common/repository/common.hppv2.repository.go @@ -59,6 +59,19 @@ type HppV2ExpenseCostRow struct { RealizationDate time.Time } +type HppV2ChickinCostRow struct { + ProjectChickinID uint + ProjectFlockKandangID uint + ChickInDate time.Time + StockableType string + StockableID uint + SourceProductID uint + SourceProductName string + Qty float64 + UnitPrice float64 + TotalCost float64 +} + type HppV2CostRepository interface { GetProjectFlockKandangContext(ctx context.Context, projectFlockKandangId uint) (*HppV2ProjectFlockKandangContext, error) GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error) @@ -66,6 +79,7 @@ type HppV2CostRepository interface { 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) ListExpenseRealizationRowsByProjectFlockID(ctx context.Context, projectFlockID uint, date *time.Time, ekspedisi bool) ([]HppV2ExpenseCostRow, error) + ListChickinCostRowsByProductFlags(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string, date *time.Time, excludeTransferToLaying bool) ([]HppV2ChickinCostRow, error) GetFeedUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error) GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) @@ -251,6 +265,181 @@ func (r *HppV2RepositoryImpl) ListAdjustmentCostRowsByProductFlags( return rows, nil } +func (r *HppV2RepositoryImpl) ListChickinCostRowsByProductFlags( + ctx context.Context, + projectFlockKandangIDs []uint, + flagNames []string, + date *time.Time, + excludeTransferToLaying bool, +) ([]HppV2ChickinCostRow, error) { + if len(projectFlockKandangIDs) == 0 || len(flagNames) == 0 { + return []HppV2ChickinCostRow{}, nil + } + if date == nil { + now := time.Now() + date = &now + } + + stockablePurchase := fifo.StockableKeyPurchaseItems.String() + stockableAdjustment := fifo.StockableKeyAdjustmentIn.String() + stockableTransferIn := fifo.StockableKeyStockTransferIn.String() + stockableTransferToLaying := fifo.StockableKeyTransferToLayingIn.String() + usableProjectChickin := fifo.UsableKeyProjectChickin.String() + usableStockTransferOut := fifo.UsableKeyStockTransferOut.String() + + rows := make([]HppV2ChickinCostRow, 0) + query := r.db.WithContext(ctx). + Table("project_chickins AS pc"). + Select(` + pc.id AS project_chickin_id, + pc.project_flock_kandang_id AS project_flock_kandang_id, + pc.chick_in_date AS chick_in_date, + sa.stockable_type AS stockable_type, + sa.stockable_id AS stockable_id, + COALESCE( + pi.product_id, + ast_pw.product_id, + tpi.product_id, + tast_pw.product_id, + spi.product_id, + sast_pw.product_id, + 0 + ) AS source_product_id, + COALESCE( + pi_prod.name, + ast_prod.name, + tpi_prod.name, + tast_prod.name, + spi_prod.name, + sast_prod.name, + '' + ) AS source_product_name, + COALESCE(SUM(sa.qty), 0) AS qty, + CASE + WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(spi.price, sast.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, tast.price, 0) + ELSE 0 + END AS unit_price, + COALESCE(SUM(sa.qty * CASE + WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(spi.price, sast.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, tast.price, 0) + ELSE 0 + END), 0) AS total_cost + `, + stockablePurchase, + stockableAdjustment, + stockableTransferIn, + stockableTransferToLaying, + stockablePurchase, + stockableAdjustment, + stockableTransferIn, + stockableTransferToLaying, + ). + Joins( + "JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.status = ? AND sa.allocation_purpose = ?", + usableProjectChickin, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeTraceChickin, + ). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase). + Joins("LEFT JOIN products AS pi_prod ON pi_prod.id = pi.product_id"). + Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment). + Joins("LEFT JOIN product_warehouses AS ast_pw ON ast_pw.id = ast.product_warehouse_id"). + Joins("LEFT JOIN products AS ast_prod ON ast_prod.id = ast_pw.product_id"). + Joins( + "LEFT JOIN stock_allocations AS tsa_transfer ON tsa_transfer.usable_type = ? AND tsa_transfer.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa_transfer.status = ? AND tsa_transfer.allocation_purpose = ?", + stockableTransferToLaying, + stockableTransferToLaying, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Joins("LEFT JOIN purchase_items AS tpi ON tpi.id = tsa_transfer.stockable_id AND tsa_transfer.stockable_type = ?", stockablePurchase). + Joins("LEFT JOIN products AS tpi_prod ON tpi_prod.id = tpi.product_id"). + Joins("LEFT JOIN adjustment_stocks AS tast ON tast.id = tsa_transfer.stockable_id AND tsa_transfer.stockable_type = ?", stockableAdjustment). + Joins("LEFT JOIN product_warehouses AS tast_pw ON tast_pw.id = tast.product_warehouse_id"). + Joins("LEFT JOIN products AS tast_prod ON tast_prod.id = tast_pw.product_id"). + Joins( + "LEFT JOIN stock_allocations AS tsa_stock ON tsa_stock.usable_type = ? AND tsa_stock.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa_stock.status = ? AND tsa_stock.allocation_purpose = ?", + usableStockTransferOut, + stockableTransferIn, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Joins("LEFT JOIN purchase_items AS spi ON spi.id = tsa_stock.stockable_id AND tsa_stock.stockable_type = ?", stockablePurchase). + Joins("LEFT JOIN products AS spi_prod ON spi_prod.id = spi.product_id"). + Joins("LEFT JOIN adjustment_stocks AS sast ON sast.id = tsa_stock.stockable_id AND tsa_stock.stockable_type = ?", stockableAdjustment). + Joins("LEFT JOIN product_warehouses AS sast_pw ON sast_pw.id = sast.product_warehouse_id"). + Joins("LEFT JOIN products AS sast_prod ON sast_prod.id = sast_pw.product_id"). + Where("pc.project_flock_kandang_id IN ?", projectFlockKandangIDs). + Where("pc.chick_in_date <= ?", *date). + Where(` + EXISTS ( + SELECT 1 + FROM flags f + WHERE f.flagable_type = ? + AND f.flagable_id = COALESCE( + pi.product_id, + ast_pw.product_id, + tpi.product_id, + tast_pw.product_id, + spi.product_id, + sast_pw.product_id, + 0 + ) + AND f.name IN ? + ) + `, entity.FlagableTypeProduct, flagNames) + + if excludeTransferToLaying { + query = query.Where("sa.stockable_type <> ?", stockableTransferToLaying) + } + + err := query. + Group(` + pc.id, + pc.project_flock_kandang_id, + pc.chick_in_date, + sa.stockable_type, + sa.stockable_id, + COALESCE( + pi.product_id, + ast_pw.product_id, + tpi.product_id, + tast_pw.product_id, + spi.product_id, + sast_pw.product_id, + 0 + ), + COALESCE( + pi_prod.name, + ast_prod.name, + tpi_prod.name, + tast_prod.name, + spi_prod.name, + sast_prod.name, + '' + ), + CASE + WHEN sa.stockable_type = '` + stockablePurchase + `' THEN COALESCE(pi.price, 0) + WHEN sa.stockable_type = '` + stockableAdjustment + `' THEN COALESCE(ast.price, 0) + WHEN sa.stockable_type = '` + stockableTransferIn + `' THEN COALESCE(spi.price, sast.price, 0) + WHEN sa.stockable_type = '` + stockableTransferToLaying + `' THEN COALESCE(tpi.price, tast.price, 0) + ELSE 0 + END + `). + Order("pc.chick_in_date ASC, pc.id ASC, sa.stockable_type ASC, sa.stockable_id ASC"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + return rows, nil +} + func (r *HppV2RepositoryImpl) ListExpenseRealizationRowsByProjectFlockKandangIDs( ctx context.Context, projectFlockKandangIDs []uint, diff --git a/internal/common/service/common.depreciation.service.go b/internal/common/service/common.depreciation.service.go new file mode 100644 index 00000000..b0cd9497 --- /dev/null +++ b/internal/common/service/common.depreciation.service.go @@ -0,0 +1,88 @@ +package service + +import ( + "strings" + "time" +) + +const ( + depreciationStartAgeDayCloseHouse = 155 + depreciationStartAgeDayOpenHouse = 176 +) + +func NormalizeDepreciationHouseType(raw string) string { + return strings.TrimSpace(strings.ToLower(raw)) +} + +func DepreciationStartAgeDay(houseType string) int { + switch NormalizeDepreciationHouseType(houseType) { + case "close_house": + return depreciationStartAgeDayCloseHouse + case "open_house": + return depreciationStartAgeDayOpenHouse + default: + return 0 + } +} + +func FlockAgeDay(originDate time.Time, periodDate time.Time) int { + origin := time.Date(originDate.Year(), originDate.Month(), originDate.Day(), 0, 0, 0, 0, originDate.Location()) + period := time.Date(periodDate.Year(), periodDate.Month(), periodDate.Day(), 0, 0, 0, 0, periodDate.Location()) + if period.Before(origin) { + return 0 + } + return int(period.Sub(origin).Hours()/24) + 1 +} + +func DepreciationScheduleDay(originDate time.Time, periodDate time.Time, houseType string) int { + ageDay := FlockAgeDay(originDate, periodDate) + startAgeDay := DepreciationStartAgeDay(houseType) + if ageDay <= 0 || startAgeDay <= 0 || ageDay < startAgeDay { + return 0 + } + return ageDay - startAgeDay + 1 +} + +func CalculateDepreciationAtDayN( + initialPulletCost float64, + dayN int, + houseType string, + percentByHouseType map[string]map[int]float64, +) (float64, float64, float64) { + if initialPulletCost <= 0 || dayN <= 0 { + return 0, 0, 0 + } + + normalizedHouseType := NormalizeDepreciationHouseType(houseType) + housePercent, exists := percentByHouseType[normalizedHouseType] + if !exists { + return 0, 0, 0 + } + + current := initialPulletCost + pulletCostDayN := 0.0 + depreciationValue := 0.0 + depreciationPercent := 0.0 + for day := 1; day <= dayN; day++ { + pct := housePercent[day] + dep := current * (pct / 100) + if day == dayN { + pulletCostDayN = current + depreciationValue = dep + depreciationPercent = pct + } + current -= dep + if current < 0 { + current = 0 + } + } + + return pulletCostDayN, depreciationValue, depreciationPercent +} + +func CalculateEffectiveDepreciationPercent(totalDepreciationValue, totalPulletCostDayN float64) float64 { + if totalPulletCostDayN <= 0 { + return 0 + } + return (totalDepreciationValue / totalPulletCostDayN) * 100 +} diff --git a/internal/common/service/common.depreciation.service_test.go b/internal/common/service/common.depreciation.service_test.go new file mode 100644 index 00000000..c05935e6 --- /dev/null +++ b/internal/common/service/common.depreciation.service_test.go @@ -0,0 +1,60 @@ +package service + +import ( + "testing" + "time" +) + +func TestDepreciationScheduleDay_UsesHouseTypeOffsets(t *testing.T) { + openOrigin := mustDepreciationDate(t, "2026-01-01") + if got := DepreciationScheduleDay(openOrigin, mustDepreciationDate(t, "2026-06-24"), "open_house"); got != 0 { + t.Fatalf("expected open house day before start to be 0, got %d", got) + } + if got := DepreciationScheduleDay(openOrigin, mustDepreciationDate(t, "2026-06-25"), "open_house"); got != 1 { + t.Fatalf("expected open house start day to map to schedule day 1, got %d", got) + } + + closeOrigin := mustDepreciationDate(t, "2026-01-01") + if got := DepreciationScheduleDay(closeOrigin, mustDepreciationDate(t, "2026-06-03"), "close_house"); got != 0 { + t.Fatalf("expected close house day before start to be 0, got %d", got) + } + if got := DepreciationScheduleDay(closeOrigin, mustDepreciationDate(t, "2026-06-04"), "close_house"); got != 1 { + t.Fatalf("expected close house start day to map to schedule day 1, got %d", got) + } +} + +func TestCalculateDepreciationAtDayN_UsesRemainingBasisRecursively(t *testing.T) { + percentByHouseType := map[string]map[int]float64{ + "close_house": { + 1: 10, + 2: 20, + }, + } + + pulletCostDayN, depreciationValue, depreciationPercent := CalculateDepreciationAtDayN(1000, 2, "close_house", percentByHouseType) + if pulletCostDayN != 900 { + t.Fatalf("expected remaining basis entering day 2 to be 900, got %v", pulletCostDayN) + } + if depreciationValue != 180 { + t.Fatalf("expected day 2 depreciation to be 180, got %v", depreciationValue) + } + if depreciationPercent != 20 { + t.Fatalf("expected day 2 depreciation percent to be 20, got %v", depreciationPercent) + } +} + +func mustDepreciationDate(t *testing.T, raw string) time.Time { + t.Helper() + + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + t.Fatalf("failed loading timezone: %v", err) + } + + value, err := time.ParseInLocation("2006-01-02", raw, location) + if err != nil { + t.Fatalf("failed parsing date %q: %v", raw, err) + } + + return value +} diff --git a/internal/common/service/common.hppv2.model.go b/internal/common/service/common.hppv2.model.go index 134ecb81..82c36ef0 100644 --- a/internal/common/service/common.hppv2.model.go +++ b/internal/common/service/common.hppv2.model.go @@ -35,10 +35,11 @@ type HppV2ComponentPart struct { } type HppV2Component struct { - Code string `json:"code"` - Title string `json:"title"` - Total float64 `json:"total"` - Parts []HppV2ComponentPart `json:"parts"` + Code string `json:"code"` + Title string `json:"title"` + Scopes []string `json:"scopes,omitempty"` + Total float64 `json:"total"` + Parts []HppV2ComponentPart `json:"parts"` } type HppV2Breakdown struct { @@ -50,6 +51,7 @@ type HppV2Breakdown struct { LocationID uint `json:"location_id,omitempty"` PeriodDate string `json:"period_date"` Window HppV2DateWindow `json:"window"` + TotalPulletCost float64 `json:"total_pullet_cost"` TotalProductionCost float64 `json:"total_production_cost"` Components []HppV2Component `json:"components"` Hpp HppCostResponse `json:"hpp"` diff --git a/internal/common/service/common.hppv2.service.go b/internal/common/service/common.hppv2.service.go index 0bde1dfa..3c753c55 100644 --- a/internal/common/service/common.hppv2.service.go +++ b/internal/common/service/common.hppv2.service.go @@ -9,23 +9,27 @@ import ( ) const ( - hppV2ComponentPakan = "PAKAN" - hppV2ComponentOvk = "OVK" - hppV2ComponentBopRegular = "BOP_REGULAR" - hppV2ComponentBopEksp = "BOP_EKSPEDISI" - hppV2PartGrowingNormal = "growing_normal" - hppV2PartGrowingCutover = "growing_cutover" - hppV2PartLayingNormal = "laying_normal" - hppV2PartLayingCutover = "laying_cutover" - hppV2PartGrowingDirect = "growing_direct" - hppV2PartGrowingFarm = "growing_farm" - hppV2PartLayingDirect = "laying_direct" - hppV2PartLayingFarm = "laying_farm" - hppV2ProrationPopulation = "growing_population_share" - hppV2ProrationEggWeight = "laying_egg_weight_share" - hppV2ProrationEggPiece = "laying_egg_piece_share" - hppV2CutoverFlagPakan = "PAKAN-CUTOVER" - hppV2CutoverFlagOvk = "OVK-CUTOVER" + hppV2ComponentPakan = "PAKAN" + hppV2ComponentOvk = "OVK" + hppV2ComponentDocChickin = "DOC_CHICKIN" + hppV2ComponentDirectPulletPurchase = "DIRECT_PULLET_PURCHASE" + hppV2ComponentBopRegular = "BOP_REGULAR" + hppV2ComponentBopEksp = "BOP_EKSPEDISI" + hppV2PartGrowingNormal = "growing_normal" + hppV2PartGrowingCutover = "growing_cutover" + hppV2PartLayingNormal = "laying_normal" + hppV2PartLayingCutover = "laying_cutover" + hppV2PartGrowingDirect = "growing_direct" + hppV2PartGrowingFarm = "growing_farm" + hppV2PartLayingDirect = "laying_direct" + hppV2PartLayingFarm = "laying_farm" + hppV2ProrationPopulation = "growing_population_share" + hppV2ProrationEggWeight = "laying_egg_weight_share" + hppV2ProrationEggPiece = "laying_egg_piece_share" + hppV2ScopePulletCost = "pullet_cost" + hppV2ScopeProductionCost = "production_cost" + hppV2CutoverFlagPakan = "PAKAN-CUTOVER" + hppV2CutoverFlagOvk = "OVK-CUTOVER" ) type HppV2Service interface { @@ -33,10 +37,14 @@ type HppV2Service interface { 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) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) @@ -99,39 +107,52 @@ func (s *hppV2Service) CalculateHppBreakdown(projectFlockKandangId uint, date *t return nil, err } + totalPulletCost := 0.0 totalProductionCost := 0.0 - components := make([]HppV2Component, 0, 4) - if pakanComponent != nil && (pakanComponent.Total > 0 || len(pakanComponent.Parts) > 0) { - totalProductionCost += pakanComponent.Total - components = append(components, *pakanComponent) + components := make([]HppV2Component, 0, 6) + 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 + } } + appendComponent(pakanComponent) ovkComponent, err := s.GetOvkBreakdown(projectFlockKandangId, &endOfDay) if err != nil { return nil, err } - if ovkComponent != nil && (ovkComponent.Total > 0 || len(ovkComponent.Parts) > 0) { - totalProductionCost += ovkComponent.Total - components = append(components, *ovkComponent) + appendComponent(ovkComponent) + + docComponent, err := s.GetDocChickinBreakdown(projectFlockKandangId, &endOfDay) + if err != nil { + return nil, err } + appendComponent(docComponent) + + directPulletComponent, err := s.GetDirectPulletPurchaseBreakdown(projectFlockKandangId, &endOfDay) + if err != nil { + return nil, err + } + appendComponent(directPulletComponent) bopRegularComponent, err := s.GetBopRegularBreakdown(projectFlockKandangId, &endOfDay) if err != nil { return nil, err } - if bopRegularComponent != nil && (bopRegularComponent.Total > 0 || len(bopRegularComponent.Parts) > 0) { - totalProductionCost += bopRegularComponent.Total - components = append(components, *bopRegularComponent) - } + appendComponent(bopRegularComponent) bopEkspedisiComponent, err := s.GetBopEkspedisiBreakdown(projectFlockKandangId, &endOfDay) if err != nil { return nil, err } - if bopEkspedisiComponent != nil && (bopEkspedisiComponent.Total > 0 || len(bopEkspedisiComponent.Parts) > 0) { - totalProductionCost += bopEkspedisiComponent.Total - components = append(components, *bopEkspedisiComponent) - } + appendComponent(bopEkspedisiComponent) hppCost, err := s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, &startOfDay, &endOfDay) if err != nil { @@ -153,6 +174,7 @@ func (s *hppV2Service) CalculateHppBreakdown(projectFlockKandangId uint, date *t Start: startOfDay.Format(time.RFC3339), End: endOfDay.Format(time.RFC3339), }, + TotalPulletCost: totalPulletCost, TotalProductionCost: totalProductionCost, Components: components, Hpp: *hppCost, @@ -206,6 +228,88 @@ func (s *hppV2Service) GetOvkBreakdown(projectFlockKandangId uint, endDate *time }) } +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 { @@ -249,9 +353,10 @@ func (s *hppV2Service) GetBopEkspedisiBreakdown(projectFlockKandangId uint, endD func (s *hppV2Service) getStockUsageComponent(projectFlockKandangId uint, endDate *time.Time, config hppV2StockComponentConfig) (*HppV2Component, error) { if s.hppRepo == nil { return &HppV2Component{ - Code: config.Code, - Title: config.Title, - Parts: []HppV2ComponentPart{}, + Code: config.Code, + Title: config.Title, + Scopes: []string{hppV2ScopePulletCost, hppV2ScopeProductionCost}, + Parts: []HppV2ComponentPart{}, }, nil } @@ -300,19 +405,21 @@ func (s *hppV2Service) getStockUsageComponent(projectFlockKandangId uint, endDat } return &HppV2Component{ - Code: config.Code, - Title: config.Title, - Total: total, - Parts: parts, + 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, - Parts: []HppV2ComponentPart{}, + Code: config.Code, + Title: config.Title, + Scopes: []string{hppV2ScopePulletCost, hppV2ScopeProductionCost}, + Parts: []HppV2ComponentPart{}, }, nil } @@ -361,13 +468,91 @@ func (s *hppV2Service) getExpenseComponent(projectFlockKandangId uint, endDate * } return &HppV2Component{ - Code: config.Code, - Title: config.Title, - Total: total, - Parts: parts, + 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, + &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, nil, 1), nil +} + func (s *hppV2Service) buildGrowingUsagePart( projectFlockKandangId uint, contextRow *commonRepo.HppV2ProjectFlockKandangContext, @@ -825,3 +1010,58 @@ func buildExpensePartFromRows( References: references, } } + +func buildChickinPartFromRows( + rows []commonRepo.HppV2ChickinCostRow, + code string, + title string, + proration *HppV2Proration, + ratio float64, +) *HppV2ComponentPart { + if len(rows) == 0 { + return nil + } + + total := 0.0 + references := make([]HppV2Reference, 0, len(rows)) + for _, row := range rows { + total += row.TotalCost * ratio + 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, + 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 +} diff --git a/internal/common/service/common.hppv2.service_test.go b/internal/common/service/common.hppv2.service_test.go index 1a7aa2a1..f3dd0747 100644 --- a/internal/common/service/common.hppv2.service_test.go +++ b/internal/common/service/common.hppv2.service_test.go @@ -17,6 +17,7 @@ type hppV2RepoStub struct { pfkIDsByProject map[uint][]uint usageRowsByKey map[string][]commonRepo.HppV2UsageCostRow adjustRowsByKey map[string][]commonRepo.HppV2AdjustmentCostRow + chickinRowsByKey map[string][]commonRepo.HppV2ChickinCostRow expenseRowsByPFKKey map[string][]commonRepo.HppV2ExpenseCostRow expenseRowsByFarmKey map[string][]commonRepo.HppV2ExpenseCostRow totalPopulationByKey map[string]float64 @@ -62,6 +63,10 @@ func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockID(_ context.Con return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByFarmKey[expenseFarmKey(projectFlockID, ekspedisi)]...), nil } +func (s *hppV2RepoStub) ListChickinCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time, excludeTransferToLaying bool) ([]commonRepo.HppV2ChickinCostRow, error) { + return append([]commonRepo.HppV2ChickinCostRow{}, s.chickinRowsByKey[chickinStubKey(projectFlockKandangIDs, flagNames, excludeTransferToLaying)]...), nil +} + func (s *hppV2RepoStub) GetFeedUsageCost(_ context.Context, _ []uint, _ *time.Time) (float64, error) { return 0, nil } @@ -339,6 +344,80 @@ func TestHppV2CalculateHppBreakdown_IncludesOvkComponent(t *testing.T) { } } +func TestHppV2CalculateHppBreakdown_IncludesDocAndDirectPulletChickin(t *testing.T) { + repo := &hppV2RepoStub{ + contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ + 35: { + ProjectFlockKandangID: 35, + ProjectFlockID: 8, + ProjectFlockCategory: "LAYING", + KandangID: 350, + KandangName: "Kandang E", + LocationID: 20, + }, + }, + pfkIDsByProject: map[uint][]uint{ + 9: {901, 902}, + }, + totalPopulationByKey: map[string]float64{ + stubKey([]uint{901, 902}, nil): 1000, + }, + transferSummaryByPFK: map[uint]struct { + projectFlockID uint + totalQty float64 + }{ + 35: {projectFlockID: 9, totalQty: 250}, + }, + chickinRowsByKey: map[string][]commonRepo.HppV2ChickinCostRow{ + chickinStubKey([]uint{901, 902}, []string{string(utils.FlagDOC)}, false): { + {ProjectChickinID: 1, ProjectFlockKandangID: 901, ChickInDate: mustTime(t, "2026-04-01"), StockableType: "purchase_items", StockableID: 1001, SourceProductID: 77, SourceProductName: "DOC", Qty: 1000, UnitPrice: 2, TotalCost: 2000}, + }, + chickinStubKey([]uint{35}, []string{string(utils.FlagPullet), string(utils.FlagLayer)}, true): { + {ProjectChickinID: 2, ProjectFlockKandangID: 35, ChickInDate: mustTime(t, "2026-04-15"), StockableType: "purchase_items", StockableID: 1002, SourceProductID: 78, SourceProductName: "Pullet", Qty: 50, UnitPrice: 20, TotalCost: 1000}, + }, + }, + eggProductionByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 35: {pieces: 100, kg: 10}, + }, + eggSalesByPFK: map[uint]struct { + pieces float64 + kg float64 + }{ + 35: {pieces: 80, kg: 8}, + }, + } + + svc := NewHppV2Service(repo) + result, err := svc.CalculateHppBreakdown(35, mustDate(t, "2026-04-19")) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + componentTotals := map[string]float64{} + for _, component := range result.Components { + componentTotals[component.Code] = component.Total + } + + if componentTotals[hppV2ComponentDocChickin] != 500 { + t.Fatalf("expected doc chickin total 500, got %v", componentTotals[hppV2ComponentDocChickin]) + } + if componentTotals[hppV2ComponentDirectPulletPurchase] != 1000 { + t.Fatalf("expected direct pullet purchase total 1000, got %v", componentTotals[hppV2ComponentDirectPulletPurchase]) + } + if result.TotalPulletCost != 500 { + t.Fatalf("expected total pullet cost 500, got %v", result.TotalPulletCost) + } + if result.TotalProductionCost != 1000 { + t.Fatalf("expected total production cost 1000, got %v", result.TotalProductionCost) + } + if result.Hpp.Estimation.HargaKg != 100 { + t.Fatalf("expected estimation harga/kg 100, got %v", result.Hpp.Estimation.HargaKg) + } +} + func TestHppV2CalculateHppBreakdown_IncludesBopRegularAndEkspedisi(t *testing.T) { repo := &hppV2RepoStub{ contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{ @@ -471,3 +550,7 @@ func expenseStubKey(ids []uint, ekspedisi bool) string { func expenseFarmKey(projectFlockID uint, ekspedisi bool) string { return fmt.Sprintf("farm=%d|ekspedisi=%t", projectFlockID, ekspedisi) } + +func chickinStubKey(ids []uint, flags []string, excludeTransferToLaying bool) string { + return stubKey(ids, append(append([]string{}, flags...), fmt.Sprintf("exclude_transfer_to_laying=%t", excludeTransferToLaying))) +} diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 0a49ed9f..87a0605a 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -473,11 +473,11 @@ func (s *repportService) computeExpenseDepreciationSnapshots( for _, row := range inputRows { groupedByFarm[row.ProjectFlockID] = append(groupedByFarm[row.ProjectFlockID], row) - dayN := depreciationDayNumber(row.TransferDate, periodDate) + dayN := approvalService.DepreciationScheduleDay(row.TransferDate, periodDate, valueOrEmptyString(row.HouseType)) if dayN > maxDay { maxDay = dayN } - houseType := strings.TrimSpace(strings.ToLower(valueOrEmptyString(row.HouseType))) + houseType := approvalService.NormalizeDepreciationHouseType(valueOrEmptyString(row.HouseType)) if houseType != "" { houseTypeSet[houseType] = struct{}{} } @@ -511,8 +511,8 @@ func (s *repportService) computeExpenseDepreciationSnapshots( totalDepreciationValue := 0.0 totalPulletCostDayN := 0.0 for _, row := range farmRows { - dayN := depreciationDayNumber(row.TransferDate, periodDate) - houseType := strings.TrimSpace(strings.ToLower(valueOrEmptyString(row.HouseType))) + dayN := approvalService.DepreciationScheduleDay(row.TransferDate, periodDate, valueOrEmptyString(row.HouseType)) + houseType := approvalService.NormalizeDepreciationHouseType(valueOrEmptyString(row.HouseType)) transferDateKey := row.TransferDate.Format("2006-01-02") cacheKey := fmt.Sprintf("%d|%s", row.SourceProjectFlockID, transferDateKey) @@ -550,7 +550,7 @@ func (s *repportService) computeExpenseDepreciationSnapshots( initialPulletCost = (cached.totalDepCost * row.TransferQty) / sourcePopulation } - pulletCostDayN, depreciationValue, depreciationPercent := calculateDepreciationAtDayN( + pulletCostDayN, depreciationValue, depreciationPercent := approvalService.CalculateDepreciationAtDayN( initialPulletCost, dayN, houseType, @@ -576,8 +576,7 @@ func (s *repportService) computeExpenseDepreciationSnapshots( }) } - effectivePercent := 0.0 - effectivePercent = calculateEffectiveDepreciationPercent(totalDepreciationValue, totalPulletCostDayN) + effectivePercent := approvalService.CalculateEffectiveDepreciationPercent(totalDepreciationValue, totalPulletCostDayN) componentsJSON, marshalErr := json.Marshal(components) if marshalErr != nil { @@ -597,57 +596,6 @@ func (s *repportService) computeExpenseDepreciationSnapshots( return result, nil } -func depreciationDayNumber(transferDate time.Time, periodDate time.Time) int { - transfer := time.Date(transferDate.Year(), transferDate.Month(), transferDate.Day(), 0, 0, 0, 0, transferDate.Location()) - period := time.Date(periodDate.Year(), periodDate.Month(), periodDate.Day(), 0, 0, 0, 0, periodDate.Location()) - if period.Before(transfer) { - return 0 - } - return int(period.Sub(transfer).Hours()/24) + 1 -} - -func calculateDepreciationAtDayN( - initialPulletCost float64, - dayN int, - houseType string, - percentByHouseType map[string]map[int]float64, -) (float64, float64, float64) { - if initialPulletCost <= 0 || dayN <= 0 || houseType == "" { - return 0, 0, 0 - } - - housePercent, exists := percentByHouseType[houseType] - if !exists { - return 0, 0, 0 - } - - current := initialPulletCost - pulletCostDayN := 0.0 - depreciationValue := 0.0 - depreciationPercent := 0.0 - for day := 1; day <= dayN; day++ { - pct := housePercent[day] - dep := current * (pct / 100) - if day == dayN { - pulletCostDayN = current - depreciationValue = dep - depreciationPercent = pct - } - current -= dep - if current < 0 { - current = 0 - } - } - return pulletCostDayN, depreciationValue, depreciationPercent -} - -func calculateEffectiveDepreciationPercent(totalDepreciationValue, totalPulletCostDayN float64) float64 { - if totalPulletCostDayN <= 0 { - return 0 - } - return (totalDepreciationValue / totalPulletCostDayN) * 100 -} - func parseSnapshotComponents(raw []byte) any { if len(raw) == 0 { return map[string]any{}