From 7f8013c5ed6cfd6e3309b5825308a36ef0b70eef Mon Sep 17 00:00:00 2001 From: Adnan Zahir Date: Thu, 5 Mar 2026 12:53:00 +0700 Subject: [PATCH] fix: reimplement transfer to laying logics separating effective financial date and physical transfer date --- ...c_cutoff_date_to_laying_transfers.down.sql | 8 + ...mic_cutoff_date_to_laying_transfers.up.sql | 13 ++ internal/entities/laying_transfer.go | 1 + .../recordings/services/recording.service.go | 164 +++++++++++++++--- .../services/recording_route_helper_test.go | 87 ++++++++++ .../dto/transfer_laying.dto.go | 26 +-- .../services/transfer_laying.service.go | 36 ++-- 7 files changed, 284 insertions(+), 51 deletions(-) create mode 100644 internal/database/migrations/20260304101500_add_economic_cutoff_date_to_laying_transfers.down.sql create mode 100644 internal/database/migrations/20260304101500_add_economic_cutoff_date_to_laying_transfers.up.sql create mode 100644 internal/modules/production/recordings/services/recording_route_helper_test.go diff --git a/internal/database/migrations/20260304101500_add_economic_cutoff_date_to_laying_transfers.down.sql b/internal/database/migrations/20260304101500_add_economic_cutoff_date_to_laying_transfers.down.sql new file mode 100644 index 00000000..528e18f6 --- /dev/null +++ b/internal/database/migrations/20260304101500_add_economic_cutoff_date_to_laying_transfers.down.sql @@ -0,0 +1,8 @@ +BEGIN; + +DROP INDEX IF EXISTS idx_laying_transfers_economic_cutoff_date; + +ALTER TABLE laying_transfers + DROP COLUMN IF EXISTS economic_cutoff_date; + +COMMIT; diff --git a/internal/database/migrations/20260304101500_add_economic_cutoff_date_to_laying_transfers.up.sql b/internal/database/migrations/20260304101500_add_economic_cutoff_date_to_laying_transfers.up.sql new file mode 100644 index 00000000..d6793f2c --- /dev/null +++ b/internal/database/migrations/20260304101500_add_economic_cutoff_date_to_laying_transfers.up.sql @@ -0,0 +1,13 @@ +BEGIN; + +ALTER TABLE laying_transfers + ADD COLUMN IF NOT EXISTS economic_cutoff_date DATE; + +CREATE INDEX IF NOT EXISTS idx_laying_transfers_economic_cutoff_date + ON laying_transfers(economic_cutoff_date); + +UPDATE laying_transfers +SET economic_cutoff_date = COALESCE(economic_cutoff_date, effective_move_date, transfer_date) +WHERE economic_cutoff_date IS NULL; + +COMMIT; diff --git a/internal/entities/laying_transfer.go b/internal/entities/laying_transfer.go index db5ca775..da21ddc9 100644 --- a/internal/entities/laying_transfer.go +++ b/internal/entities/laying_transfer.go @@ -12,6 +12,7 @@ type LayingTransfer struct { FromProjectFlockId uint `gorm:"not null"` ToProjectFlockId uint `gorm:"not null"` TransferDate time.Time `gorm:"type:date;not null"` + EconomicCutoffDate *time.Time `gorm:"type:date"` EffectiveMoveDate *time.Time `gorm:"type:date"` ExecutedAt *time.Time `gorm:"type:timestamptz"` ExecutedBy *uint `gorm:"index"` diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 4ed3c0a5..deb9a29e 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -293,7 +293,8 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent category := strings.ToUpper(pfk.ProjectFlock.Category) isLaying := category == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) - if err := s.enforceTransferRecordingRoute(ctx, pfk, recordTime); err != nil { + routePayload := buildRecordingRoutePayloadFromCreate(req) + if err := s.enforceTransferRecordingRoute(ctx, pfk, recordTime, routePayload); err != nil { return nil, err } @@ -494,6 +495,23 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } recordingEntity = recording + pfkForRoute := recordingEntity.ProjectFlockKandang + if pfkForRoute == nil || pfkForRoute.Id == 0 { + fetchedPfk, fetchErr := s.ProjectFlockKandangRepo.GetByIDLight(ctx, recordingEntity.ProjectFlockKandangId) + if fetchErr != nil { + if errors.Is(fetchErr, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang not found") + } + s.Log.Errorf("Failed to fetch project flock kandang for route validation: %+v", fetchErr) + return fetchErr + } + pfkForRoute = fetchedPfk + } + routePayload := buildRecordingRoutePayloadFromUpdate(req) + if err := s.enforceTransferRecordingRoute(ctx, pfkForRoute, recordingEntity.RecordDatetime, routePayload); err != nil { + return err + } + hasStockChanges := req.Stocks != nil hasDepletionChanges := req.Depletions != nil hasEggChanges := req.Eggs != nil @@ -909,6 +927,7 @@ func (s *recordingService) enforceTransferRecordingRoute( ctx context.Context, pfk *entity.ProjectFlockKandang, recordTime time.Time, + payload recordingRoutePayload, ) error { if pfk == nil || pfk.Id == 0 || s.TransferLayingRepo == nil { return nil @@ -928,22 +947,35 @@ func (s *recordingService) enforceTransferRecordingRoute( return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying") } - effectiveDate := effectiveTransferDate(transfer) - if effectiveDate.IsZero() { + physicalMoveDate, economicCutoffDate := transferRecordingWindow(transfer) + if physicalMoveDate.IsZero() { return nil } - if recordDate.Before(effectiveDate) { + if recordDate.Before(physicalMoveDate) { return fiber.NewError( fiber.StatusBadRequest, - fmt.Sprintf("Recording kandang laying hanya bisa dimulai pada %s. Sebelumnya gunakan kandang growing", effectiveDate.Format("2006-01-02")), + fmt.Sprintf("Recording kandang laying hanya bisa dimulai pada %s (tanggal pindah fisik). Sebelumnya gunakan kandang growing", physicalMoveDate.Format("2006-01-02")), ) } if transfer.ExecutedAt == nil || transfer.ExecutedAt.IsZero() { return fiber.NewError( fiber.StatusBadRequest, - fmt.Sprintf("Transfer laying %s sudah efektif pada %s tetapi belum dieksekusi. Eksekusi transfer terlebih dahulu", transfer.TransferNumber, effectiveDate.Format("2006-01-02")), + fmt.Sprintf("Transfer laying %s dengan tanggal pindah fisik %s belum dieksekusi. Eksekusi transfer terlebih dahulu", transfer.TransferNumber, physicalMoveDate.Format("2006-01-02")), + ) + } + + if recordDate.Before(economicCutoffDate) && payload.StockCount > 0 { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf( + "Periode transisi transfer laying %s (%s s.d. %s): input PAKAN/OVK harus dicatat di kandang growing hingga %s. Recording kandang laying pada periode ini hanya untuk deplesi (dan telur bila ada).", + transfer.TransferNumber, + physicalMoveDate.Format("2006-01-02"), + economicCutoffDate.AddDate(0, 0, -1).Format("2006-01-02"), + economicCutoffDate.AddDate(0, 0, -1).Format("2006-01-02"), + ), ) } @@ -957,22 +989,38 @@ func (s *recordingService) enforceTransferRecordingRoute( return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying") } - if transfer != nil && transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero() { - return fiber.NewError( - fiber.StatusBadRequest, - "Project flock kandang sudah dipindahkan ke laying", - ) - } - - effectiveDate := effectiveTransferDate(transfer) - if effectiveDate.IsZero() { + physicalMoveDate, economicCutoffDate := transferRecordingWindow(transfer) + if physicalMoveDate.IsZero() { return nil } - if !recordDate.Before(effectiveDate) { + if recordDate.Before(physicalMoveDate) { + return nil + } + + if transfer.ExecutedAt == nil || transfer.ExecutedAt.IsZero() { return fiber.NewError( fiber.StatusBadRequest, - fmt.Sprintf("Recording kandang growing hanya diperbolehkan sampai %s. Gunakan kandang laying mulai %s", effectiveDate.AddDate(0, 0, -1).Format("2006-01-02"), effectiveDate.Format("2006-01-02")), + fmt.Sprintf("Transfer laying %s sudah memasuki tanggal pindah fisik %s namun belum dieksekusi. Eksekusi transfer lalu lakukan recording transisi sesuai aturan", transfer.TransferNumber, physicalMoveDate.Format("2006-01-02")), + ) + } + + if !recordDate.Before(economicCutoffDate) { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Recording kandang growing hanya diperbolehkan sampai %s. Gunakan kandang laying mulai %s", economicCutoffDate.AddDate(0, 0, -1).Format("2006-01-02"), economicCutoffDate.Format("2006-01-02")), + ) + } + + if payload.DepletionCount > 0 { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf( + "Periode transisi transfer laying %s (%s s.d. %s): deplesi harus dicatat di kandang laying tujuan agar mapping tidak ambigu. Kandang growing pada periode ini hanya untuk PAKAN/OVK.", + transfer.TransferNumber, + physicalMoveDate.Format("2006-01-02"), + economicCutoffDate.AddDate(0, 0, -1).Format("2006-01-02"), + ), ) } } @@ -980,19 +1028,93 @@ func (s *recordingService) enforceTransferRecordingRoute( return nil } -func effectiveTransferDate(transfer *entity.LayingTransfer) time.Time { +type recordingRoutePayload struct { + StockCount int + DepletionCount int + EggCount int +} + +func buildRecordingRoutePayloadFromCreate(req *validation.Create) recordingRoutePayload { + payload := recordingRoutePayload{} + if req == nil { + return payload + } + for _, stock := range req.Stocks { + if stock.Qty > 0 { + payload.StockCount++ + } + } + for _, depletion := range req.Depletions { + if depletion.Qty > 0 { + payload.DepletionCount++ + } + } + for _, egg := range req.Eggs { + if egg.Qty > 0 { + payload.EggCount++ + } + } + return payload +} + +func buildRecordingRoutePayloadFromUpdate(req *validation.Update) recordingRoutePayload { + payload := recordingRoutePayload{} + if req == nil { + return payload + } + for _, stock := range req.Stocks { + if stock.Qty > 0 { + payload.StockCount++ + } + } + for _, depletion := range req.Depletions { + if depletion.Qty > 0 { + payload.DepletionCount++ + } + } + for _, egg := range req.Eggs { + if egg.Qty > 0 { + payload.EggCount++ + } + } + return payload +} + +func transferPhysicalMoveDate(transfer *entity.LayingTransfer) time.Time { if transfer == nil { return time.Time{} } - if transfer.EffectiveMoveDate != nil && !transfer.EffectiveMoveDate.IsZero() { - return normalizeDateOnlyUTC(*transfer.EffectiveMoveDate) - } if !transfer.TransferDate.IsZero() { return normalizeDateOnlyUTC(transfer.TransferDate) } return time.Time{} } +func transferEconomicCutoffDate(transfer *entity.LayingTransfer) time.Time { + if transfer == nil { + return time.Time{} + } + if transfer.EconomicCutoffDate != nil && !transfer.EconomicCutoffDate.IsZero() { + return normalizeDateOnlyUTC(*transfer.EconomicCutoffDate) + } + if transfer.EffectiveMoveDate != nil && !transfer.EffectiveMoveDate.IsZero() { + return normalizeDateOnlyUTC(*transfer.EffectiveMoveDate) + } + return transferPhysicalMoveDate(transfer) +} + +func transferRecordingWindow(transfer *entity.LayingTransfer) (time.Time, time.Time) { + physicalMoveDate := transferPhysicalMoveDate(transfer) + economicCutoffDate := transferEconomicCutoffDate(transfer) + if economicCutoffDate.IsZero() { + economicCutoffDate = physicalMoveDate + } + if !physicalMoveDate.IsZero() && economicCutoffDate.Before(physicalMoveDate) { + economicCutoffDate = physicalMoveDate + } + return physicalMoveDate, economicCutoffDate +} + func normalizeDateOnlyUTC(value time.Time) time.Time { return time.Date(value.UTC().Year(), value.UTC().Month(), value.UTC().Day(), 0, 0, 0, 0, time.UTC) } diff --git a/internal/modules/production/recordings/services/recording_route_helper_test.go b/internal/modules/production/recordings/services/recording_route_helper_test.go new file mode 100644 index 00000000..155bd93e --- /dev/null +++ b/internal/modules/production/recordings/services/recording_route_helper_test.go @@ -0,0 +1,87 @@ +package service + +import ( + "testing" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +func mustDate(t *testing.T, value string) time.Time { + t.Helper() + parsed, err := time.Parse("2006-01-02", value) + if err != nil { + t.Fatalf("failed parsing date %s: %v", value, err) + } + return parsed +} + +func TestTransferRecordingWindow(t *testing.T) { + t.Run("early transfer keeps transition until economic cutoff", func(t *testing.T) { + physical := mustDate(t, "2026-04-08") + cutoff := mustDate(t, "2026-05-13") + transfer := &entity.LayingTransfer{ + TransferDate: physical, + EconomicCutoffDate: &cutoff, + } + + gotPhysical, gotCutoff := transferRecordingWindow(transfer) + if gotPhysical.Format("2006-01-02") != "2026-04-08" { + t.Fatalf("unexpected physical date: %s", gotPhysical.Format("2006-01-02")) + } + if gotCutoff.Format("2006-01-02") != "2026-05-13" { + t.Fatalf("unexpected cutoff date: %s", gotCutoff.Format("2006-01-02")) + } + }) + + t.Run("standard transfer has no transition window", func(t *testing.T) { + physical := mustDate(t, "2026-05-13") + cutoff := mustDate(t, "2026-05-13") + transfer := &entity.LayingTransfer{ + TransferDate: physical, + EconomicCutoffDate: &cutoff, + } + + gotPhysical, gotCutoff := transferRecordingWindow(transfer) + if gotPhysical.Format("2006-01-02") != "2026-05-13" { + t.Fatalf("unexpected physical date: %s", gotPhysical.Format("2006-01-02")) + } + if gotCutoff.Format("2006-01-02") != "2026-05-13" { + t.Fatalf("unexpected cutoff date: %s", gotCutoff.Format("2006-01-02")) + } + }) + + t.Run("late transfer clamps economic cutoff to physical move", func(t *testing.T) { + physical := mustDate(t, "2026-06-03") + cutoff := mustDate(t, "2026-05-13") + transfer := &entity.LayingTransfer{ + TransferDate: physical, + EconomicCutoffDate: &cutoff, + } + + gotPhysical, gotCutoff := transferRecordingWindow(transfer) + if gotPhysical.Format("2006-01-02") != "2026-06-03" { + t.Fatalf("unexpected physical date: %s", gotPhysical.Format("2006-01-02")) + } + if gotCutoff.Format("2006-01-02") != "2026-06-03" { + t.Fatalf("unexpected cutoff date: %s", gotCutoff.Format("2006-01-02")) + } + }) + + t.Run("legacy data falls back to effective move date", func(t *testing.T) { + physical := mustDate(t, "2026-04-08") + legacyEffective := mustDate(t, "2026-05-13") + transfer := &entity.LayingTransfer{ + TransferDate: physical, + EffectiveMoveDate: &legacyEffective, + } + + gotPhysical, gotCutoff := transferRecordingWindow(transfer) + if gotPhysical.Format("2006-01-02") != "2026-04-08" { + t.Fatalf("unexpected physical date: %s", gotPhysical.Format("2006-01-02")) + } + if gotCutoff.Format("2006-01-02") != "2026-05-13" { + t.Fatalf("unexpected cutoff date: %s", gotCutoff.Format("2006-01-02")) + } + }) +} diff --git a/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go b/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go index a23cc7df..4250aa49 100644 --- a/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go +++ b/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go @@ -14,12 +14,13 @@ import ( // === DTO Structs === type TransferLayingRelationDTO struct { - Id uint `json:"id"` - TransferNumber string `json:"transfer_number"` - TransferDate time.Time `json:"transfer_date"` - EffectiveMoveDate *time.Time `json:"effective_move_date,omitempty"` - ExecutedAt *time.Time `json:"executed_at,omitempty"` - Notes string `json:"notes"` + Id uint `json:"id"` + TransferNumber string `json:"transfer_number"` + TransferDate time.Time `json:"transfer_date"` + EconomicCutoffDate *time.Time `json:"economic_cutoff_date,omitempty"` + EffectiveMoveDate *time.Time `json:"effective_move_date,omitempty"` + ExecutedAt *time.Time `json:"executed_at,omitempty"` + Notes string `json:"notes"` } type ProjectFlockKandangWithKandangDTO struct { @@ -92,12 +93,13 @@ type MaxTargetQtyForTransferDTO struct { func ToTransferLayingRelationDTO(e entity.LayingTransfer) TransferLayingRelationDTO { return TransferLayingRelationDTO{ - Id: e.Id, - TransferNumber: e.TransferNumber, - TransferDate: e.TransferDate, - EffectiveMoveDate: e.EffectiveMoveDate, - ExecutedAt: e.ExecutedAt, - Notes: e.Notes, + Id: e.Id, + TransferNumber: e.TransferNumber, + TransferDate: e.TransferDate, + EconomicCutoffDate: e.EconomicCutoffDate, + EffectiveMoveDate: e.EffectiveMoveDate, + ExecutedAt: e.ExecutedAt, + Notes: e.Notes, } } diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index 9a1bf993..0b01ac9d 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -783,15 +783,16 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil sources transfer") } - effectiveMoveDate, err := s.calculateEffectiveMoveDate(c.Context(), sources) + economicCutoffDate, err := s.calculateEconomicCutoffDate(c.Context(), sources) if err != nil { return err } if err := repoTx.PatchOne(c.Context(), approvableID, map[string]any{ - "effective_move_date": effectiveMoveDate, - "executed_at": nil, - "executed_by": nil, + "economic_cutoff_date": economicCutoffDate, + "effective_move_date": economicCutoffDate, // Backward-compatible alias for existing clients. + "executed_at": nil, + "executed_by": nil, }, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal menyimpan tanggal efektif transfer laying") } @@ -866,23 +867,25 @@ func (s transferLayingService) Execute(c *fiber.Ctx, id uint) (*entity.LayingTra return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil target transfer laying") } - if transfer.EffectiveMoveDate == nil || transfer.EffectiveMoveDate.IsZero() { - effectiveMoveDate, calcErr := s.calculateEffectiveMoveDate(c.Context(), sources) + if transfer.EconomicCutoffDate == nil || transfer.EconomicCutoffDate.IsZero() { + economicCutoffDate, calcErr := s.calculateEconomicCutoffDate(c.Context(), sources) if calcErr != nil { return calcErr } if patchErr := repoTx.PatchOne(c.Context(), transfer.Id, map[string]any{ - "effective_move_date": effectiveMoveDate, + "economic_cutoff_date": economicCutoffDate, + "effective_move_date": economicCutoffDate, // Keep legacy field in sync. }, nil); patchErr != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal menyimpan tanggal efektif transfer laying") } - transfer.EffectiveMoveDate = &effectiveMoveDate + transfer.EconomicCutoffDate = &economicCutoffDate + transfer.EffectiveMoveDate = &economicCutoffDate } - effectiveMoveDate := normalizeDateOnlyUTC(*transfer.EffectiveMoveDate) + physicalMoveDate := normalizeDateOnlyUTC(transfer.TransferDate) today := normalizeDateOnlyUTC(time.Now().UTC()) - if today.Before(effectiveMoveDate) { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Transfer laying baru bisa dieksekusi mulai tanggal %s", effectiveMoveDate.Format("2006-01-02"))) + if today.Before(physicalMoveDate) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Transfer laying baru bisa dieksekusi mulai tanggal pindah fisik %s", physicalMoveDate.Format("2006-01-02"))) } if err := s.executeApprovedTransferMovement(c.Context(), dbTransaction, transfer, actorID, sources, targets); err != nil { @@ -978,10 +981,7 @@ func (s *transferLayingService) executeApprovedTransferMovement( return fiber.NewError(fiber.StatusInternalServerError, "Gagal update source usage qty") } - asOf := transfer.TransferDate - if transfer.EffectiveMoveDate != nil && !transfer.EffectiveMoveDate.IsZero() { - asOf = *transfer.EffectiveMoveDate - } + asOf := normalizeDateOnlyUTC(transfer.TransferDate) if _, err := s.FifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{ FlagGroupCode: transferToLayingFlagGroupCode, ProductWarehouseID: *source.ProductWarehouseId, @@ -1147,7 +1147,7 @@ func (s *transferLayingService) allocatePopulationForTransfer( ) } -func (s *transferLayingService) calculateEffectiveMoveDate(ctx context.Context, sources []entity.LayingTransferSource) (time.Time, error) { +func (s *transferLayingService) calculateEconomicCutoffDate(ctx context.Context, sources []entity.LayingTransferSource) (time.Time, error) { if len(sources) == 0 { return time.Time{}, fiber.NewError(fiber.StatusBadRequest, "Sumber transfer laying tidak ditemukan") } @@ -1172,8 +1172,8 @@ func (s *transferLayingService) calculateEffectiveMoveDate(ctx context.Context, return time.Time{}, fiber.NewError(fiber.StatusBadRequest, "Tanggal chick in sumber transfer laying tidak ditemukan") } - effectiveMoveDate := baselineChickInDate.AddDate(0, 0, maxGrowingWeek*7) - return normalizeDateOnlyUTC(effectiveMoveDate), nil + economicCutoffDate := baselineChickInDate.AddDate(0, 0, maxGrowingWeek*7) + return normalizeDateOnlyUTC(economicCutoffDate), nil } func (s *transferLayingService) resolveSourceChickInDate(ctx context.Context, sourceProjectFlockKandangID uint) (time.Time, error) {