Compare commits

...

7 Commits

Author SHA1 Message Date
giovanni fecbcab48d initial refactori trasnfer to laying, and depretitation to 25 week 2026-05-27 15:00:13 +07:00
Giovanni Gabriel Septriadi 2da476b276 Merge branch 'development' into 'production'
add excel export for purchase supplier report

See merge request mbugroup/lti-api!563
2026-05-25 08:16:24 +00:00
Giovanni Gabriel Septriadi 3232fc90bb Merge branch 'export/marketing' into 'development'
fix list penjualan and export penjualan dengan qty

See merge request mbugroup/lti-api!562
2026-05-25 07:53:38 +00:00
Rivaldi A N S 55666c1dcd Merge branch 'feat/export-balance-monitoring' into 'development'
[FEAT][BE] Export Balance Monitoring

See merge request mbugroup/lti-api!561
2026-05-25 07:25:03 +00:00
ValdiANS c107f0f683 feat(reports): add Excel export to balance monitoring endpoint
Add ?export=excel support to GetBalanceMonitoring. Creates a new
repport.balance_monitoring.export.go with a 2-row merged header layout
matching the UI (Penjualan Ayam and Penjualan Telur grouped columns),
a totals row, red styling for negative Saldo Akhir, and frozen panes
below the header rows. Exported data reflects all active query filters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 14:18:47 +07:00
Rivaldi A N S ba8f00a560 Merge branch 'feat/export-report-purchases-per-supplier' into 'development'
[FEAT][BE] Export Report Purchases Per Supplier

