From fecbcab48db8369a5f402f64a92d3f7cb4cf226f Mon Sep 17 00:00:00 2001 From: giovanni Date: Wed, 27 May 2026 15:00:13 +0700 Subject: [PATCH] initial refactori trasnfer to laying, and depretitation to 25 week --- .../repository/common.hppv2.repository.go | 115 +++++++- .../service/common.depreciation.service.go | 36 +-- .../common/service/common.hppv2.service.go | 156 +++++++--- .../service/common.hppv2.service_test.go | 20 ++ internal/config/config.go | 5 +- ...close_house_depreciation_standard.down.sql | 17 ++ ...y_close_house_depreciation_standard.up.sql | 279 ++++++++++++++++++ ...alculate_economic_cutoff_25_weeks.down.sql | 22 ++ ...ecalculate_economic_cutoff_25_weeks.up.sql | 24 ++ ...ncate_farm_depreciation_snapshots.down.sql | 3 + ...runcate_farm_depreciation_snapshots.up.sql | 10 + .../controllers/projectflock.controller.go | 6 +- .../services/projectflock.service.go | 28 +- .../recordings/services/recording.service.go | 42 ++- .../laying_transfer.repository.go | 91 ++++++ .../services/transfer_laying.service.go | 83 +++--- .../dto/repportExpenseDepreciation.dto.go | 18 +- .../expense_depreciation.repository.go | 42 ++- .../repports/services/repport.service.go | 226 ++++++++++---- internal/utils/recording/recording_helpers.go | 18 +- 20 files changed, 1018 insertions(+), 223 deletions(-) create mode 100644 internal/database/migrations/20260520000001_unify_close_house_depreciation_standard.down.sql create mode 100644 internal/database/migrations/20260520000001_unify_close_house_depreciation_standard.up.sql create mode 100644 internal/database/migrations/20260527074540_recalculate_economic_cutoff_25_weeks.down.sql create mode 100644 internal/database/migrations/20260527074540_recalculate_economic_cutoff_25_weeks.up.sql create mode 100644 internal/database/migrations/20260527074620_truncate_farm_depreciation_snapshots.down.sql create mode 100644 internal/database/migrations/20260527074620_truncate_farm_depreciation_snapshots.up.sql diff --git a/internal/common/repository/common.hppv2.repository.go b/internal/common/repository/common.hppv2.repository.go index 829079c3..e437764a 100644 --- a/internal/common/repository/common.hppv2.repository.go +++ b/internal/common/repository/common.hppv2.repository.go @@ -102,11 +102,17 @@ 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) + // GetAllTransferInputsByProjectFlockKandangID return SEMUA approved transfer ke target kandang + // itu, untuk skenario multi-source di mana 1 target menerima dari multiple transfer terpisah. + // Setiap row = 1 transfer dengan cost basis & chick_in_date sendiri (per source). Order: + // effective_date ASC, id ASC (kronologis). + GetAllTransferInputsByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint, period time.Time) ([]HppV2LatestTransferInputRow, error) GetManualDepreciationInputByProjectFlockID(ctx context.Context, projectFlockID uint) (*HppV2ManualDepreciationInputRow, error) GetRecordingStockRoutingAdjustmentCostByProjectFlockID(ctx context.Context, projectFlockID uint, periodDate time.Time) (float64, error) GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(ctx context.Context, projectFlockID uint, periodDate time.Time) (*HppV2FarmDepreciationSnapshotRow, error) GetEarliestChickInDateByProjectFlockID(ctx context.Context, projectFlockID uint) (*time.Time, error) - GetDepreciationPercents(ctx context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, error) + GetChickinPopulationByPFKForFarm(ctx context.Context, projectFlockID uint) (map[uint]float64, error) + GetMultiplicationPercentages(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) @@ -230,6 +236,62 @@ LIMIT 1 return &row, nil } +func (r *HppV2RepositoryImpl) GetAllTransferInputsByProjectFlockKandangID( + ctx context.Context, + projectFlockKandangId uint, + period time.Time, +) ([]HppV2LatestTransferInputRow, error) { + var rows []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 ASC, at.id ASC +` + + err := r.db.WithContext(ctx).Raw(query, map[string]any{ + "approval_type": utils.ApprovalWorkflowTransferToLaying.String(), + "project_flock_kandang_id": projectFlockKandangId, + "period_date": period, + }).Scan(&rows).Error + if err != nil { + return nil, err + } + return rows, nil +} + func (r *HppV2RepositoryImpl) GetManualDepreciationInputByProjectFlockID( ctx context.Context, projectFlockID uint, @@ -373,7 +435,34 @@ func (r *HppV2RepositoryImpl) GetEarliestChickInDateByProjectFlockID(ctx context return selected.ChickInDate, nil } -func (r *HppV2RepositoryImpl) GetDepreciationPercents( +func (r *HppV2RepositoryImpl) GetChickinPopulationByPFKForFarm( + ctx context.Context, + projectFlockID uint, +) (map[uint]float64, error) { + type row struct { + ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"` + TotalQty float64 `gorm:"column:total_qty"` + } + var rows []row + err := r.db.WithContext(ctx). + Table("project_chickins AS pc"). + Select("pc.project_flock_kandang_id, SUM(pc.usage_qty) AS total_qty"). + 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). + Group("pc.project_flock_kandang_id"). + Scan(&rows).Error + if err != nil { + return nil, err + } + result := make(map[uint]float64, len(rows)) + for _, x := range rows { + result[x.ProjectFlockKandangID] = x.TotalQty + } + return result, nil +} + +func (r *HppV2RepositoryImpl) GetMultiplicationPercentages( ctx context.Context, houseTypes []string, maxDay int, @@ -384,19 +473,19 @@ func (r *HppV2RepositoryImpl) GetDepreciationPercents( } type row struct { - HouseType string - Day int - DepreciationPercent float64 + HouseType string + Day int + MultiplicationPercentage 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 + err := r.db.WithContext(ctx).Raw(` + SELECT DISTINCT ON (house_type::text, day) + house_type::text AS house_type, day, multiplication_percentage + FROM house_depreciation_standards + WHERE house_type::text IN ? AND day <= ? + ORDER BY house_type, day, effective_date DESC NULLS LAST + `, houseTypes, maxDay).Scan(&rows).Error if err != nil { return nil, err } @@ -405,7 +494,7 @@ func (r *HppV2RepositoryImpl) GetDepreciationPercents( if _, exists := result[item.HouseType]; !exists { result[item.HouseType] = make(map[int]float64) } - result[item.HouseType][item.Day] = item.DepreciationPercent + result[item.HouseType][item.Day] = item.MultiplicationPercentage } return result, nil diff --git a/internal/common/service/common.depreciation.service.go b/internal/common/service/common.depreciation.service.go index e73e601a..6c9f0936 100644 --- a/internal/common/service/common.depreciation.service.go +++ b/internal/common/service/common.depreciation.service.go @@ -6,8 +6,8 @@ import ( ) const ( - depreciationStartAgeDayCloseHouse = 155 - depreciationStartAgeDayOpenHouse = 176 + depreciationStartAgeDayCloseHouse = 175 + depreciationStartAgeDayOpenHouse = 175 ) func NormalizeDepreciationHouseType(raw string) string { @@ -26,8 +26,8 @@ func DepreciationStartAgeDay(houseType string) int { } 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()) + origin := time.Date(originDate.Year(), originDate.Month(), originDate.Day(), 0, 0, 0, 0, time.UTC) + period := time.Date(periodDate.Year(), periodDate.Month(), periodDate.Day(), 0, 0, 0, 0, time.UTC) if period.Before(origin) { return 0 } @@ -47,9 +47,9 @@ func CalculateDepreciationAtDayN( initialPulletCost float64, dayN int, houseType string, - percentByHouseType map[string]map[int]float64, + multiplicationByHouseType map[string]map[int]float64, ) (float64, float64, float64) { - return CalculateDepreciationFromDayRange(initialPulletCost, 1, dayN, houseType, percentByHouseType) + return CalculateDepreciationFromDayRange(initialPulletCost, 1, dayN, houseType, multiplicationByHouseType) } func CalculateDepreciationFromDayRange( @@ -57,8 +57,8 @@ func CalculateDepreciationFromDayRange( startDay int, endDay int, houseType string, - percentByHouseType map[string]map[int]float64, -) (float64, float64, float64) { + multiplicationByHouseType map[string]map[int]float64, +) (pulletCostDayN, depreciationValue, multiplicationPercentage float64) { if initialPulletCost <= 0 || endDay <= 0 { return 0, 0, 0 } @@ -70,30 +70,30 @@ func CalculateDepreciationFromDayRange( } normalizedHouseType := NormalizeDepreciationHouseType(houseType) - housePercent, exists := percentByHouseType[normalizedHouseType] + houseMult, exists := multiplicationByHouseType[normalizedHouseType] if !exists { return 0, 0, 0 } current := initialPulletCost - pulletCostDayN := 0.0 - depreciationValue := 0.0 - depreciationPercent := 0.0 for day := startDay; day <= endDay; day++ { - pct := housePercent[day] - dep := current * (pct / 100) + mult, ok := houseMult[day] + if !ok { + // No standard for this day → assume no depreciation (mult=1). + mult = 1.0 + } if day == endDay { pulletCostDayN = current - depreciationValue = dep - depreciationPercent = pct + multiplicationPercentage = mult + depreciationValue = current * (1.0 - mult) } - current -= dep + current = current * mult if current < 0 { current = 0 } } - return pulletCostDayN, depreciationValue, depreciationPercent + return pulletCostDayN, depreciationValue, multiplicationPercentage } func CalculateEffectiveDepreciationPercent(totalDepreciationValue, totalPulletCostDayN float64) float64 { diff --git a/internal/common/service/common.hppv2.service.go b/internal/common/service/common.hppv2.service.go index 8d4c967e..76751bf6 100644 --- a/internal/common/service/common.hppv2.service.go +++ b/internal/common/service/common.hppv2.service.go @@ -1191,26 +1191,72 @@ func (s *hppV2Service) getDepreciationComponent( }, nil } - if totalPulletCost <= 0 { - return nil, nil - } - - transferInput, err := s.hppRepo.GetLatestTransferInputByProjectFlockKandangID(context.Background(), projectFlockKandangId, periodDate) + // Multi-source support: 1 target kandang bisa menerima dari MULTIPLE transfer terpisah + // (tiap transfer = 1 source kandang). Depresiasi per target = SUM dari per-transfer depresiasi. + // Setiap transfer dihitung dengan chick_in_date source-nya sendiri dan cost basis pro-rated + // berdasarkan qty share (transfer.qty / totalTransferQty). + transferInputs, err := s.hppRepo.GetAllTransferInputsByProjectFlockKandangID(context.Background(), projectFlockKandangId, periodDate) if err != nil { return nil, err } - var part *HppV2ComponentPart - if transferInput != nil && transferInput.SourceProjectFlockID > 0 { - part, err = s.buildNormalTransferDepreciationPart(contextRow, transferInput, periodDate, totalPulletCost) - if err != nil { - return nil, err + // Filter valid transfers (punya source flock id) + validTransfers := make([]commonRepo.HppV2LatestTransferInputRow, 0, len(transferInputs)) + totalTransferQty := 0.0 + for _, t := range transferInputs { + if t.SourceProjectFlockID == 0 { + continue } - } else { - part, err = s.buildManualCutoverDepreciationPart(projectFlockKandangId, contextRow, periodDate, totalPulletCost) - if err != nil { - return nil, err + validTransfers = append(validTransfers, t) + totalTransferQty += t.TransferQty + } + + if len(validTransfers) > 0 { + if totalPulletCost <= 0 { + return nil, nil } + + totalDepreciation := 0.0 + parts := make([]HppV2ComponentPart, 0, len(validTransfers)) + for i := range validTransfers { + t := validTransfers[i] + // Pro-rate cost basis per transfer berdasarkan qty share. + // CATATAN: pendekatan ini AKURAT kalau cost per ekor sama antar source flock. + // Kalau cost per ekor berbeda signifikan antar source, follow-up: refactor + // `buildGrowingUsagePart` untuk multi-source-flock cost computation. + transferCostBasis := totalPulletCost + if totalTransferQty > 0 && len(validTransfers) > 1 { + transferCostBasis = totalPulletCost * (t.TransferQty / totalTransferQty) + } + + part, partErr := s.buildNormalTransferDepreciationPart(contextRow, &t, periodDate, transferCostBasis) + if partErr != nil { + return nil, partErr + } + if part == nil { + continue + } + totalDepreciation += part.Total + parts = append(parts, *part) + } + + if len(parts) == 0 { + return nil, nil + } + + return &HppV2Component{ + Code: hppV2ComponentDepreciation, + Title: "Depreciation", + Scopes: []string{hppV2ScopeProductionCost}, + Total: totalDepreciation, + Parts: parts, + }, nil + } + + // Fallback: manual cut-over (kandang tanpa transfer record) + part, err := s.buildManualCutoverDepreciationPart(projectFlockKandangId, contextRow, periodDate, totalPulletCost) + if err != nil { + return nil, err } if part == nil { return nil, nil @@ -1344,20 +1390,22 @@ func (s *hppV2Service) buildNormalTransferDepreciationPart( } houseType := NormalizeDepreciationHouseType(contextRow.HouseType) - percentByHouseType, err := s.hppRepo.GetDepreciationPercents(context.Background(), []string{houseType}, scheduleDay) + multiplicationByHouseType, err := s.hppRepo.GetMultiplicationPercentages(context.Background(), []string{houseType}, scheduleDay) if err != nil { return nil, err } - pulletCostDayN, depreciationValue, depreciationPercent := CalculateDepreciationAtDayN( + pulletCostDayN, depreciationValue, multiplicationPercentage := CalculateDepreciationAtDayN( totalPulletCost, scheduleDay, contextRow.HouseType, - percentByHouseType, + multiplicationByHouseType, ) - if depreciationValue <= 0 { + if depreciationValue <= 0 && pulletCostDayN <= 0 { return nil, nil } + totalValueAfter := pulletCostDayN * multiplicationPercentage + depreciationPercent := (1.0 - multiplicationPercentage) * 100.0 return &HppV2ComponentPart{ Code: hppV2PartDepreciationNormal, @@ -1365,13 +1413,15 @@ func (s *hppV2Service) buildNormalTransferDepreciationPart( 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, + "basis_total": totalPulletCost, + "pullet_cost_day_n": pulletCostDayN, + "multiplication_percentage": multiplicationPercentage, + "total_value_pullet_after_depreciation": totalValueAfter, + "depreciation_percent": depreciationPercent, + "schedule_day": scheduleDay, + "origin_date": formatDateOnly(*originDate), + "transfer_date": formatDateOnly(transferInput.TransferDate), + "source_project_flock_id": transferInput.SourceProjectFlockID, }, References: []HppV2Reference{ { @@ -1392,7 +1442,7 @@ func (s *hppV2Service) buildManualCutoverDepreciationPart( periodDate time.Time, totalPulletCost float64, ) (*HppV2ComponentPart, error) { - if contextRow == nil || totalPulletCost <= 0 { + if contextRow == nil { return nil, nil } @@ -1407,6 +1457,21 @@ func (s *hppV2Service) buildManualCutoverDepreciationPart( return nil, nil } + populations, err := s.hppRepo.GetChickinPopulationByPFKForFarm(context.Background(), contextRow.ProjectFlockID) + if err != nil { + return nil, err + } + var totalPopulation float64 + for _, qty := range populations { + totalPopulation += qty + } + kandangPopulation := populations[projectFlockKandangId] + if totalPopulation <= 0 || kandangPopulation <= 0 { + return nil, nil + } + populationShare := kandangPopulation / totalPopulation + basis := manualInput.TotalCost * populationShare + originDate, err := s.hppRepo.GetEarliestChickInDateByProjectFlockID(context.Background(), contextRow.ProjectFlockID) if err != nil { return nil, err @@ -1427,21 +1492,24 @@ func (s *hppV2Service) buildManualCutoverDepreciationPart( } houseType := NormalizeDepreciationHouseType(contextRow.HouseType) - percentByHouseType, err := s.hppRepo.GetDepreciationPercents(context.Background(), []string{houseType}, reportScheduleDay) + multiplicationByHouseType, err := s.hppRepo.GetMultiplicationPercentages(context.Background(), []string{houseType}, reportScheduleDay) if err != nil { return nil, err } - pulletCostDayN, depreciationValue, depreciationPercent := CalculateDepreciationFromDayRange( - totalPulletCost, + pulletCostDayN, depreciationValue, multiplicationPercentage := CalculateDepreciationFromDayRange( + basis, startDay, reportScheduleDay, contextRow.HouseType, - percentByHouseType, + multiplicationByHouseType, ) - if depreciationValue <= 0 { + if depreciationValue <= 0 && pulletCostDayN <= 0 { return nil, nil } + totalValueAfter := pulletCostDayN * multiplicationPercentage + depreciationPercent := (1.0 - multiplicationPercentage) * 100.0 + _ = totalPulletCost return &HppV2ComponentPart{ Code: hppV2PartDepreciationCutover, @@ -1449,15 +1517,19 @@ func (s *hppV2Service) buildManualCutoverDepreciationPart( 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, + "basis_total": basis, + "manual_input_total": manualInput.TotalCost, + "population_share": populationShare, + "pullet_cost_day_n": pulletCostDayN, + "multiplication_percentage": multiplicationPercentage, + "total_value_pullet_after_depreciation": totalValueAfter, + "depreciation_percent": depreciationPercent, + "schedule_day": reportScheduleDay, + "start_schedule_day": startDay, + "origin_date": formatDateOnly(*originDate), + "cutover_date": formatDateOnly(manualInput.CutoverDate), + "manual_input_id": manualInput.ID, + "project_flock_kandang": projectFlockKandangId, }, References: []HppV2Reference{ { @@ -1465,7 +1537,7 @@ func (s *hppV2Service) buildManualCutoverDepreciationPart( ID: manualInput.ID, Date: formatDateOnly(manualInput.CutoverDate), Qty: 1, - Total: totalPulletCost, + Total: manualInput.TotalCost, AppliedTotal: depreciationValue, }, }, @@ -1724,7 +1796,7 @@ func partHasScope(part *HppV2ComponentPart, scope string) bool { } func dateOnly(value time.Time) time.Time { - return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, value.Location()) + return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, time.UTC) } func formatDateOnly(value time.Time) string { diff --git a/internal/common/service/common.hppv2.service_test.go b/internal/common/service/common.hppv2.service_test.go index d75cf0f2..290abad8 100644 --- a/internal/common/service/common.hppv2.service_test.go +++ b/internal/common/service/common.hppv2.service_test.go @@ -57,6 +57,14 @@ func (s *hppV2RepoStub) GetLatestTransferInputByProjectFlockKandangID(_ context. return s.latestTransferByPFK[projectFlockKandangId], nil } +func (s *hppV2RepoStub) GetAllTransferInputsByProjectFlockKandangID(_ context.Context, projectFlockKandangId uint, _ time.Time) ([]commonRepo.HppV2LatestTransferInputRow, error) { + row := s.latestTransferByPFK[projectFlockKandangId] + if row == nil { + return []commonRepo.HppV2LatestTransferInputRow{}, nil + } + return []commonRepo.HppV2LatestTransferInputRow{*row}, nil +} + func (s *hppV2RepoStub) GetManualDepreciationInputByProjectFlockID(_ context.Context, projectFlockID uint) (*commonRepo.HppV2ManualDepreciationInputRow, error) { return s.manualInputByProject[projectFlockID], nil } @@ -93,6 +101,18 @@ func (s *hppV2RepoStub) GetDepreciationPercents(_ context.Context, houseTypes [] return result, nil } +// GetMultiplicationPercentages — alias yang sama dengan GetDepreciationPercents untuk match +// interface HppV2CostRepository (interface dipakai method name baru ini). +func (s *hppV2RepoStub) GetMultiplicationPercentages(ctx context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, error) { + return s.GetDepreciationPercents(ctx, houseTypes, maxDay) +} + +// GetChickinPopulationByPFKForFarm — return populasi per PFK dari satu project flock. +// Stub minimal: return empty map (depreciation manual cutover tidak di-test di sini). +func (s *hppV2RepoStub) GetChickinPopulationByPFKForFarm(_ context.Context, _ uint) (map[uint]float64, error) { + return map[uint]float64{}, 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 } diff --git a/internal/config/config.go b/internal/config/config.go index 2a5d640e..d20479c8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -121,9 +121,12 @@ func init() { // Redis RedisURL = viper.GetString("REDIS_URL") + // TransferToLayingGrowingMaxWeek: batas umur (minggu dari chick_in) yang masih boleh ditransfer ke laying. + // Disatukan dengan depreciation_start_age_day = 175 hari = 25 minggu, agar konsisten antara batas transfer + // dan kapan depresiasi mulai berjalan. TransferToLayingGrowingMaxWeek = viper.GetInt("TRANSFER_TO_LAYING_GROWING_MAX_WEEK") if TransferToLayingGrowingMaxWeek <= 0 { - TransferToLayingGrowingMaxWeek = 19 + TransferToLayingGrowingMaxWeek = 25 } // Object storage diff --git a/internal/database/migrations/20260520000001_unify_close_house_depreciation_standard.down.sql b/internal/database/migrations/20260520000001_unify_close_house_depreciation_standard.down.sql new file mode 100644 index 00000000..d3619567 --- /dev/null +++ b/internal/database/migrations/20260520000001_unify_close_house_depreciation_standard.down.sql @@ -0,0 +1,17 @@ +-- Hapus open_house dan close_house rows dengan effective_date baru +DELETE FROM house_depreciation_standards +WHERE house_type IN ('open_house', 'close_house') AND effective_date = '2026-05-20'; + +-- Hapus kolom multiplication_percentage +ALTER TABLE house_depreciation_standards DROP COLUMN multiplication_percentage; + +-- Invalidate snapshot cache +DELETE FROM farm_depreciation_snapshots; + +-- Kembalikan unique constraint lama +ALTER TABLE house_depreciation_standards + DROP CONSTRAINT house_depreciation_standards_house_type_day_eff_unique; + +ALTER TABLE house_depreciation_standards + ADD CONSTRAINT house_depreciation_standards_house_type_day_unique + UNIQUE (house_type, day); diff --git a/internal/database/migrations/20260520000001_unify_close_house_depreciation_standard.up.sql b/internal/database/migrations/20260520000001_unify_close_house_depreciation_standard.up.sql new file mode 100644 index 00000000..e26831c1 --- /dev/null +++ b/internal/database/migrations/20260520000001_unify_close_house_depreciation_standard.up.sql @@ -0,0 +1,279 @@ +-- Drop unique constraint lama (house_type, day) agar bisa support multi effective_date +ALTER TABLE house_depreciation_standards + DROP CONSTRAINT house_depreciation_standards_house_type_day_unique; + +-- Unique baru: (house_type, day, effective_date) +-- NULL dianggap distinct di PostgreSQL → row lama (effective_date NULL) tidak konflik dengan row baru +ALTER TABLE house_depreciation_standards + ADD CONSTRAINT house_depreciation_standards_house_type_day_eff_unique + UNIQUE (house_type, day, effective_date); + +-- Tambah kolom multiplication_percentage (nilai dari baris ke-3 Excel "Depresiasi 25 week.xlsx") +ALTER TABLE house_depreciation_standards + ADD COLUMN multiplication_percentage numeric(20,15) NOT NULL DEFAULT 0; + +-- Isi multiplication_percentage untuk semua row existing (effective_date IS NULL) +-- Value diambil dari row 3 Excel: kolom A=day1 s/d TL=day532 +UPDATE house_depreciation_standards AS hds +SET multiplication_percentage = v.val +FROM (VALUES + (1,0.997742664),(2,0.997737557),(3,0.997732426),(4,0.997727273),(5,0.997722096), + (6,0.997716895),(7,0.99771167),(8,0.997706422),(9,0.997701149),(10,0.997695853), + (11,0.997690531),(12,0.9977),(13,0.997679814),(14,0.997674419),(15,0.998), + (16,0.997997998),(17,0.997993982),(18,0.99798995),(19,0.997985901),(20,0.997981837), + (21,0.997977755),(22,0.997635934),(23,0.997630332),(24,0.997624703),(25,0.997619048), + (26,0.997613365),(27,0.997607656),(28,0.997601918),(29,0.997596154),(30,0.997590361), + (31,0.997584541),(32,0.997578692),(33,0.997572816),(34,0.99756691),(35,0.997560976), + (36,0.997555012),(37,0.99754902),(38,0.997542998),(39,0.997536946),(40,0.997530864), + (41,0.997524752),(42,0.99751861),(43,0.997867804),(44,0.997863248),(45,0.997858672), + (46,0.997854077),(47,0.997849462),(48,0.997844828),(49,0.997840173),(50,0.997474747), + (51,0.997468354),(52,0.997461929),(53,0.997455471),(54,0.99744898),(55,0.997442455), + (56,0.997435897),(57,0.997429306),(58,0.99742268),(59,0.997416021),(60,0.997409326), + (61,0.997402597),(62,0.997395833),(63,0.997389034),(64,0.997756171),(65,0.997751124), + (66,0.997746056),(67,0.997740964),(68,0.997735849),(69,0.997730711),(70,0.99772555), + (71,0.997340426),(72,0.997333333),(73,0.997326203),(74,0.997319035),(75,0.997311828), + (76,0.997304582),(77,0.9972973),(78,0.99767712),(79,0.99767171),(80,0.99766628), + (81,0.99766082),(82,0.99765533),(83,0.99764982),(84,0.997644287),(85,0.997245179), + (86,0.997237569),(87,0.997229917),(88,0.997222222),(89,0.997214485),(90,0.997206704), + (91,0.99719888),(92,0.997191011),(93,0.997183099),(94,0.997175141),(95,0.997167139), + (96,0.997159091),(97,0.997150997),(98,0.997142857),(99,0.997544003),(100,0.997537957), + (101,0.99753188),(102,0.997525773),(103,0.997519636),(104,0.997513469),(105,0.99750727), + (106,0.997084548),(107,0.997076023),(108,0.997067449),(109,0.997058824),(110,0.997050147), + (111,0.99704142),(112,0.997032641),(113,0.99744898),(114,0.997442455),(115,0.997435897), + (116,0.997429306),(117,0.99742268),(118,0.997416021),(119,0.997409326),(120,0.996969697), + (121,0.996960486),(122,0.99695122),(123,0.996941896),(124,0.996932515),(125,0.996923077), + (126,0.99691358),(127,0.997346307),(128,0.997339246),(129,0.997332148),(130,0.997325011), + (131,0.997317836),(132,0.997310623),(133,0.997303371),(134,0.996845426),(135,0.996835443), + (136,0.996825397),(137,0.996815287),(138,0.996805112),(139,0.996794872),(140,0.996784566), + (141,0.997235023),(142,0.997227357),(143,0.997219648),(144,0.997211896),(145,0.997204101), + (146,0.997196262),(147,0.997188379),(148,0.996710526),(149,0.99669967),(150,0.996688742), + (151,0.996677741),(152,0.996666667),(153,0.996655518),(154,0.996644295),(155,0.997113997), + (156,0.997105644),(157,0.997097242),(158,0.997088792),(159,0.997080292),(160,0.997071742), + (161,0.997063142),(162,0.997054492),(163,0.99704579),(164,0.997037037),(165,0.997028232), + (166,0.997019374),(167,0.997010463),(168,0.997001499),(169,0.996491228),(170,0.996478873), + (171,0.996466431),(172,0.996453901),(173,0.996441281),(174,0.996428571),(175,0.996415771), + (176,0.996916752),(177,0.996907216),(178,0.996897622),(179,0.996887967),(180,0.996878252), + (181,0.996868476),(182,0.996858639),(183,0.996848739),(184,0.996838778),(185,0.996828753), + (186,0.996818664),(187,0.996808511),(188,0.996798292),(189,0.996788009),(190,0.996240602), + (191,0.996226415),(192,0.996212121),(193,0.996197719),(194,0.996183206),(195,0.996168582), + (196,0.996153846),(197,0.996690568),(198,0.996679579),(199,0.996668517),(200,0.996657382), + (201,0.996646171),(202,0.996634885),(203,0.996623523),(204,0.996612084),(205,0.996600567), + (206,0.996588971),(207,0.996577296),(208,0.996565541),(209,0.996553705),(210,0.996541787), + (211,0.996529786),(212,0.996517702),(213,0.996505533),(214,0.996493279),(215,0.996480938), + (216,0.996468511),(217,0.996455995),(218,0.996443391),(219,0.996430696),(220,0.99641791), + (221,0.996405033),(222,0.996392063),(223,0.996378998),(224,0.996365839),(225,0.995744681), + (226,0.995726496),(227,0.995708155),(228,0.995689655),(229,0.995670996),(230,0.995652174), + (231,0.995633188),(232,0.996240602),(233,0.996226415),(234,0.996212121),(235,0.996197719), + (236,0.996183206),(237,0.996168582),(238,0.996153846),(239,0.9961389960),(240,0.996124031), + (241,0.996108949),(242,0.99609375),(243,0.996078431),(244,0.996062992),(245,0.996047431), + (246,0.996031746),(247,0.996015936),(248,0.996),(249,0.995983936),(250,0.995967742), + (251,0.995951417),(252,0.995934959),(253,0.995918367),(254,0.995901639),(255,0.995884774), + (256,0.995867769),(257,0.995850622),(258,0.995833333),(259,0.9958158999),(260,0.995798319), + (261,0.995780591),(262,0.995762712),(263,0.995744681),(264,0.995726496),(265,0.995708155), + (266,0.995689655),(267,0.995670996),(268,0.995652174),(269,0.995633188),(270,0.995614035), + (271,0.995594714),(272,0.995575221),(273,0.995555556),(274,0.995535714),(275,0.995515695), + (276,0.995495495),(277,0.995475113),(278,0.995454545),(279,0.99543379),(280,0.995412844), + (281,0.995391705),(282,0.99537037),(283,0.995348837),(284,0.995327103),(285,0.995305164), + (286,0.995282919),(287,0.995260664),(288,0.996031746),(289,0.996015936),(290,0.996), + (291,0.995983936),(292,0.995967742),(293,0.995951417),(294,0.995934959),(295,0.995102041), + (296,0.995077933),(297,0.995053586),(298,0.995028998),(299,0.995004163),(300,0.994979079), + (301,0.994953743),(302,0.994928149),(303,0.994902294),(304,0.994876174),(305,0.994849785), + (306,0.994823123),(307,0.994796184),(308,0.994768963),(309,0.994741455),(310,0.994713656), + (311,0.994685562),(312,0.994657168),(313,0.994628469),(314,0.99459946),(315,0.994570136), + (316,0.994540491),(317,0.994510522),(318,0.994480221),(319,0.994449584),(320,0.994418605), + (321,0.994387278),(322,0.994355597),(323,0.995269631),(324,0.995247148),(325,0.995224451), + (326,0.995201536),(327,0.995178399),(328,0.995155039),(329,0.995131451),(330,0.994129159), + (331,0.994094488),(332,0.994059406),(333,0.994023904),(334,0.993987976),(335,0.993951613), + (336,0.993914807),(337,0.994897959),(338,0.994871795),(339,0.994845361),(340,0.994818653), + (341,0.994791667),(342,0.994764398),(343,0.994736842),(344,0.993650794),(345,0.993610224), + (346,0.993569132),(347,0.993527508),(348,0.993484342),(349,0.993442623),(350,0.99339934), + (351,0.993355482),(352,0.993311037),(353,0.993265993),(354,0.993220339),(355,0.993174061), + (356,0.993127148),(357,0.993079585),(358,0.994192799),(359,0.994158879),(360,0.994124559), + (361,0.994089835),(362,0.994054697),(363,0.994019139),(364,0.993983153),(365,0.992736077), + (366,0.992682927),(367,0.992628993),(368,0.992574257),(369,0.992518703),(370,0.992462312), + (371,0.992405063),(372,0.993622449),(373,0.993581515),(374,0.993540052),(375,0.993498049), + (376,0.993455497),(377,0.993412385),(378,0.9933687),(379,0.993324433),(380,0.99327957), + (381,0.9932341),(382,0.993188011),(383,0.993141289),(384,0.993093923),(385,0.993045897), + (386,0.991596639),(387,0.991525424),(388,0.991452991),(389,0.99137931),(390,0.991304348), + (391,0.99122807),(392,0.991150442),(393,0.992559524),(394,0.992503748),(395,0.99244713), + (396,0.99238965),(397,0.992331288),(398,0.992272025),(399,0.992211838),(400,0.992150706), + (401,0.992088608),(402,0.992025518),(403,0.991961415),(404,0.991896272),(405,0.991830065), + (406,0.991762768),(407,0.991694352),(408,0.991624791),(409,0.991554054),(410,0.991482112), + (411,0.991408935),(412,0.991334489),(413,0.991258741),(414,0.989417989),(415,0.989304813), + (416,0.989189189),(417,0.989071038),(418,0.988950276),(419,0.988826816),(420,0.988700565), + (421,0.99047619),(422,0.990384615),(423,0.990291262),(424,0.990196078),(425,0.99009901), + (426,0.99),(427,0.98989899),(428,0.989795918),(429,0.989690722),(430,0.989583333), + (431,0.989473684),(432,0.989361702),(433,0.989247312),(434,0.989130435),(435,0.989010989), + (436,0.988888889),(437,0.988764045),(438,0.988636364),(439,0.988505747),(440,0.988372093), + (441,0.988235294),(442,0.988095238),(443,0.987951807),(444,0.987804878),(445,0.987654321), + (446,0.9875),(447,0.987341772),(448,0.987179487),(449,0.987012987),(450,0.986842105), + (451,0.986666667),(452,0.986486486),(453,0.98630137),(454,0.986111111),(455,0.985915493), + (456,0.985714286),(457,0.985507246),(458,0.985294118),(459,0.985074627),(460,0.984848485), + (461,0.984615385),(462,0.984375),(463,0.987301587),(464,0.987138264),(465,0.986970684), + (466,0.98679868),(467,0.986622074),(468,0.986440678),(469,0.986254296),(470,0.982578397), + (471,0.982269504),(472,0.981949458),(473,0.981617647),(474,0.981273408),(475,0.980916031), + (476,0.980544747),(477,0.98015873),(478,0.979757085),(479,0.979338843),(480,0.978902954), + (481,0.978448276),(482,0.977973568),(483,0.977477477),(484,0.976958525),(485,0.976415094), + (486,0.975845411),(487,0.975247525),(488,0.974619289),(489,0.973958333),(490,0.973262032), + (491,0.978021978),(492,0.97752809),(493,0.977011494),(494,0.976470588),(495,0.975903614), + (496,0.975308642),(497,0.974683544),(498,0.967532468),(499,0.966442953),(500,0.965277778), + (501,0.964028777),(502,0.962686567),(503,0.96124031),(504,0.959677419),(505,0.966386555), + (506,0.965217391),(507,0.963963964),(508,0.962616822),(509,0.961165049),(510,0.95959596), + (511,0.957894737),(512,0.945054945),(513,0.941860465),(514,0.938271605),(515,0.934210526), + (516,0.929577465),(517,0.924242424),(518,0.918032787),(519,0.928571429),(520,0.923076923), + (521,0.916666667),(522,0.909090909),(523,0.9),(524,0.888888889),(525,0.875), + (526,0.857142857),(527,0.833333333),(528,0.8),(529,0.75),(530,0.666666667), + (531,0.5),(532,9.11e-12) +) AS v(day_num, val) +WHERE hds.day = v.day_num; + +-- Insert open_house baru dengan effective_date 2026-05-20 +-- multiplication_percentage diambil dari row existing (sudah di-UPDATE di step sebelumnya) +INSERT INTO house_depreciation_standards + (house_type, day, effective_date, depreciation_percent, standard_week, name, multiplication_percentage) +SELECT + 'open_house'::house_type_enum, + day, + '2026-05-20'::date, + depreciation_percent, + 25, + 'Standard Open House Week 25', + multiplication_percentage +FROM ( + SELECT DISTINCT ON (day) + day, depreciation_percent, multiplication_percentage + FROM house_depreciation_standards + WHERE house_type = 'open_house' + ORDER BY day, effective_date DESC NULLS LAST +) effective_open_house; + +-- Insert close_house baru: depreciation_percent dari open_house, multiplication_percentage dari Excel row 8 (close_house) +INSERT INTO house_depreciation_standards + (house_type, day, effective_date, depreciation_percent, standard_week, name, multiplication_percentage) +SELECT + 'close_house'::house_type_enum, + oh.day, + '2026-05-20'::date, + oh.depreciation_percent, + 25, + 'Standard Close House Week 25', + ch.val +FROM ( + SELECT DISTINCT ON (day) + day, depreciation_percent + FROM house_depreciation_standards + WHERE house_type = 'open_house' + ORDER BY day, effective_date DESC NULLS LAST +) oh +JOIN (VALUES + (1,0.9981),(2,0.9981),(3,0.9981),(4,0.9981),(5,0.9981), + (6,0.9981),(7,0.9981),(8,0.9978),(9,0.9978),(10,0.9978), + (11,0.9978),(12,0.9978),(13,0.9978),(14,0.9978),(15,0.9978), + (16,0.9978),(17,0.9978),(18,0.9978),(19,0.9978),(20,0.9978), + (21,0.9978),(22,0.9981),(23,0.9981),(24,0.9981),(25,0.9981), + (26,0.9981),(27,0.9981),(28,0.9981),(29,0.9978),(30,0.9978), + (31,0.9978),(32,0.9978),(33,0.9978),(34,0.9978),(35,0.9978), + (36,0.9978),(37,0.9978),(38,0.9978),(39,0.9978),(40,0.9978), + (41,0.9978),(42,0.9978),(43,0.9978),(44,0.9978),(45,0.9978), + (46,0.9978),(47,0.9978),(48,0.9978),(49,0.9978),(50,0.9981), + (51,0.9981),(52,0.9981),(53,0.9981),(54,0.9981),(55,0.9981), + (56,0.9981),(57,0.9978),(58,0.9978),(59,0.9978),(60,0.9978), + (61,0.9978),(62,0.9978),(63,0.9978),(64,0.9978),(65,0.9978), + (66,0.9977),(67,0.9977),(68,0.9977),(69,0.9977),(70,0.9977), + (71,0.9973),(72,0.9973),(73,0.9973),(74,0.9973),(75,0.9973), + (76,0.9973),(77,0.9973),(78,0.9977),(79,0.9977),(80,0.9977), + (81,0.9977),(82,0.9977),(83,0.9976),(84,0.9976),(85,0.9972), + (86,0.9972),(87,0.9972),(88,0.9972),(89,0.9972),(90,0.9972), + (91,0.9972),(92,0.9972),(93,0.9972),(94,0.9972),(95,0.9972), + (96,0.9972),(97,0.9972),(98,0.9971),(99,0.9975),(100,0.9975), + (101,0.9975),(102,0.9975),(103,0.9975),(104,0.9975),(105,0.9975), + (106,0.9971),(107,0.9971),(108,0.9971),(109,0.9971),(110,0.9971), + (111,0.997),(112,0.997),(113,0.9974),(114,0.9974),(115,0.9974), + (116,0.9974),(117,0.9974),(118,0.9974),(119,0.9974),(120,0.997), + (121,0.997),(122,0.997),(123,0.9969),(124,0.9969),(125,0.9969), + (126,0.9969),(127,0.9973),(128,0.9973),(129,0.9973),(130,0.9973), + (131,0.9973),(132,0.9973),(133,0.9973),(134,0.9968),(135,0.9968), + (136,0.9968),(137,0.9968),(138,0.9968),(139,0.9968),(140,0.9968), + (141,0.9972),(142,0.9972),(143,0.9972),(144,0.9972),(145,0.9972), + (146,0.9972),(147,0.9972),(148,0.9967),(149,0.9967),(150,0.9967), + (151,0.9967),(152,0.9967),(153,0.9967),(154,0.9966),(155,0.9971), + (156,0.9971),(157,0.9971),(158,0.9971),(159,0.9971),(160,0.9971), + (161,0.9971),(162,0.9971),(163,0.997),(164,0.997),(165,0.997), + (166,0.997),(167,0.997),(168,0.997),(169,0.9965),(170,0.9965), + (171,0.9965),(172,0.9965),(173,0.9964),(174,0.9964),(175,0.9964), + (176,0.9969),(177,0.9969),(178,0.9969),(179,0.9969),(180,0.9969), + (181,0.9969),(182,0.9969),(183,0.9968),(184,0.9968),(185,0.9968), + (186,0.9968),(187,0.9968),(188,0.9968),(189,0.9968),(190,0.9962), + (191,0.9962),(192,0.9962),(193,0.9962),(194,0.9962),(195,0.9962), + (196,0.9962),(197,0.9967),(198,0.9967),(199,0.9967),(200,0.9967), + (201,0.9966),(202,0.9966),(203,0.9966),(204,0.9966),(205,0.9966), + (206,0.9966),(207,0.9966),(208,0.9966),(209,0.9966),(210,0.9965), + (211,0.9965),(212,0.9965),(213,0.9965),(214,0.9965),(215,0.9965), + (216,0.9965),(217,0.9965),(218,0.9964),(219,0.9964),(220,0.9964), + (221,0.9964),(222,0.9964),(223,0.9964),(224,0.9964),(225,0.9957), + (226,0.9957),(227,0.9957),(228,0.9957),(229,0.9957),(230,0.9957), + (231,0.9956),(232,0.9962),(233,0.9962),(234,0.9962),(235,0.9962), + (236,0.9962),(237,0.9962),(238,0.9962),(239,0.9961),(240,0.9961), + (241,0.9961),(242,0.9961),(243,0.9961),(244,0.9961),(245,0.996), + (246,0.996),(247,0.996),(248,0.996),(249,0.996),(250,0.996), + (251,0.996),(252,0.9959),(253,0.9959),(254,0.9959),(255,0.9959), + (256,0.9959),(257,0.9959),(258,0.9958),(259,0.9958),(260,0.9958), + (261,0.9958),(262,0.9958),(263,0.9957),(264,0.9957),(265,0.9957), + (266,0.9957),(267,0.9957),(268,0.9957),(269,0.9956),(270,0.9956), + (271,0.9956),(272,0.9956),(273,0.9956),(274,0.9955),(275,0.9955), + (276,0.9955),(277,0.9955),(278,0.9955),(279,0.9954),(280,0.9954), + (281,0.9954),(282,0.9954),(283,0.9953),(284,0.9953),(285,0.9953), + (286,0.9953),(287,0.9953),(288,0.996),(289,0.996),(290,0.996), + (291,0.996),(292,0.996),(293,0.996),(294,0.9959),(295,0.9951), + (296,0.9951),(297,0.9951),(298,0.995),(299,0.995),(300,0.995), + (301,0.995),(302,0.9949),(303,0.9949),(304,0.9949),(305,0.9948), + (306,0.9948),(307,0.9948),(308,0.9948),(309,0.9947),(310,0.9947), + (311,0.9947),(312,0.9947),(313,0.9946),(314,0.9946),(315,0.9946), + (316,0.9945),(317,0.9945),(318,0.9945),(319,0.9944),(320,0.9944), + (321,0.9944),(322,0.9944),(323,0.9953),(324,0.9952),(325,0.9952), + (326,0.9952),(327,0.9952),(328,0.9952),(329,0.9951),(330,0.9941), + (331,0.9941),(332,0.9941),(333,0.994),(334,0.994),(335,0.994), + (336,0.9939),(337,0.9949),(338,0.9949),(339,0.9948),(340,0.9948), + (341,0.9948),(342,0.9948),(343,0.9947),(344,0.9937),(345,0.9936), + (346,0.9936),(347,0.9935),(348,0.9935),(349,0.9934),(350,0.9934), + (351,0.9934),(352,0.9933),(353,0.9933),(354,0.9932),(355,0.9932), + (356,0.9931),(357,0.9931),(358,0.9942),(359,0.9942),(360,0.9941), + (361,0.9941),(362,0.9941),(363,0.994),(364,0.994),(365,0.9927), + (366,0.9927),(367,0.9926),(368,0.9926),(369,0.9925),(370,0.9925), + (371,0.9924),(372,0.9936),(373,0.9936),(374,0.9935),(375,0.9935), + (376,0.9935),(377,0.9934),(378,0.9934),(379,0.9933),(380,0.9933), + (381,0.9932),(382,0.9932),(383,0.9931),(384,0.9931),(385,0.993), + (386,0.9916),(387,0.9915),(388,0.9915),(389,0.9914),(390,0.9913), + (391,0.9912),(392,0.9912),(393,0.9926),(394,0.9925),(395,0.9924), + (396,0.9924),(397,0.9923),(398,0.9923),(399,0.9922),(400,0.9922), + (401,0.9921),(402,0.992),(403,0.992),(404,0.9919),(405,0.9918), + (406,0.9918),(407,0.9917),(408,0.9916),(409,0.9916),(410,0.9915), + (411,0.9914),(412,0.9913),(413,0.9913),(414,0.9894),(415,0.9893), + (416,0.9892),(417,0.9891),(418,0.989),(419,0.9888),(420,0.9887), + (421,0.9905),(422,0.9904),(423,0.9903),(424,0.9902),(425,0.9901), + (426,0.99),(427,0.9899),(428,0.9898),(429,0.9897),(430,0.9896), + (431,0.9895),(432,0.9894),(433,0.9892),(434,0.9891),(435,0.989), + (436,0.9889),(437,0.9888),(438,0.9886),(439,0.9885),(440,0.9884), + (441,0.9882),(442,0.9881),(443,0.988),(444,0.9878),(445,0.9877), + (446,0.9875),(447,0.9873),(448,0.9872),(449,0.987),(450,0.9868), + (451,0.9867),(452,0.9865),(453,0.9863),(454,0.9861),(455,0.9859), + (456,0.9857),(457,0.9855),(458,0.9853),(459,0.9851),(460,0.9848), + (461,0.9846),(462,0.9844),(463,0.9873),(464,0.9871),(465,0.987), + (466,0.9868),(467,0.9866),(468,0.9864),(469,0.9863),(470,0.9826), + (471,0.9823),(472,0.9819),(473,0.9816),(474,0.9813),(475,0.9809), + (476,0.9805),(477,0.9802),(478,0.9798),(479,0.9793),(480,0.9789), + (481,0.9784),(482,0.978),(483,0.9775),(484,0.977),(485,0.9764), + (486,0.9758),(487,0.9752),(488,0.9746),(489,0.974),(490,0.9733), + (491,0.978),(492,0.9775),(493,0.977),(494,0.9765),(495,0.9759), + (496,0.9753),(497,0.9747),(498,0.9675),(499,0.9664),(500,0.9653), + (501,0.964),(502,0.9627),(503,0.9612),(504,0.9597),(505,0.9664), + (506,0.9652),(507,0.964),(508,0.9626),(509,0.9612),(510,0.9596), + (511,0.9579),(512,0.9451),(513,0.9419),(514,0.9383),(515,0.9342), + (516,0.9296),(517,0.9242),(518,0.918),(519,0.9286),(520,0.9231), + (521,0.9167),(522,0.9091),(523,0.9),(524,0.8889),(525,0.875), + (526,0.8571),(527,0.8333),(528,0.8),(529,0.75),(530,0.6667), + (531,0.5),(532,0) +) AS ch(day, val) ON oh.day = ch.day; + +-- Invalidate snapshot cache depreciation agar recompute dengan standard baru +DELETE FROM farm_depreciation_snapshots; diff --git a/internal/database/migrations/20260527074540_recalculate_economic_cutoff_25_weeks.down.sql b/internal/database/migrations/20260527074540_recalculate_economic_cutoff_25_weeks.down.sql new file mode 100644 index 00000000..ba5134c1 --- /dev/null +++ b/internal/database/migrations/20260527074540_recalculate_economic_cutoff_25_weeks.down.sql @@ -0,0 +1,22 @@ +-- Rollback: balik ke rule lama (19 minggu = 133 hari) + +BEGIN; + +UPDATE laying_transfers lt +SET economic_cutoff_date = sub.cutoff_date, + updated_at = NOW() +FROM ( + SELECT + lt2.id AS transfer_id, + (MIN(pc.chick_in_date)::date + INTERVAL '133 days')::date AS cutoff_date + FROM laying_transfers lt2 + JOIN project_chickins pc ON pc.project_flock_kandang_id = lt2.source_project_flock_kandang_id + WHERE lt2.deleted_at IS NULL + AND lt2.source_project_flock_kandang_id IS NOT NULL + AND pc.deleted_at IS NULL + GROUP BY lt2.id +) sub +WHERE lt.id = sub.transfer_id + AND lt.deleted_at IS NULL; + +COMMIT; diff --git a/internal/database/migrations/20260527074540_recalculate_economic_cutoff_25_weeks.up.sql b/internal/database/migrations/20260527074540_recalculate_economic_cutoff_25_weeks.up.sql new file mode 100644 index 00000000..8e8e69ff --- /dev/null +++ b/internal/database/migrations/20260527074540_recalculate_economic_cutoff_25_weeks.up.sql @@ -0,0 +1,24 @@ +-- Recalculate laying_transfers.economic_cutoff_date dari rule 19 minggu (lama) ke 25 minggu (baru, +-- sejalan dengan depreciation_start_age_day = 175). Semua transfer historis yang punya +-- source_project_flock_kandang_id akan di-update agar economic_cutoff_date = source.chick_in_date + 175 hari. + +BEGIN; + +UPDATE laying_transfers lt +SET economic_cutoff_date = sub.cutoff_date, + updated_at = NOW() +FROM ( + SELECT + lt2.id AS transfer_id, + (MIN(pc.chick_in_date)::date + INTERVAL '175 days')::date AS cutoff_date + FROM laying_transfers lt2 + JOIN project_chickins pc ON pc.project_flock_kandang_id = lt2.source_project_flock_kandang_id + WHERE lt2.deleted_at IS NULL + AND lt2.source_project_flock_kandang_id IS NOT NULL + AND pc.deleted_at IS NULL + GROUP BY lt2.id +) sub +WHERE lt.id = sub.transfer_id + AND lt.deleted_at IS NULL; + +COMMIT; diff --git a/internal/database/migrations/20260527074620_truncate_farm_depreciation_snapshots.down.sql b/internal/database/migrations/20260527074620_truncate_farm_depreciation_snapshots.down.sql new file mode 100644 index 00000000..288616ab --- /dev/null +++ b/internal/database/migrations/20260527074620_truncate_farm_depreciation_snapshots.down.sql @@ -0,0 +1,3 @@ +-- Down migration: tidak ada cara restore TRUNCATE. Snapshot akan auto-regenerate on demand. +-- File kosong sengaja: rollback safe karena snapshot dianggap cache yang bisa di-regenerate. +SELECT 1; diff --git a/internal/database/migrations/20260527074620_truncate_farm_depreciation_snapshots.up.sql b/internal/database/migrations/20260527074620_truncate_farm_depreciation_snapshots.up.sql new file mode 100644 index 00000000..627ea62d --- /dev/null +++ b/internal/database/migrations/20260527074620_truncate_farm_depreciation_snapshots.up.sql @@ -0,0 +1,10 @@ +-- Truncate semua farm_depreciation_snapshots agar di-recompute dengan logic baru: +-- 1. Multi-transfer per target kandang sekarang menghasilkan multiple parts (1 per transfer) +-- 2. Economic cutoff date sudah diupdate dari 19 minggu ke 25 minggu +-- 3. Format `components` JSON tetap kompatibel — yang berubah adalah jumlah entries (lebih banyak +-- untuk kandang multi-transfer) +-- +-- Snapshot akan otomatis di-regenerate saat user request `/api/reports/expense/depreciation` +-- untuk period yang relevan. + +TRUNCATE TABLE farm_depreciation_snapshots; diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index 8c33e053..7e280336 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -312,10 +312,10 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { mapped := warehouseDTO.ToWarehouseRelationDTO(*warehouse) dtoResult.Warehouse = &mapped } - if _, isLaying, serr := u.ProjectflockService.GetProjectFlockKandangTransferStateAtDate(c, result.Id, recordDate); serr != nil { + if isTransition, isLaying, serr := u.ProjectflockService.GetProjectFlockKandangTransferStateAtDate(c, result.Id, recordDate); serr != nil { return serr } else { - dtoResult.IsTransition = false + dtoResult.IsTransition = isTransition dtoResult.IsLaying = isLaying } applyCutOverLayingLookupOverride(&dtoResult) @@ -346,7 +346,7 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { } func applyCutOverLayingLookupOverride(result *dto.ProjectFlockKandangDTO) { - if result == nil || result.ProjectFlock == nil || result.IsLaying || result.ChickInDate == nil { + if result == nil || result.ProjectFlock == nil || result.IsLaying || result.IsTransition || result.ChickInDate == nil { return } diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index ce92d428..320fad14 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -588,17 +588,29 @@ func (s projectflockService) GetProjectFlockKandangTransferStateAtDate(ctx *fibe switch category { case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)): transfer, err = s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx.Context(), projectFlockKandangID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, false, nil + } + s.Log.Errorf("Failed to resolve transfer state for project flock kandang %d: %+v", projectFlockKandangID, err) + return false, false, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve transfer state") + } case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)): - transfer, err = s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx.Context(), projectFlockKandangID) - default: - return false, false, nil - } - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { + // Multi-source: target kandang bisa menerima dari multiple transfer terpisah. Pakai + // EARLIEST transfer (transfer_date ASC) sebagai anchor — kandang masuk transition/laying + // mengikuti batch pertama yang sampai. + allTransfers, allErr := s.TransferLayingRepo.GetAllApprovedByTargetKandang(ctx.Context(), projectFlockKandangID) + if allErr != nil { + s.Log.Errorf("Failed to resolve transfers for project flock kandang %d: %+v", projectFlockKandangID, allErr) + return false, false, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve transfer state") + } + if len(allTransfers) == 0 { return false, false, nil } - s.Log.Errorf("Failed to resolve transfer state for project flock kandang %d: %+v", projectFlockKandangID, err) - return false, false, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve transfer state") + // Repository ORDER BY transfer_date ASC, id ASC → [0] = earliest + transfer = &allTransfers[0] + default: + return false, false, nil } if transfer == nil { return false, false, nil diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 7ca84d95..f06216eb 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -198,10 +198,22 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti if err != nil { return nil, 0, err } - targetTransferByPFK, err := s.TransferLayingRepo.GetLatestApprovedByTargetKandangs(c.Context(), layingPFKIDs) + // Multi-source support: 1 target kandang bisa menerima dari multiple transfer terpisah. + // Untuk state evaluation (IsTransition/IsLaying), kita pakai EARLIEST transfer sebagai anchor + // (sesuai dengan rule "kandang masuk fase laying mengikuti batch pertama yang sampai"). + allTransfersByTarget, err := s.TransferLayingRepo.GetAllApprovedByTargetKandangs(c.Context(), layingPFKIDs) if err != nil { return nil, 0, err } + targetTransferByPFK := make(map[uint]*entity.LayingTransfer, len(allTransfersByTarget)) + for pfkID, list := range allTransfersByTarget { + if len(list) == 0 { + continue + } + // list sudah ORDER BY transfer_date ASC, id ASC → element [0] adalah earliest + earliest := list[0] + targetTransferByPFK[pfkID] = &earliest + } hasTargetRecordingCache := make(map[uint]bool) cutOverChickinAvailability := make(map[uint]bool) @@ -1292,17 +1304,29 @@ func (s *recordingService) evaluatePopulationMutationState(ctx context.Context, switch category { case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)): transfer, err = s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, recording.ProjectFlockKandangId) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return true, false, false, false, nil, time.Time{}, nil + } + s.Log.Errorf("Failed to resolve approved transfer for recording %d: %+v", recording.Id, err) + return true, false, false, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording") + } case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)): - transfer, err = s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx, recording.ProjectFlockKandangId) - default: - return true, false, false, false, nil, time.Time{}, nil - } - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { + // Multi-source: target kandang bisa menerima dari multiple transfer terpisah. + // Pakai EARLIEST transfer (transfer_date ASC) sebagai anchor untuk state evaluation — + // kandang dianggap masuk transition/laying berdasarkan batch pertama yang masuk. + allTransfers, allErr := s.TransferLayingRepo.GetAllApprovedByTargetKandang(ctx, recording.ProjectFlockKandangId) + if allErr != nil { + s.Log.Errorf("Failed to resolve approved transfers for recording %d: %+v", recording.Id, allErr) + return true, false, false, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording") + } + if len(allTransfers) == 0 { return true, false, false, false, nil, time.Time{}, nil } - s.Log.Errorf("Failed to resolve approved transfer for recording %d: %+v", recording.Id, err) - return true, false, false, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording") + // Repository sudah ORDER BY transfer_date ASC, id ASC → element [0] adalah earliest. + transfer = &allTransfers[0] + default: + return true, false, false, false, nil, time.Time{}, nil } if transfer == nil { return true, false, false, false, nil, time.Time{}, nil diff --git a/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go b/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go index 1b9dfe9f..bae0f59e 100644 --- a/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go +++ b/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go @@ -19,6 +19,11 @@ type TransferLayingRepository interface { GetLatestApprovedByTargetKandang(ctx context.Context, targetProjectFlockKandangID uint) (*entity.LayingTransfer, error) GetLatestApprovedBySourceKandangs(ctx context.Context, pfkIDs []uint) (map[uint]*entity.LayingTransfer, error) GetLatestApprovedByTargetKandangs(ctx context.Context, pfkIDs []uint) (map[uint]*entity.LayingTransfer, error) + // GetAllApprovedByTargetKandang return semua approved transfer yang menuju ke target kandang itu. + // Dipakai untuk multi-source case di mana 1 target kandang bisa menerima dari multiple transfer + // terpisah (tiap transfer = 1 source). Order: transfer_date ASC, id ASC (kronologis). + GetAllApprovedByTargetKandang(ctx context.Context, targetProjectFlockKandangID uint) ([]entity.LayingTransfer, error) + GetAllApprovedByTargetKandangs(ctx context.Context, pfkIDs []uint) (map[uint][]entity.LayingTransfer, error) // Tambah method baru untuk query dengan filter lengkap GetAllWithFilters(ctx context.Context, offset int, limit int, params *GetAllFilterParams) ([]entity.LayingTransfer, int64, error) @@ -362,3 +367,89 @@ func (r *TransferLayingRepositoryImpl) GetLatestApprovedByTargetKandangs(ctx con } return result, nil } + +// GetAllApprovedByTargetKandang return SEMUA approved transfer ke target kandang itu (bukan hanya yang +// terbaru). Dipakai untuk skenario multi-source di mana 1 target kandang menerima dari multiple transfer +// terpisah, sehingga depresiasi/HPP/recording state perlu aggregate dari semua transfer. +func (r *TransferLayingRepositoryImpl) GetAllApprovedByTargetKandang(ctx context.Context, targetProjectFlockKandangID uint) ([]entity.LayingTransfer, error) { + if targetProjectFlockKandangID == 0 { + return nil, nil + } + + var transfers []entity.LayingTransfer + err := r.db.WithContext(ctx). + Model(&entity.LayingTransfer{}). + Joins("JOIN laying_transfer_targets ltt ON ltt.laying_transfer_id = laying_transfers.id AND ltt.deleted_at IS NULL"). + Where("ltt.target_project_flock_kandang_id = ?", targetProjectFlockKandangID). + Where("laying_transfers.deleted_at IS NULL"). + Where(`( + SELECT a.action + FROM approvals a + WHERE a.approvable_type = ? + AND a.approvable_id = laying_transfers.id + ORDER BY a.id DESC + LIMIT 1 + ) = ?`, string(utils.ApprovalWorkflowTransferToLaying), entity.ApprovalActionApproved). + Order("laying_transfers.transfer_date ASC, laying_transfers.id ASC"). + Distinct("laying_transfers.*"). + Find(&transfers).Error + if err != nil { + return nil, err + } + return transfers, nil +} + +// GetAllApprovedByTargetKandangs batch version: return map dari target_pfk_id ke list of approved transfers. +// Order per target: transfer_date ASC, id ASC. +func (r *TransferLayingRepositoryImpl) GetAllApprovedByTargetKandangs(ctx context.Context, pfkIDs []uint) (map[uint][]entity.LayingTransfer, error) { + result := make(map[uint][]entity.LayingTransfer) + if len(pfkIDs) == 0 { + return result, nil + } + + type targetTransferRow struct { + TargetPFKID uint `gorm:"column:target_pfk_id"` + TransferID uint `gorm:"column:transfer_id"` + } + + var rows []targetTransferRow + err := r.db.WithContext(ctx).Raw(` + SELECT ltt.target_project_flock_kandang_id AS target_pfk_id, ltt.laying_transfer_id AS transfer_id + FROM laying_transfer_targets ltt + JOIN laying_transfers t ON t.id = ltt.laying_transfer_id AND t.deleted_at IS NULL + WHERE ltt.target_project_flock_kandang_id IN ? + AND ltt.deleted_at IS NULL + AND ( + SELECT a.action FROM approvals a + WHERE a.approvable_type = ? AND a.approvable_id = t.id + ORDER BY a.id DESC LIMIT 1 + ) = ? + ORDER BY t.transfer_date ASC, t.id ASC + `, + pfkIDs, string(utils.ApprovalWorkflowTransferToLaying), string(entity.ApprovalActionApproved), + ).Scan(&rows).Error + if err != nil { + return nil, err + } + if len(rows) == 0 { + return result, nil + } + + transferIDs := make([]uint, 0, len(rows)) + targetsByTransfer := make(map[uint][]uint, len(rows)) + for _, row := range rows { + transferIDs = append(transferIDs, row.TransferID) + targetsByTransfer[row.TransferID] = append(targetsByTransfer[row.TransferID], row.TargetPFKID) + } + + var transfers []entity.LayingTransfer + if err := r.db.WithContext(ctx).Where("id IN ? AND deleted_at IS NULL", transferIDs).Order("transfer_date ASC, id ASC").Find(&transfers).Error; err != nil { + return nil, err + } + for i := range transfers { + for _, targetID := range targetsByTransfer[transfers[i].Id] { + result[targetID] = append(result[targetID], transfers[i]) + } + } + return result, nil +} diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index cff2b067..978b7490 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -1617,6 +1617,13 @@ func (s *transferLayingService) validateKandangOwnership( return nil } +// validateTargetSourceLineage memvalidasi bahwa source kandang yang sama TIDAK boleh ditransfer 2x ke +// target kandang yang sama (anti-duplicate pair). Aturan lama "satu target hanya boleh punya satu +// source" sudah dihapus — sekarang 1 target boleh menerima dari multiple source kandang via transfer +// terpisah (multi-source via N-call approach). +// +// Yang ditolak: kalau ada approved transfer lain (id != excludeTransferID) yang punya pair +// (source = sourceProjectFlockKandangID, target ∈ targetKandangIDs) yang sama. func (s *transferLayingService) validateTargetSourceLineage( ctx context.Context, sourceProjectFlockKandangID uint, @@ -1637,7 +1644,7 @@ func (s *transferLayingService) validateTargetSourceLineage( } seen[targetKandangID] = struct{}{} - existingTransfer, err := s.Repository.GetLatestApprovedByTargetKandang(ctx, targetKandangID) + existingTransfers, err := s.Repository.GetAllApprovedByTargetKandang(ctx, targetKandangID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { continue @@ -1645,47 +1652,49 @@ func (s *transferLayingService) validateTargetSourceLineage( s.Log.Errorf("Failed to validate transfer lineage for target kandang %d: %+v", targetKandangID, err) return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi relasi sumber transfer ke laying") } - if existingTransfer == nil { - continue - } - if excludeTransferID != 0 && existingTransfer.Id == excludeTransferID { - continue - } - existingSourceID := uint(0) - if existingTransfer.SourceProjectFlockKandangId != nil && *existingTransfer.SourceProjectFlockKandangId != 0 { - existingSourceID = *existingTransfer.SourceProjectFlockKandangId - } - if existingSourceID == 0 && s.LayingTransferSourceRepo != nil { - sources, sourceErr := s.LayingTransferSourceRepo.GetByLayingTransferId(ctx, existingTransfer.Id) - if sourceErr != nil { - s.Log.Errorf("Failed to resolve transfer sources for lineage validation transfer=%d: %+v", existingTransfer.Id, sourceErr) - return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi relasi sumber transfer ke laying") + for i := range existingTransfers { + existingTransfer := &existingTransfers[i] + if excludeTransferID != 0 && existingTransfer.Id == excludeTransferID { + continue } - for _, source := range sources { - if source.SourceProjectFlockKandangId != 0 { - existingSourceID = source.SourceProjectFlockKandangId - break + + // Source di header (single source of truth per migration 20260307130342). + existingSourceID := uint(0) + if existingTransfer.SourceProjectFlockKandangId != nil && *existingTransfer.SourceProjectFlockKandangId != 0 { + existingSourceID = *existingTransfer.SourceProjectFlockKandangId + } + + // Fallback ke laying_transfer_sources untuk transfer yang belum punya source di header + // (historis pre-migration 20260307130342). + if existingSourceID == 0 && s.LayingTransferSourceRepo != nil { + sources, sourceErr := s.LayingTransferSourceRepo.GetByLayingTransferId(ctx, existingTransfer.Id) + if sourceErr != nil { + s.Log.Errorf("Failed to resolve transfer sources for lineage validation transfer=%d: %+v", existingTransfer.Id, sourceErr) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi relasi sumber transfer ke laying") + } + for _, source := range sources { + if source.SourceProjectFlockKandangId == sourceProjectFlockKandangID { + existingSourceID = source.SourceProjectFlockKandangId + break + } } } - } - if existingSourceID == 0 { - continue - } - if existingSourceID == sourceProjectFlockKandangID { - continue - } - return fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf( - "Kandang tujuan %d sudah memiliki lineage sumber kandang %d dari transfer %s. Tidak boleh ganti ke sumber kandang %d.", - targetKandangID, - existingSourceID, - existingTransfer.TransferNumber, - sourceProjectFlockKandangID, - ), - ) + if existingSourceID != sourceProjectFlockKandangID { + continue + } + + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf( + "Source kandang %d sudah pernah ditransfer ke target kandang %d via transfer %s. Tidak boleh duplikat (source, target) pair yang sama.", + sourceProjectFlockKandangID, + targetKandangID, + existingTransfer.TransferNumber, + ), + ) + } } return nil diff --git a/internal/modules/repports/dto/repportExpenseDepreciation.dto.go b/internal/modules/repports/dto/repportExpenseDepreciation.dto.go index e7e3f4fd..202e61d1 100644 --- a/internal/modules/repports/dto/repportExpenseDepreciation.dto.go +++ b/internal/modules/repports/dto/repportExpenseDepreciation.dto.go @@ -16,13 +16,17 @@ type ExpenseDepreciationMetaDTO struct { } type ExpenseDepreciationRowDTO struct { - ProjectFlockID int64 `json:"project_flock_id"` - FarmName string `json:"farm_name"` - Period string `json:"period"` - DepreciationPercentEffective float64 `json:"depreciation_percent_effective"` - DepreciationValue float64 `json:"depreciation_value"` - PulletCostDayNTotal float64 `json:"pullet_cost_day_n_total"` - Components any `json:"components"` + ProjectFlockID int64 `json:"project_flock_id"` + FarmName string `json:"farm_name"` + Period string `json:"period"` + DepreciationPercentEffective float64 `json:"depreciation_percent_effective"` + DepreciationValue float64 `json:"depreciation_value"` + PulletCostDayNTotal float64 `json:"pullet_cost_day_n_total"` + MultiplicationPercentage float64 `json:"multiplication_percentage"` + DayN int `json:"day_n"` + ChickinDate string `json:"chickin_date"` + TotalValuePulletAfterDepreciation float64 `json:"total_value_pullet_after_depreciation"` + Components any `json:"components"` } type ExpenseDepreciationManualInputRowDTO struct { diff --git a/internal/modules/repports/repositories/expense_depreciation.repository.go b/internal/modules/repports/repositories/expense_depreciation.repository.go index 7e058a0b..054fa8dd 100644 --- a/internal/modules/repports/repositories/expense_depreciation.repository.go +++ b/internal/modules/repports/repositories/expense_depreciation.repository.go @@ -37,10 +37,10 @@ type FarmDepreciationManualInputRow struct { Note *string } -type houseDepreciationPercentRow struct { - HouseType string - Day int - DepreciationPercent float64 +type houseMultiplicationPercentageRow struct { + HouseType string + Day int + MultiplicationPercentage float64 } type ExpenseDepreciationRepository interface { @@ -48,8 +48,9 @@ type ExpenseDepreciationRepository interface { GetSnapshotsByPeriodAndFarmIDs(ctx context.Context, period time.Time, farmIDs []uint) ([]entity.FarmDepreciationSnapshot, error) UpsertSnapshots(ctx context.Context, rows []entity.FarmDepreciationSnapshot) error DeleteSnapshotsFromDate(ctx context.Context, fromDate time.Time, farmIDs []uint) error + DeleteSnapshotsByFarmIDs(ctx context.Context, farmIDs []uint) error GetLatestTransferInputsByFarms(ctx context.Context, period time.Time, farmIDs []uint) ([]FarmDepreciationLatestTransferRow, error) - GetDepreciationPercents(ctx context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, error) + GetMultiplicationPercentages(ctx context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, error) GetLatestManualInputsByFarms(ctx context.Context, areaIDs, locationIDs, projectFlockIDs []int64) ([]FarmDepreciationManualInputRow, error) UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error DB() *gorm.DB @@ -159,6 +160,17 @@ func (r *expenseDepreciationRepository) DeleteSnapshotsFromDate( return query.Delete(nil).Error } +func (r *expenseDepreciationRepository) DeleteSnapshotsByFarmIDs(ctx context.Context, farmIDs []uint) error { + if len(farmIDs) == 0 { + return nil + } + + return r.db.WithContext(ctx). + Table("farm_depreciation_snapshots"). + Where("project_flock_id IN ?", farmIDs). + Delete(nil).Error +} + func (r *expenseDepreciationRepository) GetLatestTransferInputsByFarms( ctx context.Context, period time.Time, @@ -228,7 +240,7 @@ ORDER BY ltt.target_project_flock_kandang_id, at.effective_date DESC, at.id DESC return rows, nil } -func (r *expenseDepreciationRepository) GetDepreciationPercents( +func (r *expenseDepreciationRepository) GetMultiplicationPercentages( ctx context.Context, houseTypes []string, maxDay int, @@ -238,14 +250,14 @@ func (r *expenseDepreciationRepository) GetDepreciationPercents( return result, nil } - rows := make([]houseDepreciationPercentRow, 0) - if 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; err != nil { + rows := make([]houseMultiplicationPercentageRow, 0) + if err := r.db.WithContext(ctx).Raw(` + SELECT DISTINCT ON (house_type::text, day) + house_type::text AS house_type, day, multiplication_percentage + FROM house_depreciation_standards + WHERE house_type::text IN ? AND day <= ? + ORDER BY house_type, day, effective_date DESC NULLS LAST + `, houseTypes, maxDay).Scan(&rows).Error; err != nil { return nil, err } @@ -253,7 +265,7 @@ func (r *expenseDepreciationRepository) GetDepreciationPercents( if _, exists := result[row.HouseType]; !exists { result[row.HouseType] = make(map[int]float64) } - result[row.HouseType][row.Day] = row.DepreciationPercent + result[row.HouseType][row.Day] = row.MultiplicationPercentage } return result, nil diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 4e2a9482..1d6d590d 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -237,6 +237,9 @@ func (s *repportService) GetExpenseDepreciation(ctx *fiber.Ctx) ([]dto.ExpenseDe snapshotByFarmID := make(map[uint]entity.FarmDepreciationSnapshot) if params.ForceRecompute { + if err := s.ExpenseDepreciationRepo.DeleteSnapshotsByFarmIDs(ctx.Context(), farmIDs); err != nil { + return nil, nil, err + } computedSnapshots, computeErr := s.computeExpenseDepreciationSnapshots(ctx.Context(), periodDate, farmIDs, farmNameByID) if computeErr != nil { return nil, nil, computeErr @@ -289,24 +292,31 @@ func (s *repportService) GetExpenseDepreciation(ctx *fiber.Ctx) ([]dto.ExpenseDe snapshot, exists := snapshotByFarmID[candidate.ProjectFlockID] if !exists { rows = append(rows, dto.ExpenseDepreciationRowDTO{ - ProjectFlockID: int64(candidate.ProjectFlockID), - FarmName: candidate.FarmName, - Period: params.Period, - DepreciationPercentEffective: 0, - DepreciationValue: 0, - PulletCostDayNTotal: 0, - Components: map[string]any{}, + ProjectFlockID: int64(candidate.ProjectFlockID), + FarmName: candidate.FarmName, + Period: params.Period, + DepreciationPercentEffective: 0, + DepreciationValue: 0, + PulletCostDayNTotal: 0, + TotalValuePulletAfterDepreciation: 0, + Components: map[string]any{}, }) continue } + components := parseSnapshotComponents(snapshot.Components) + multiplicationPercentage, dayN, chickinDate := depreciationSnapshotInfo(components) rows = append(rows, dto.ExpenseDepreciationRowDTO{ - ProjectFlockID: int64(snapshot.ProjectFlockId), - FarmName: candidate.FarmName, - Period: params.Period, - DepreciationPercentEffective: snapshot.DepreciationPercentEffective, - DepreciationValue: snapshot.DepreciationValue, - PulletCostDayNTotal: snapshot.PulletCostDayNTotal, - Components: parseSnapshotComponents(snapshot.Components), + ProjectFlockID: int64(snapshot.ProjectFlockId), + FarmName: candidate.FarmName, + Period: params.Period, + DepreciationPercentEffective: snapshot.DepreciationPercentEffective, + DepreciationValue: snapshot.DepreciationValue, + PulletCostDayNTotal: snapshot.PulletCostDayNTotal, + MultiplicationPercentage: multiplicationPercentage, + DayN: dayN, + ChickinDate: chickinDate, + TotalValuePulletAfterDepreciation: snapshot.PulletCostDayNTotal - snapshot.DepreciationValue, + Components: components, }) } @@ -472,23 +482,26 @@ func (s *repportService) UpsertExpenseDepreciationManualInput(ctx *fiber.Ctx, re } type depreciationKandangComponent struct { - ProjectFlockKandangID uint `json:"project_flock_kandang_id"` - KandangID uint `json:"kandang_id"` - KandangName string `json:"kandang_name"` - TransferID uint `json:"transfer_id"` - TransferDate string `json:"transfer_date"` - SourceProjectFlockID uint `json:"source_project_flock_id"` - HouseType string `json:"house_type"` - DayN int `json:"day_n"` - DepreciationPercent float64 `json:"depreciation_percent"` - TransferQty float64 `json:"transfer_qty"` - PulletCostDayN float64 `json:"pullet_cost_day_n"` - DepreciationValue float64 `json:"depreciation_value"` - DepreciationSource string `json:"depreciation_source,omitempty"` - ManualInputID *uint `json:"manual_input_id,omitempty"` - CutoverDate string `json:"cutover_date,omitempty"` - OriginDate string `json:"origin_date,omitempty"` - StartScheduleDay *int `json:"start_schedule_day,omitempty"` + ProjectFlockKandangID uint `json:"project_flock_kandang_id"` + KandangID uint `json:"kandang_id"` + KandangName string `json:"kandang_name"` + TransferID uint `json:"transfer_id"` + TransferDate string `json:"transfer_date"` + SourceProjectFlockID uint `json:"source_project_flock_id"` + HouseType string `json:"house_type"` + DayN int `json:"day_n"` + DepreciationPercent float64 `json:"depreciation_percent"` + MultiplicationPercentage float64 `json:"multiplication_percentage"` + TransferQty float64 `json:"transfer_qty"` + PulletCostDayN float64 `json:"pullet_cost_day_n"` + DepreciationValue float64 `json:"depreciation_value"` + TotalValuePulletAfterDepreciation float64 `json:"total_value_pullet_after_depreciation"` + DepreciationSource string `json:"depreciation_source,omitempty"` + ManualInputID *uint `json:"manual_input_id,omitempty"` + CutoverDate string `json:"cutover_date,omitempty"` + OriginDate string `json:"origin_date,omitempty"` + ChickinDate string `json:"chickin_date,omitempty"` + StartScheduleDay *int `json:"start_schedule_day,omitempty"` } type depreciationFarmComponents struct { @@ -548,17 +561,20 @@ func (s *repportService) computeExpenseDepreciationSnapshots( houseType := approvalService.NormalizeDepreciationHouseType(breakdown.HouseType) component := depreciationKandangComponent{ - ProjectFlockKandangID: breakdown.ProjectFlockKandangID, - KandangID: breakdown.KandangID, - KandangName: breakdown.KandangName, - SourceProjectFlockID: hppV2DetailUint(part.Details, "source_project_flock_id"), - HouseType: houseType, - DayN: hppV2DetailInt(part.Details, "schedule_day"), - DepreciationPercent: hppV2DetailFloat(part.Details, "depreciation_percent"), - PulletCostDayN: hppV2DetailFloat(part.Details, "pullet_cost_day_n"), - DepreciationValue: part.Total, - DepreciationSource: part.Code, - OriginDate: hppV2DetailString(part.Details, "origin_date"), + ProjectFlockKandangID: breakdown.ProjectFlockKandangID, + KandangID: breakdown.KandangID, + KandangName: breakdown.KandangName, + SourceProjectFlockID: hppV2DetailUint(part.Details, "source_project_flock_id"), + HouseType: houseType, + DayN: hppV2DetailInt(part.Details, "schedule_day"), + DepreciationPercent: hppV2DetailFloat(part.Details, "depreciation_percent"), + MultiplicationPercentage: hppV2DetailFloat(part.Details, "multiplication_percentage"), + PulletCostDayN: hppV2DetailFloat(part.Details, "pullet_cost_day_n"), + DepreciationValue: part.Total, + TotalValuePulletAfterDepreciation: hppV2DetailFloat(part.Details, "total_value_pullet_after_depreciation"), + DepreciationSource: part.Code, + OriginDate: hppV2DetailString(part.Details, "origin_date"), + ChickinDate: hppV2DetailString(part.Details, "origin_date"), } if component.HouseType == "" { @@ -700,8 +716,11 @@ func hppV2DetailString(details map[string]any, key string) string { if details == nil || key == "" { return "" } - raw, exists := details[key] - if !exists || raw == nil { + return anyString(details[key]) +} + +func anyString(raw any) string { + if raw == nil { return "" } switch value := raw.(type) { @@ -725,6 +744,68 @@ func parseSnapshotComponents(raw []byte) any { return out } +func depreciationSnapshotInfo(components any) (float64, int, string) { + root, ok := components.(map[string]any) + if !ok { + return 0, 0, "" + } + kandang, ok := root["kandang"].([]any) + if !ok { + return 0, 0, "" + } + for _, raw := range kandang { + component, ok := raw.(map[string]any) + if !ok { + continue + } + dayN := int(math.Round(anyFloat(component["day_n"]))) + multiplicationPercentage := anyFloat(component["multiplication_percentage"]) + chickinDate := anyString(component["chickin_date"]) + if chickinDate == "" { + chickinDate = anyString(component["origin_date"]) + } + if dayN > 0 || multiplicationPercentage > 0 || chickinDate != "" { + return multiplicationPercentage, dayN, chickinDate + } + } + return 0, 0, "" +} + +func anyFloat(raw any) float64 { + switch value := raw.(type) { + case float64: + return value + case float32: + return float64(value) + case int: + return float64(value) + case int8: + return float64(value) + case int16: + return float64(value) + case int32: + return float64(value) + case int64: + return float64(value) + case uint: + return float64(value) + case uint8: + return float64(value) + case uint16: + return float64(value) + case uint32: + return float64(value) + case uint64: + return float64(value) + case string: + parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err == nil { + return parsed + } + } + return 0 +} + func valueOrEmptyString(v *string) string { if v == nil { return "" @@ -1812,6 +1893,15 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu return nil, 0, err } + expenseIDs := make([]uint64, 0, len(expenses)) + for _, exp := range expenses { + expenseIDs = append(expenseIDs, exp.Id) + } + expenseWarehousesMap, err := s.DebtSupplierRepo.GetWarehousesByExpenseIDs(c.Context(), expenseIDs) + if err != nil { + return nil, 0, err + } + purchasesBySupplier := make(map[uint][]entity.Purchase, len(supplierIDs)) for _, purchase := range purchases { purchasesBySupplier[purchase.SupplierId] = append(purchasesBySupplier[purchase.SupplierId], purchase) @@ -1906,7 +1996,7 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu } for _, exp := range expensesBySupplier[supplierID] { - row := buildDebtSupplierExpenseRow(exp, now, location) + row := buildDebtSupplierExpenseRow(exp, expenseWarehousesMap[exp.Id], now, location) sortTime := exp.TransactionDate.In(location) rowIndex := len(combinedRows) combinedRows = append(combinedRows, debtSupplierRowItem{ @@ -2068,7 +2158,8 @@ func buildDebtSupplierRow(purchase entity.Purchase, now time.Time, loc *time.Loc travelNumber := "-" receivedDate := "" var area *areaDTO.AreaRelationDTO - var warehouse *warehouseDTO.WarehouseRelationDTO + warehouses := []warehouseDTO.WarehouseRelationDTO{} + seenWarehouseIDs := map[uint]bool{} if len(purchase.Items) > 0 { firstItem := purchase.Items[0] @@ -2076,24 +2167,22 @@ func buildDebtSupplierRow(purchase entity.Purchase, now time.Time, loc *time.Loc travelNumber = *firstItem.TravelNumber } - if firstItem.Warehouse != nil && firstItem.Warehouse.Id != 0 { - mappedWarehouse := warehouseDTO.ToWarehouseRelationDTO(*firstItem.Warehouse) - warehouse = &mappedWarehouse - if firstItem.Warehouse.Area.Id != 0 { - mappedArea := areaDTO.ToAreaRelationDTO(firstItem.Warehouse.Area) - area = &mappedArea - } - } - earliestReceived := time.Time{} for _, item := range purchase.Items { totalPrice += item.TotalPrice - if item.ReceivedDate == nil || item.ReceivedDate.IsZero() { - continue + if item.ReceivedDate != nil && !item.ReceivedDate.IsZero() { + received := item.ReceivedDate.In(loc) + if earliestReceived.IsZero() || received.Before(earliestReceived) { + earliestReceived = received + } } - received := item.ReceivedDate.In(loc) - if earliestReceived.IsZero() || received.Before(earliestReceived) { - earliestReceived = received + if item.Warehouse != nil && item.Warehouse.Id != 0 && !seenWarehouseIDs[item.Warehouse.Id] { + seenWarehouseIDs[item.Warehouse.Id] = true + warehouses = append(warehouses, warehouseDTO.ToWarehouseRelationDTO(*item.Warehouse)) + if area == nil && item.Warehouse.Area.Id != 0 { + mappedArea := areaDTO.ToAreaRelationDTO(item.Warehouse.Area) + area = &mappedArea + } } } if !earliestReceived.IsZero() { @@ -2126,7 +2215,7 @@ func buildDebtSupplierRow(purchase entity.Purchase, now time.Time, loc *time.Loc ReceivedDate: receivedDate, Aging: aging, Area: area, - Warehouse: warehouse, + Warehouses: warehouses, DueDate: dueDate, DueStatus: dueStatus, TotalPrice: totalPrice, @@ -2156,7 +2245,7 @@ func buildDebtSupplierPaymentRow(payment entity.Payment, loc *time.Location) dto ReceivedDate: payment.PaymentDate.In(loc).Format("2006-01-02"), Aging: 0, Area: nil, - Warehouse: nil, + Warehouses: []warehouseDTO.WarehouseRelationDTO{}, DueDate: "-", DueStatus: "-", TotalPrice: 0, @@ -2260,7 +2349,7 @@ func resolveDebtSupplierReceivedDate(purchase entity.Purchase, loc *time.Locatio return time.Date(earliest.Year(), earliest.Month(), earliest.Day(), 0, 0, 0, 0, loc) } -func buildDebtSupplierExpenseRow(exp entity.Expense, now time.Time, loc *time.Location) dto.DebtSupplierRowDTO { +func buildDebtSupplierExpenseRow(exp entity.Expense, warehouses []entity.Warehouse, now time.Time, loc *time.Location) dto.DebtSupplierRowDTO { txDate := exp.TransactionDate.In(loc) dateStr := txDate.Format("2006-01-02") @@ -2282,6 +2371,15 @@ func buildDebtSupplierExpenseRow(exp entity.Expense, now time.Time, loc *time.Lo area = &mapped } + warehouseDTOs := make([]warehouseDTO.WarehouseRelationDTO, 0, len(warehouses)) + seenWarehouseIDs := map[uint]bool{} + for _, w := range warehouses { + if w.Id != 0 && !seenWarehouseIDs[w.Id] { + seenWarehouseIDs[w.Id] = true + warehouseDTOs = append(warehouseDTOs, warehouseDTO.ToWarehouseRelationDTO(w)) + } + } + poNumber := "" if strings.TrimSpace(exp.PoNumber) != "" { poNumber = exp.PoNumber @@ -2294,7 +2392,7 @@ func buildDebtSupplierExpenseRow(exp entity.Expense, now time.Time, loc *time.Lo ReceivedDate: dateStr, Aging: aging, Area: area, - Warehouse: nil, + Warehouses: warehouseDTOs, DueDate: "-", DueStatus: "-", TotalPrice: totalPrice, diff --git a/internal/utils/recording/recording_helpers.go b/internal/utils/recording/recording_helpers.go index be96d12c..fda16f48 100644 --- a/internal/utils/recording/recording_helpers.go +++ b/internal/utils/recording/recording_helpers.go @@ -244,8 +244,12 @@ func AttachProductionStandards(ctx context.Context, db *gorm.DB, warnOnly bool, growthDetailByStd[standardID] = growthMap } - // Batch-load laying transfer targets → source PFK chick_in_dates - // untuk menentukan actual chicken week (bukan hardcode LayingWeekStart offset) + // Batch-load laying transfer targets → EARLIEST source PFK chick_in_date per target. + // Multi-source: 1 target kandang bisa menerima dari multiple transfer terpisah. Untuk + // production standard week, kita pakai chick_in_date PALING AWAL (umur paling tua) sebagai + // anchor — agar perbandingan standar produksi tidak under-estimate umur ayam. + // Source diambil dari header `laying_transfers.source_project_flock_kandang_id` (single source + // of truth per migration 20260307130342), bukan dari `laying_transfer_sources`. type transferChickIn struct { TargetPFKID uint ChickInDate time.Time @@ -255,14 +259,16 @@ func AttachProductionStandards(ctx context.Context, db *gorm.DB, warnOnly bool, if len(layingPFKIDs) > 0 { var results []transferChickIn db.Raw(` - SELECT ltt.target_project_flock_kandang_id AS target_pfk_id, pc.chick_in_date + SELECT ltt.target_project_flock_kandang_id AS target_pfk_id, + MIN(pc.chick_in_date) AS chick_in_date FROM laying_transfer_targets ltt - JOIN laying_transfer_sources lts ON lts.laying_transfer_id = ltt.laying_transfer_id - JOIN project_chickins pc ON pc.project_flock_kandang_id = lts.source_project_flock_kandang_id + JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id AND lt.deleted_at IS NULL + JOIN project_chickins pc ON pc.project_flock_kandang_id = lt.source_project_flock_kandang_id WHERE ltt.target_project_flock_kandang_id IN ? AND ltt.deleted_at IS NULL - AND lts.deleted_at IS NULL + AND lt.source_project_flock_kandang_id IS NOT NULL AND pc.deleted_at IS NULL + GROUP BY ltt.target_project_flock_kandang_id `, layingPFKIDs).Scan(&results) for _, r := range results { sourceChickInByTarget[r.TargetPFKID] = r.ChickInDate