Merge branch 'feat/trf-dep' into 'rc/01'

Feat/trf dep

See merge request mbugroup/lti-api!575
This commit is contained in:
Giovanni Gabriel Septriadi
2026-05-29 15:45:33 +00:00
20 changed files with 979 additions and 234 deletions
@@ -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
@@ -16,13 +16,19 @@ 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"`
StandardEffectiveDate string `json:"standard_effective_date,omitempty"`
TotalPopulation float64 `json:"total_population"`
Components any `json:"components"`
}
type ExpenseDepreciationManualInputRowDTO struct {
@@ -37,10 +37,11 @@ type FarmDepreciationManualInputRow struct {
Note *string
}
type houseDepreciationPercentRow struct {
HouseType string
Day int
DepreciationPercent float64
type houseMultiplicationPercentageRow struct {
HouseType string
Day int
MultiplicationPercentage float64
EffectiveDate *time.Time
}
type ExpenseDepreciationRepository interface {
@@ -48,8 +49,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, map[string]*time.Time, error)
GetLatestManualInputsByFarms(ctx context.Context, areaIDs, locationIDs, projectFlockIDs []int64) ([]FarmDepreciationManualInputRow, error)
UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error
DB() *gorm.DB
@@ -159,6 +161,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,35 +241,39 @@ 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,
) (map[string]map[int]float64, error) {
) (map[string]map[int]float64, map[string]*time.Time, error) {
result := make(map[string]map[int]float64)
effectiveDates := make(map[string]*time.Time)
if len(houseTypes) == 0 || maxDay <= 0 {
return result, nil
return result, effectiveDates, 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 {
return nil, err
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, effective_date
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, nil, err
}
for _, row := range rows {
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
if _, tracked := effectiveDates[row.HouseType]; !tracked {
effectiveDates[row.HouseType] = row.EffectiveDate
}
}
return result, nil
return result, effectiveDates, nil
}
func (r *expenseDepreciationRepository) GetLatestManualInputsByFarms(
@@ -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,34 @@ 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, standardEffectiveDate := depreciationSnapshotInfo(components)
totalPopulation := depreciationTotalPopulation(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,
StandardEffectiveDate: standardEffectiveDate,
TotalPopulation: totalPopulation,
Components: components,
})
}
@@ -472,28 +485,34 @@ 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"`
StandardEffectiveDate string `json:"standard_effective_date,omitempty"`
Population float64 `json:"population"`
}
type depreciationFarmComponents struct {
KandangCount int `json:"kandang_count"`
Kandang []depreciationKandangComponent `json:"kandang"`
KandangCount int `json:"kandang_count"`
TotalPopulation float64 `json:"total_population"`
Kandang []depreciationKandangComponent `json:"kandang"`
}
func (s *repportService) computeExpenseDepreciationSnapshots(
@@ -527,6 +546,7 @@ func (s *repportService) computeExpenseDepreciationSnapshots(
totalDepreciationValue := 0.0
totalPulletCostDayN := 0.0
totalPopulation := 0.0
for _, kandangID := range kandangIDs {
breakdown, err := s.HppV2Svc.CalculateHppBreakdown(kandangID, &periodDate)
if err != nil {
@@ -548,17 +568,22 @@ 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"),
StandardEffectiveDate: hppV2DetailString(part.Details, "standard_effective_date"),
Population: hppV2DetailFloat(part.Details, "kandang_population"),
}
if component.HouseType == "" {
@@ -589,11 +614,13 @@ func (s *repportService) computeExpenseDepreciationSnapshots(
totalPulletCostDayN += component.PulletCostDayN
totalDepreciationValue += component.DepreciationValue
totalPopulation += component.Population
components.Kandang = append(components.Kandang, component)
}
}
components.KandangCount = len(components.Kandang)
components.TotalPopulation = totalPopulation
effectivePercent := approvalService.CalculateEffectiveDepreciationPercent(totalDepreciationValue, totalPulletCostDayN)
componentsJSON, marshalErr := json.Marshal(components)
@@ -700,8 +727,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 +755,77 @@ func parseSnapshotComponents(raw []byte) any {
return out
}
func depreciationSnapshotInfo(components any) (float64, int, string, 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 != "" {
standardEffectiveDate := anyString(component["standard_effective_date"])
return multiplicationPercentage, dayN, chickinDate, standardEffectiveDate
}
}
return 0, 0, "", ""
}
func depreciationTotalPopulation(components any) float64 {
root, ok := components.(map[string]any)
if !ok {
return 0
}
return anyFloat(root["total_population"])
}
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 ""
@@ -1971,7 +2072,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]
@@ -1979,24 +2081,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() {
@@ -2022,6 +2122,12 @@ func buildDebtSupplierRow(purchase entity.Purchase, now time.Time, loc *time.Loc
poDate = purchase.PoDate.In(loc).Format("2006-01-02")
}
var firstWarehouse *warehouseDTO.WarehouseRelationDTO
if len(warehouses) > 0 {
w := warehouses[0]
firstWarehouse = &w
}
return dto.DebtSupplierRowDTO{
PrNumber: prNumber,
PoNumber: poNumber,
@@ -2029,7 +2135,7 @@ func buildDebtSupplierRow(purchase entity.Purchase, now time.Time, loc *time.Loc
ReceivedDate: receivedDate,
Aging: aging,
Area: area,
Warehouse: warehouse,
Warehouse: firstWarehouse,
DueDate: dueDate,
DueStatus: dueStatus,
TotalPrice: totalPrice,