See merge request mbugroup/lti-api!560
2026-05-25 04:35:15 +00:00
ValdiANS 65a1282312 add excel export for purchase supplier report
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 11:25:53 +07:00
23 changed files with 1730 additions and 223 deletions
@@ -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
@@ -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 {
+114 -42
View File
@@ -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 {
@@ -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
}
+4 -1
View File
@@ -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
@@ -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);
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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
}
@@ -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
@@ -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
@@ -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
}
@@ -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
@@ -0,0 +1,286 @@
package controller
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
"gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto"
)
func isBalanceMonitoringExcelExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel")
}
func exportBalanceMonitoringExcel(c *fiber.Ctx, items []dto.BalanceMonitoringRowDTO, totals dto.BalanceMonitoringTotalsDTO) error {
content, err := buildBalanceMonitoringWorkbook(items, totals)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("laporan-balance-monitoring-%s.xlsx", time.Now().Format("2006-01-02-1504"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
return c.Status(fiber.StatusOK).Send(content)
}
func buildBalanceMonitoringWorkbook(items []dto.BalanceMonitoringRowDTO, totals dto.BalanceMonitoringTotalsDTO) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
const sheet = "Balance Monitoring"
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if defaultSheet != sheet {
if err := file.SetSheetName(defaultSheet, sheet); err != nil {
return nil, err
}
}
if err := setBalanceMonitoringColumns(file, sheet); err != nil {
return nil, err
}
if err := setBalanceMonitoringHeaders(file, sheet); err != nil {
return nil, err
}
if err := writeBalanceMonitoringRows(file, sheet, items, totals); err != nil {
return nil, err
}
if err := file.SetPanes(sheet, &excelize.Panes{
Freeze: true,
YSplit: 2,
TopLeftCell: "A3",
ActivePane: "bottomLeft",
}); err != nil {
return nil, err
}
buf, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
var bmColumnWidths = map[string]float64{
"A": 5,
"B": 28,
"C": 18,
"D": 12,
"E": 12,
"F": 20,
"G": 12,
"H": 12,
"I": 20,
"J": 20,
"K": 18,
"L": 12,
"M": 16,
"N": 20,
}
func setBalanceMonitoringColumns(file *excelize.File, sheet string) error {
for col, width := range bmColumnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
if err := file.SetRowHeight(sheet, 1, 24); err != nil {
return err
}
return file.SetRowHeight(sheet, 2, 24)
}
func setBalanceMonitoringHeaders(file *excelize.File, sheet string) error {
borderStyle := []excelize.Border{
{Type: "left", Color: "000000", Style: 1},
{Type: "top", Color: "000000", Style: 1},
{Type: "bottom", Color: "000000", Style: 1},
{Type: "right", Color: "000000", Style: 1},
}
headerStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "FFFFFF", Family: "Arial", Size: 10},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"4472C4"}},
Alignment: &excelize.Alignment{
Horizontal: "center",
Vertical: "center",
WrapText: true,
},
Border: borderStyle,
})
if err != nil {
return err
}
// Single-column headers: merge rows 1 and 2 vertically
singleColHeaders := map[string]string{
"A": "No",
"B": "Customer",
"C": "Saldo Awal",
"J": "Penjualan Trading",
"K": "Pembayaran",
"L": "Aging",
"M": "Aging Rata-Rata",
"N": "Saldo Akhir",
}
for col, header := range singleColHeaders {
if err := file.SetCellValue(sheet, col+"1", header); err != nil {
return err
}
if err := file.MergeCell(sheet, col+"1", col+"2"); err != nil {
return err
}
}
// Group headers: merge columns horizontally in row 1
if err := file.SetCellValue(sheet, "D1", "Penjualan Ayam"); err != nil {
return err
}
if err := file.MergeCell(sheet, "D1", "F1"); err != nil {
return err
}
if err := file.SetCellValue(sheet, "G1", "Penjualan Telur"); err != nil {
return err
}
if err := file.MergeCell(sheet, "G1", "I1"); err != nil {
return err
}
// Sub-column headers in row 2
subHeaders := map[string]string{
"D": "Ekor",
"E": "Kg",
"F": "Nominal",
"G": "Butir",
"H": "Kg",
"I": "Nominal",
}
for col, header := range subHeaders {
if err := file.SetCellValue(sheet, col+"2", header); err != nil {
return err
}
}
return file.SetCellStyle(sheet, "A1", "N2", headerStyle)
}
func writeBalanceMonitoringRows(file *excelize.File, sheet string, items []dto.BalanceMonitoringRowDTO, totals dto.BalanceMonitoringTotalsDTO) error {
borderStyle := []excelize.Border{
{Type: "left", Color: "000000", Style: 1},
{Type: "top", Color: "000000", Style: 1},
{Type: "bottom", Color: "000000", Style: 1},
{Type: "right", Color: "000000", Style: 1},
}
dataStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Color: "000000", Family: "Arial", Size: 10},
Alignment: &excelize.Alignment{Vertical: "center", WrapText: true},
Border: borderStyle,
})
if err != nil {
return err
}
totalStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "000000", Family: "Arial", Size: 10},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"E2EFDA"}},
Alignment: &excelize.Alignment{Vertical: "center", WrapText: true},
Border: borderStyle,
})
if err != nil {
return err
}
redDataStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Color: "FF0000", Family: "Arial", Size: 10},
Alignment: &excelize.Alignment{Vertical: "center", WrapText: true},
Border: borderStyle,
})
if err != nil {
return err
}
redTotalStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "FF0000", Family: "Arial", Size: 10},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"E2EFDA"}},
Alignment: &excelize.Alignment{Vertical: "center", WrapText: true},
Border: borderStyle,
})
if err != nil {
return err
}
for i, row := range items {
rowNum := i + 3
rowStr := strconv.Itoa(rowNum)
cells := map[string]interface{}{
"A": i + 1,
"B": row.Customer.Name,
"C": row.SaldoAwal,
"D": row.PenjualanAyam.Ekor,
"E": row.PenjualanAyam.Kg,
"F": row.PenjualanAyam.Nominal,
"G": row.PenjualanTelur.Butir,
"H": row.PenjualanTelur.Kg,
"I": row.PenjualanTelur.Nominal,
"J": row.PenjualanTrading.Nominal,
"K": row.Pembayaran,
"L": fmt.Sprintf("%d hari", row.Aging),
"M": formatBMAging(row.AgingRataRata),
"N": row.SaldoAkhir,
}
for col, val := range cells {
if err := file.SetCellValue(sheet, col+rowStr, val); err != nil {
return err
}
}
if err := file.SetCellStyle(sheet, "A"+rowStr, "N"+rowStr, dataStyle); err != nil {
return err
}
if row.SaldoAkhir < 0 {
if err := file.SetCellStyle(sheet, "N"+rowStr, "N"+rowStr, redDataStyle); err != nil {
return err
}
}
}
// Totals row
totalRowStr := strconv.Itoa(len(items) + 3)
totalCells := map[string]interface{}{
"A": "Total",
"C": totals.SaldoAwal,
"D": totals.PenjualanAyam.Ekor,
"E": totals.PenjualanAyam.Kg,
"F": totals.PenjualanAyam.Nominal,
"G": totals.PenjualanTelur.Butir,
"H": totals.PenjualanTelur.Kg,
"I": totals.PenjualanTelur.Nominal,
"J": totals.PenjualanTrading.Nominal,
"K": totals.Pembayaran,
"N": totals.SaldoAkhir,
}
for col, val := range totalCells {
if err := file.SetCellValue(sheet, col+totalRowStr, val); err != nil {
return err
}
}
if err := file.SetCellStyle(sheet, "A"+totalRowStr, "N"+totalRowStr, totalStyle); err != nil {
return err
}
if totals.SaldoAkhir < 0 {
if err := file.SetCellStyle(sheet, "N"+totalRowStr, "N"+totalRowStr, redTotalStyle); err != nil {
return err
}
}
return nil
}
func formatBMAging(v float64) string {
s := strconv.FormatFloat(v, 'f', 2, 64)
s = strings.ReplaceAll(s, ".", ",")
return s + " hari"
}
@@ -324,6 +324,13 @@ func (c *RepportController) GetPurchaseSupplier(ctx *fiber.Ctx) error {
return err
}
if isPurchaseSupplierExcelExportRequest(ctx) {
return exportPurchaseSupplierExcel(ctx, result)
}
if isPurchaseSupplierExcelAllExportRequest(ctx) {
return exportPurchaseSupplierExcelAll(ctx, result)
}
filters := map[string]interface{}{
"area_id": query.AreaIDs,
"supplier_id": query.SupplierIDs,
@@ -555,6 +562,10 @@ func (c *RepportController) GetBalanceMonitoring(ctx *fiber.Ctx) error {
return err
}
if isBalanceMonitoringExcelExportRequest(ctx) {
return exportBalanceMonitoringExcel(ctx, result, totals)
}
limit := query.Limit
if limit < 1 {
limit = 10
@@ -0,0 +1,415 @@
package controller
import (
"fmt"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
"gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto"
)
func isPurchaseSupplierExcelExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel")
}
func isPurchaseSupplierExcelAllExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel-all")
}
func exportPurchaseSupplierExcel(c *fiber.Ctx, items []dto.PurchaseSupplierDTO) error {
content, err := buildPurchaseSupplierWorkbook(items)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("laporan-pembelian-supplier-%s.xlsx", time.Now().Format("2006-01-02-1504"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
return c.Status(fiber.StatusOK).Send(content)
}
func exportPurchaseSupplierExcelAll(c *fiber.Ctx, items []dto.PurchaseSupplierDTO) error {
content, err := buildPurchaseSupplierAllWorkbook(items)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("laporan-pembelian-supplier-all-%s.xlsx", time.Now().Format("2006-01-02-1504"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
return c.Status(fiber.StatusOK).Send(content)
}
// buildPurchaseSupplierWorkbook creates a workbook with one sheet per supplier.
func buildPurchaseSupplierWorkbook(items []dto.PurchaseSupplierDTO) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if len(items) == 0 {
if err := writePurchaseSupplierSheet(file, defaultSheet, dto.PurchaseSupplierDTO{}); err != nil {
return nil, err
}
buf, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
for idx, item := range items {
sheetName := sanitizePurchaseSupplierSheetName(purchaseSupplierName(item))
if sheetName == "" {
sheetName = fmt.Sprintf("Supplier %d", idx+1)
}
if idx == 0 {
if defaultSheet != sheetName {
if err := file.SetSheetName(defaultSheet, sheetName); err != nil {
return nil, err
}
}
} else {
if _, err := file.NewSheet(sheetName); err != nil {
return nil, err
}
}
if err := writePurchaseSupplierSheet(file, sheetName, item); err != nil {
return nil, err
}
}
buf, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// buildPurchaseSupplierAllWorkbook creates a single-sheet workbook with all suppliers.
func buildPurchaseSupplierAllWorkbook(items []dto.PurchaseSupplierDTO) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
const sheet = "Rekap Pembelian Supplier"
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if defaultSheet != sheet {
if err := file.SetSheetName(defaultSheet, sheet); err != nil {
return nil, err
}
}
if err := setPurchaseSupplierAllColumns(file, sheet); err != nil {
return nil, err
}
if err := setPurchaseSupplierAllHeaders(file, sheet); err != nil {
return nil, err
}
if err := writePurchaseSupplierAllRows(file, sheet, items); err != nil {
return nil, err
}
if err := file.SetPanes(sheet, &excelize.Panes{
Freeze: true,
YSplit: 1,
TopLeftCell: "A2",
ActivePane: "bottomLeft",
}); err != nil {
return nil, err
}
buf, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
var purchaseSupplierSheetHeaders = []string{
"No",
"Tanggal Terima",
"Tanggal PO",
"No. Referensi",
"Nama Produk",
"Tujuan",
"QTY",
"Harga Beli (Rp)",
"Value Harga Beli (Rp)",
"Transport (Rp)",
"Value Transport (Rp)",
"Jumlah (Rp)",
"Ekspedisi",
"Surat Jalan",
}
var purchaseSupplierAllSheetHeaders = append([]string{"Supplier"}, purchaseSupplierSheetHeaders...)
var purchaseSupplierSheetColumnWidths = map[string]float64{
"A": 5,
"B": 14,
"C": 12,
"D": 16,
"E": 20,
"F": 20,
"G": 10,
"H": 20,
"I": 20,
"J": 22,
"K": 22,
"L": 16,
"M": 20,
"N": 20,
}
var purchaseSupplierAllSheetColumnWidths = map[string]float64{
"A": 24,
"B": 6,
"C": 14,
"D": 12,
"E": 16,
"F": 20,
"G": 20,
"H": 10,
"I": 20,
"J": 20,
"K": 22,
"L": 22,
"M": 16,
"N": 20,
"O": 20,
}
func writePurchaseSupplierSheet(file *excelize.File, sheet string, item dto.PurchaseSupplierDTO) error {
for col, width := range purchaseSupplierSheetColumnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
for i, h := range purchaseSupplierSheetHeaders {
col, _ := excelize.ColumnNumberToName(i + 1)
if err := file.SetCellValue(sheet, col+"1", h); err != nil {
return err
}
}
for i, row := range item.Rows {
rowNum := i + 2
rowStr := fmt.Sprintf("%d", rowNum)
values := purchaseSupplierRowCells(row, i+1)
for colIdx, val := range values {
col, _ := excelize.ColumnNumberToName(colIdx + 1)
if err := file.SetCellValue(sheet, col+rowStr, val); err != nil {
return err
}
}
}
// Summary row
totalRowNum := len(item.Rows) + 2
totalRowStr := fmt.Sprintf("%d", totalRowNum)
totalCells := map[string]interface{}{
"A": "Total",
"G": item.Summary.TotalQty,
"H": item.Summary.TotalUnitPrice,
"I": item.Summary.TotalPurchaseValue,
"J": item.Summary.TotalTransportUnitPrice,
"K": item.Summary.TotalTransportValue,
"L": item.Summary.TotalAmount,
}
for col, val := range totalCells {
if err := file.SetCellValue(sheet, col+totalRowStr, val); err != nil {
return err
}
}
return nil
}
func setPurchaseSupplierAllColumns(file *excelize.File, sheet string) error {
for col, width := range purchaseSupplierAllSheetColumnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
if err := file.SetRowHeight(sheet, 1, 24); err != nil {
return err
}
return nil
}
func setPurchaseSupplierAllHeaders(file *excelize.File, sheet string) error {
headerStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "FFFFFF", Family: "Arial", Size: 10},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"4472C4"}},
Alignment: &excelize.Alignment{
Horizontal: "center",
Vertical: "center",
WrapText: true,
},
Border: []excelize.Border{
{Type: "left", Color: "000000", Style: 1},
{Type: "top", Color: "000000", Style: 1},
{Type: "bottom", Color: "000000", Style: 1},
{Type: "right", Color: "000000", Style: 1},
},
})
if err != nil {
return err
}
for i, h := range purchaseSupplierAllSheetHeaders {
col, _ := excelize.ColumnNumberToName(i + 1)
if err := file.SetCellValue(sheet, col+"1", h); err != nil {
return err
}
}
lastCol, _ := excelize.ColumnNumberToName(len(purchaseSupplierAllSheetHeaders))
return file.SetCellStyle(sheet, "A1", lastCol+"1", headerStyle)
}
func writePurchaseSupplierAllRows(file *excelize.File, sheet string, items []dto.PurchaseSupplierDTO) error {
borderStyle := []excelize.Border{
{Type: "left", Color: "000000", Style: 1},
{Type: "top", Color: "000000", Style: 1},
{Type: "bottom", Color: "000000", Style: 1},
{Type: "right", Color: "000000", Style: 1},
}
dataStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Color: "000000", Family: "Arial", Size: 10},
Alignment: &excelize.Alignment{Vertical: "center", WrapText: true},
Border: borderStyle,
})
if err != nil {
return err
}
totalStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "000000", Family: "Arial", Size: 10},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"E2EFDA"}},
Alignment: &excelize.Alignment{Vertical: "center", WrapText: true},
Border: borderStyle,
})
if err != nil {
return err
}
lastHeaderCol, _ := excelize.ColumnNumberToName(len(purchaseSupplierAllSheetHeaders))
currentRow := 2
for _, item := range items {
supplierName := purchaseSupplierName(item)
// Data rows
for seq, row := range item.Rows {
rowStr := fmt.Sprintf("%d", currentRow)
if err := file.SetCellValue(sheet, "A"+rowStr, supplierName); err != nil {
return err
}
values := purchaseSupplierRowCells(row, seq+1)
for colIdx, val := range values {
col, _ := excelize.ColumnNumberToName(colIdx + 2)
if err := file.SetCellValue(sheet, col+rowStr, val); err != nil {
return err
}
}
if err := file.SetCellStyle(sheet, "A"+rowStr, lastHeaderCol+rowStr, dataStyle); err != nil {
return err
}
currentRow++
}
// Summary row
totalRowStr := fmt.Sprintf("%d", currentRow)
totalCells := map[string]interface{}{
"A": supplierName,
"B": "Total",
"H": item.Summary.TotalQty,
"I": item.Summary.TotalUnitPrice,
"J": item.Summary.TotalPurchaseValue,
"K": item.Summary.TotalTransportUnitPrice,
"L": item.Summary.TotalTransportValue,
"M": item.Summary.TotalAmount,
}
for col, val := range totalCells {
if err := file.SetCellValue(sheet, col+totalRowStr, val); err != nil {
return err
}
}
if err := file.SetCellStyle(sheet, "A"+totalRowStr, lastHeaderCol+totalRowStr, totalStyle); err != nil {
return err
}
currentRow++
// Empty separator row
currentRow++
}
return nil
}
// purchaseSupplierRowCells returns cell values for one data row.
func purchaseSupplierRowCells(row dto.PurchaseSupplierRowDTO, seq int) []interface{} {
productName := "-"
if row.Product != nil && strings.TrimSpace(row.Product.Name) != "" {
productName = row.Product.Name
}
warehouseName := "-"
if row.Warehouse != nil && strings.TrimSpace(row.Warehouse.Name) != "" {
warehouseName = row.Warehouse.Name
}
return []interface{}{
seq,
safePurchaseSupplierText(row.ReceiveDate),
safePurchaseSupplierText(row.PoDate),
safePurchaseSupplierText(row.PoNumber),
productName,
warehouseName,
row.Qty,
row.UnitPrice,
row.PurchaseValue,
row.TransportUnitPrice,
row.TransportValue,
row.TotalAmount,
safePurchaseSupplierText(row.Expedition),
safePurchaseSupplierText(row.DeliveryNumber),
}
}
func purchaseSupplierName(item dto.PurchaseSupplierDTO) string {
if item.Supplier != nil && strings.TrimSpace(item.Supplier.Name) != "" {
return item.Supplier.Name
}
return "Supplier"
}
func sanitizePurchaseSupplierSheetName(name string) string {
replacer := strings.NewReplacer(
":", " ", "\\", " ", "/", " ",
"?", " ", "*", " ", "[", " ", "]", " ",
)
sanitized := strings.TrimSpace(replacer.Replace(name))
if sanitized == "" {
return "Sheet"
}
runes := []rune(sanitized)
if len(runes) > 31 {
return string(runes[:31])
}
return sanitized
}
func safePurchaseSupplierText(s string) string {
t := strings.TrimSpace(s)
if t == "" {
return "-"
}
return t
}
@@ -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 {
@@ -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
@@ -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,
+12 -6
View File
@@ -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