From 3a8cc47fa0000cefb04cfd776ac4dec81eb4a70d Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 9 Mar 2026 13:10:06 +0700 Subject: [PATCH] Fix transfer to laying delete and fix chikin delete with response recording --- internal/entities/recording.go | 2 + .../controllers/chickin.controller.go | 32 +- internal/modules/production/chickins/route.go | 2 +- .../chickins/services/chickin.service.go | 766 +++++++++++++++++- .../controllers/projectflock.controller.go | 6 + .../dto/projectflock_kandang.dto.go | 2 + .../production/project_flocks/module.go | 4 +- .../services/projectflock.service.go | 66 ++ .../recordings/dto/recording.dto.go | 4 + .../modules/production/recordings/module.go | 1 + .../recordings/services/recording.service.go | 52 +- .../services/fifo_stock_v2_helper.go | 92 +++ .../services/fifo_stock_v2_helper_test.go | 56 ++ .../services/transfer_laying.service.go | 92 ++- 14 files changed, 1091 insertions(+), 86 deletions(-) create mode 100644 internal/modules/production/transfer_layings/services/fifo_stock_v2_helper_test.go diff --git a/internal/entities/recording.go b/internal/entities/recording.go index fa20907f..19b757a4 100644 --- a/internal/entities/recording.go +++ b/internal/entities/recording.go @@ -45,4 +45,6 @@ type Recording struct { StandardFcr *float64 `gorm:"-"` PopulationCanChange *bool `gorm:"-"` TransferExecuted *bool `gorm:"-"` + IsTransition *bool `gorm:"-"` + IsLaying *bool `gorm:"-"` } diff --git a/internal/modules/production/chickins/controllers/chickin.controller.go b/internal/modules/production/chickins/controllers/chickin.controller.go index 7f8e0d5b..0d9c67e0 100644 --- a/internal/modules/production/chickins/controllers/chickin.controller.go +++ b/internal/modules/production/chickins/controllers/chickin.controller.go @@ -151,25 +151,25 @@ func (u *ChickinController) GetOne(c *fiber.Ctx) error { // }) // } -// func (u *ChickinController) DeleteOne(c *fiber.Ctx) error { -// param := c.Params("id") +func (u *ChickinController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") -// id, err := strconv.Atoi(param) -// if err != nil { -// return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") -// } + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } -// if err := u.ChickinService.DeleteOne(c, uint(id)); err != nil { -// return err -// } + if err := u.ChickinService.DeleteOne(c, uint(id)); err != nil { + return err + } -// return c.Status(fiber.StatusOK). -// JSON(response.Common{ -// Code: fiber.StatusOK, -// Status: "success", -// Message: "Delete chickin successfully", -// }) -// } + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete chickin successfully", + }) +} func (u *ChickinController) Approval(c *fiber.Ctx) error { req := new(validation.Approve) diff --git a/internal/modules/production/chickins/route.go b/internal/modules/production/chickins/route.go index 103a3655..4b49969a 100644 --- a/internal/modules/production/chickins/route.go +++ b/internal/modules/production/chickins/route.go @@ -19,6 +19,6 @@ func ChickinRoutes(v1 fiber.Router, u user.UserService, s chickin.ChickinService route.Post("/",m.RequirePermissions(m.P_ChickinsCreateOne), ctrl.CreateOne) route.Get("/:id",m.RequirePermissions(m.P_ChickinsGetOne), ctrl.GetOne) // route.Patch("/:id", ctrl.UpdateOne) - // route.Delete("/:id", ctrl.DeleteOne) + route.Delete("/:id", ctrl.DeleteOne) route.Post("/approvals",m.RequirePermissions(m.P_ChickinsApproval), ctrl.Approval) } diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index a1bfeb17..ef598be3 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -33,7 +33,10 @@ import ( "gorm.io/gorm/clause" ) -const chickinDeletePopulationGuardMessage = "Chickin tidak dapat dihapus karena sudah memiliki population. Lakukan rollback/penyesuaian population terlebih dahulu" +const ( + chickinDeletePopulationGuardMessage = "Chickin tidak dapat dihapus karena masih memiliki population aktif" + chickinDeleteRecordingGuardMessage = "Chickin tidak dapat dihapus karena masih terkait recording. Lakukan rollback/delete recording terlebih dahulu" +) type ChickinService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error) @@ -133,16 +136,31 @@ func (s chickinService) ensureNotTransferred(ctx context.Context, projectFlockKa return nil } - transfer, err := s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, projectFlockKandangID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil + // Restriction transfer->laying untuk chickin hanya berlaku pada kandang kategori growing. + if s.ProjectflockKandangRepo != nil { + pfk, err := s.ProjectflockKandangRepo.GetByIDLight(ctx, projectFlockKandangID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to resolve project flock kandang %d: %+v", projectFlockKandangID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying") } + if err == nil && pfk != nil { + category := strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category)) + if category != strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)) { + return nil + } + } + } + + checkExecuted := func(transfer *entity.LayingTransfer) bool { + return transfer != nil && transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero() + } + + sourceTransfer, err := s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, projectFlockKandangID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { s.Log.Errorf("Failed to resolve transfer laying by source kandang %d: %+v", projectFlockKandangID, err) return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying") } - - if transfer != nil && transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero() { + if checkExecuted(sourceTransfer) { return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang sudah dipindahkan ke laying") } @@ -175,6 +193,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti newChikins := make([]*entity.ProjectChickin, 0) chickinQtyMap := make(map[uint]float64) + flockCategory := strings.ToUpper(strings.TrimSpace(projectFlockKandang.ProjectFlock.Category)) for idx, chickinReq := range req.ChickinRequests { @@ -194,8 +213,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti } if productWarehouse.Product.Id != 0 { - category := strings.ToUpper(strings.TrimSpace(projectFlockKandang.ProjectFlock.Category)) - if category != string(utils.ProjectFlockCategoryGrowing) && category != string(utils.ProjectFlockCategoryLaying) { + if flockCategory != string(utils.ProjectFlockCategoryGrowing) && flockCategory != string(utils.ProjectFlockCategoryLaying) { return nil, fmt.Errorf("invalid flock category for chickin") } @@ -244,6 +262,19 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti if availableQty < 0 { availableQty = 0 } + if flockCategory == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) { + transferAvailable, err := s.resolveLayingTransferAvailableQty(c.Context(), nil, req.ProjectFlockKandangId, chickinReq.ProductWarehouseId) + if err != nil { + s.Log.Errorf("Failed to resolve laying transfer availability for pfk=%d pw=%d: %+v", req.ProjectFlockKandangId, chickinReq.ProductWarehouseId, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi stok transfer laying") + } + if transferAvailable < 0 { + transferAvailable = 0 + } + if transferAvailable < availableQty { + availableQty = transferAvailable + } + } chickinQtyMap[uint(idx)] = availableQty } @@ -253,6 +284,9 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + if err := s.ensurePopulationRouteScope(c.Context(), dbTransaction); err != nil { + return err + } repositoryTx := repository.NewChickinRepository(dbTransaction) existingChikins, err := repositoryTx.GetByProjectFlockKandangIDForUpdate(c.Context(), req.ProjectFlockKandangId) @@ -425,13 +459,8 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { if err := s.ensureNotTransferred(c.Context(), chickin.ProjectFlockKandangId); err != nil { return err } - hasPopulation, err := s.ProjectflockPopulationRepo.ExistsByProjectChickinID(c.Context(), chickin.Id) - if err != nil { - s.Log.Errorf("Failed to check population by chickin %d: %+v", chickin.Id, err) - return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi population chickin") - } - if hasPopulation { - return fiber.NewError(fiber.StatusBadRequest, chickinDeletePopulationGuardMessage) + if err := s.ensureNoExecutedTransferForDelete(c.Context(), chickin.ProjectFlockKandangId); err != nil { + return err } actorID, err := m.ActorIDFromContext(c) @@ -440,27 +469,51 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + if err := s.ensurePopulationRouteScope(c.Context(), tx); err != nil { + return err + } + chickinRepoTx := repository.NewChickinRepository(tx) - if chickin.UsageQty > 0 || chickin.PendingUsageQty > 0 { - if err := s.ReleaseChickinStocks(c.Context(), tx, chickin, actorID); err != nil { + lockedChickin, err := chickinRepoTx.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + return db.Clauses(clause.Locking{Strength: "UPDATE"}) + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Chickin not found") + } + return err + } + + consumeAllocBefore, traceAllocBefore, err := s.countActiveChickinAllocations(c.Context(), tx, lockedChickin.Id) + if err != nil { + return err + } + s.Log.Infof( + "Delete chickin start id=%d usage=%.3f pending=%.3f active_consume_alloc=%d active_trace_alloc=%d", + lockedChickin.Id, + lockedChickin.UsageQty, + lockedChickin.PendingUsageQty, + consumeAllocBefore, + traceAllocBefore, + ) + + if err := s.ensureNoRelatedRecording(c.Context(), tx, lockedChickin); err != nil { + return err + } + + hasActiveConsumeAlloc, err := s.hasActiveChickinConsumeAllocations(c.Context(), tx, lockedChickin.Id) + if err != nil { + return err + } + + if lockedChickin.UsageQty > 0 || lockedChickin.PendingUsageQty > 0 || hasActiveConsumeAlloc { + if err := s.ReleaseChickinStocks(c.Context(), tx, lockedChickin, actorID); err != nil { return err } } - now := time.Now().UTC() - note := "delete chickin rollback" - if err := tx.WithContext(c.Context()). - Model(&entity.StockAllocation{}). - Where("usable_type = ? AND usable_id = ? AND status = ?", - fifo.UsableKeyProjectChickin.String(), - chickin.Id, - entity.StockAllocationStatusActive, - ). - Updates(map[string]any{ - "status": entity.StockAllocationStatusReleased, - "released_at": now, - "note": note, - }).Error; err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal release alokasi FIFO chickin") + + if err := s.rollbackChickinPopulation(c.Context(), tx, lockedChickin.Id); err != nil { + return err } if err := chickinRepoTx.DeleteOne(c.Context(), id); err != nil { @@ -473,10 +526,29 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { return err } - if err := s.syncChickinTraceForProductWarehouse(c.Context(), tx, chickin.ProductWarehouseId); err != nil { + reflowAsOf := normalizeDateOnlyUTC(lockedChickin.ChickInDate) + if reflowAsOf.IsZero() { + reflowAsOf = time.Now().UTC() + } + if err := s.reflowWarehouseAfterChickinDelete(c.Context(), tx, lockedChickin.ProductWarehouseId, reflowAsOf); err != nil { return err } + if err := s.syncChickinTraceForProductWarehouse(c.Context(), tx, lockedChickin.ProductWarehouseId); err != nil { + return err + } + + consumeAllocAfter, traceAllocAfter, err := s.countActiveChickinAllocations(c.Context(), tx, lockedChickin.Id) + if err != nil { + return err + } + s.Log.Infof( + "Delete chickin complete id=%d active_consume_alloc=%d active_trace_alloc=%d", + lockedChickin.Id, + consumeAllocAfter, + traceAllocAfter, + ) + return nil }) if err != nil { @@ -489,6 +561,325 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { return nil } +func (s chickinService) ensureNoExecutedTransferForDelete(ctx context.Context, projectFlockKandangID uint) error { + if projectFlockKandangID == 0 || s.TransferLayingRepo == nil { + return nil + } + + // Delete guard by executed transfer hanya untuk kandang kategori growing. + if s.ProjectflockKandangRepo != nil { + pfk, err := s.ProjectflockKandangRepo.GetByIDLight(ctx, projectFlockKandangID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to resolve project flock kandang %d for delete guard: %+v", projectFlockKandangID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying") + } + if err == nil && pfk != nil { + category := strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category)) + if category != strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)) { + return nil + } + } + } + + isExecuted := func(transfer *entity.LayingTransfer) bool { + return transfer != nil && transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero() + } + + sourceTransfer, err := s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, projectFlockKandangID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to resolve transfer laying by source kandang %d for delete guard: %+v", projectFlockKandangID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying") + } + targetTransfer, err := s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx, projectFlockKandangID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to resolve transfer laying by target kandang %d for delete guard: %+v", projectFlockKandangID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying") + } + + if isExecuted(sourceTransfer) || isExecuted(targetTransfer) { + return fiber.NewError( + fiber.StatusBadRequest, + "Chickin tidak dapat dihapus karena transfer laying sudah dieksekusi. Lakukan unexecute transfer terlebih dahulu", + ) + } + + return nil +} + +func (s *chickinService) resolveLayingTransferAvailableQty(ctx context.Context, tx *gorm.DB, targetProjectFlockKandangID, productWarehouseID uint) (float64, error) { + if targetProjectFlockKandangID == 0 || productWarehouseID == 0 { + return 0, nil + } + + db := s.Repository.DB().WithContext(ctx) + if tx != nil { + db = tx.WithContext(ctx) + } + + var available float64 + err := db.Table("laying_transfer_targets ltt"). + Select("COALESCE(SUM(GREATEST(0, COALESCE(ltt.total_qty,0) - COALESCE(ltt.total_used,0))), 0) AS available"). + Joins("JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id AND lt.deleted_at IS NULL"). + Where("ltt.deleted_at IS NULL"). + Where("ltt.target_project_flock_kandang_id = ?", targetProjectFlockKandangID). + Where("ltt.product_warehouse_id = ?", productWarehouseID). + Where("lt.executed_at IS NOT NULL"). + Where(`( + SELECT a.action + FROM approvals a + WHERE a.approvable_type = ? + AND a.approvable_id = lt.id + ORDER BY a.id DESC + LIMIT 1 + ) = ?`, string(utils.ApprovalWorkflowTransferToLaying), entity.ApprovalActionApproved). + Scan(&available).Error + if err != nil { + return 0, err + } + return available, nil +} + +func (s chickinService) ensurePopulationRouteScope(ctx context.Context, tx *gorm.DB) error { + db := tx + if db == nil { + db = s.Repository.DB() + } + if db == nil { + return nil + } + + now := time.Now().UTC() + result := db.WithContext(ctx). + Table("fifo_stock_v2_route_rules"). + Where("is_active = TRUE"). + Where("lane = ?", "STOCKABLE"). + Where("function_code = ?", "POPULATION_IN"). + Where("source_table = ?", "project_flock_populations"). + Where("(scope_sql IS NULL OR TRIM(scope_sql) = '')"). + Updates(map[string]any{ + "scope_sql": "deleted_at IS NULL", + "updated_at": now, + }) + if result.Error != nil { + s.Log.Errorf("Failed to enforce FIFO population route scope: %+v", result.Error) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi konfigurasi FIFO chickin") + } + if result.RowsAffected > 0 { + s.Log.Warnf( + "Auto-fixed FIFO population route scope for chickin flow (rows=%d)", + result.RowsAffected, + ) + } + + return nil +} + +func (s *chickinService) ensureNoRelatedRecording(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin) error { + if chickin == nil || chickin.ProjectFlockKandangId == 0 { + return nil + } + + db := s.Repository.DB().WithContext(ctx) + if tx != nil { + db = tx.WithContext(ctx) + } + + recordDateFloor := normalizeDateOnlyUTC(chickin.ChickInDate) + var earliest entity.Recording + query := db.Model(&entity.Recording{}). + Where("project_flock_kandangs_id = ?", chickin.ProjectFlockKandangId). + Where("deleted_at IS NULL") + if !recordDateFloor.IsZero() { + query = query.Where("record_datetime >= ?", recordDateFloor) + } + if err := query.Order("record_datetime ASC, id ASC").Take(&earliest).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + s.Log.Errorf("Failed to validate related recordings for chickin %d: %+v", chickin.Id, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi recording terkait chickin") + } + + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf( + "%s (recording tanggal %s)", + chickinDeleteRecordingGuardMessage, + normalizeDateOnlyUTC(earliest.RecordDatetime).Format("2006-01-02"), + ), + ) +} + +func (s *chickinService) hasActiveChickinConsumeAllocations(ctx context.Context, tx *gorm.DB, chickinID uint) (bool, error) { + if tx == nil || chickinID == 0 { + return false, nil + } + + var count int64 + if err := tx.WithContext(ctx). + Model(&entity.StockAllocation{}). + Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", + fifo.UsableKeyProjectChickin.String(), + chickinID, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Count(&count).Error; err != nil { + return false, err + } + + return count > 0, nil +} + +func (s *chickinService) countActiveChickinAllocations(ctx context.Context, tx *gorm.DB, chickinID uint) (consume int64, trace int64, err error) { + if tx == nil || chickinID == 0 { + return 0, 0, nil + } + + baseQuery := tx.WithContext(ctx).Model(&entity.StockAllocation{}). + Where("usable_type = ? AND usable_id = ? AND status = ?", + fifo.UsableKeyProjectChickin.String(), + chickinID, + entity.StockAllocationStatusActive, + ) + if err := baseQuery.Session(&gorm.Session{}). + Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). + Count(&consume).Error; err != nil { + return 0, 0, err + } + if err := baseQuery.Session(&gorm.Session{}). + Where("allocation_purpose = ?", entity.StockAllocationPurposeTraceChickin). + Count(&trace).Error; err != nil { + return 0, 0, err + } + + return consume, trace, nil +} + +func (s *chickinService) reflowWarehouseAfterChickinDelete(ctx context.Context, tx *gorm.DB, productWarehouseID uint, asOf time.Time) error { + if tx == nil || productWarehouseID == 0 || s.FifoStockV2Svc == nil { + return nil + } + if err := s.ensurePopulationRouteScope(ctx, tx); err != nil { + return err + } + qtyBefore, hasQtyBefore := s.tryLoadWarehouseQty(ctx, tx, productWarehouseID) + + flagGroupCode, err := s.resolveFlagGroupByProductWarehouse(ctx, tx, productWarehouseID) + if err != nil { + s.Log.Errorf("Failed to resolve flag group for delete chickin reflow pw=%d: %+v", productWarehouseID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal sinkronisasi stok setelah delete chickin") + } + if strings.TrimSpace(flagGroupCode) == "" { + return nil + } + + result, err := s.FifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: flagGroupCode, + ProductWarehouseID: productWarehouseID, + AsOf: &asOf, + Tx: tx, + }) + if err != nil { + s.Log.Errorf("Failed to reflow warehouse after delete chickin pw=%d: %+v", productWarehouseID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal sinkronisasi stok setelah delete chickin") + } + + processedUsables := 0 + rollbackQty := 0.0 + allocateQty := 0.0 + if result != nil { + processedUsables = result.ProcessedUsables + rollbackQty = result.Rollback.ReleasedQty + allocateQty = result.Allocate.AllocatedQty + } + s.Log.Infof( + "Delete chickin warehouse reflow pw=%d processed_usables=%d rollback_qty=%.3f allocate_qty=%.3f", + productWarehouseID, + processedUsables, + rollbackQty, + allocateQty, + ) + s.logWarehouseQtySnapshot( + ctx, + tx, + productWarehouseID, + "reflow_after_delete_chickin", + 0, + hasQtyBefore, + qtyBefore, + ) + + return nil +} + +func (s *chickinService) rollbackChickinPopulation(ctx context.Context, tx *gorm.DB, chickinID uint) error { + if tx == nil || chickinID == 0 { + return nil + } + + var populationIDs []uint + if err := tx.WithContext(ctx). + Model(&entity.ProjectFlockPopulation{}). + Where("project_chickin_id = ?", chickinID). + Pluck("id", &populationIDs).Error; err != nil { + s.Log.Errorf("Failed to list population ids for chickin %d: %+v", chickinID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil population chickin") + } + if len(populationIDs) == 0 { + return nil + } + + now := time.Now().UTC() + note := "delete chickin rollback population" + releaseResult := tx.WithContext(ctx). + Model(&entity.StockAllocation{}). + Where("stockable_type = ? AND stockable_id IN ? AND status = ?", + fifo.StockableKeyProjectFlockPopulation.String(), + populationIDs, + entity.StockAllocationStatusActive, + ). + Where("NOT (usable_type = ? AND usable_id = ?)", + fifo.UsableKeyProjectChickin.String(), + chickinID, + ). + Updates(map[string]any{ + "status": entity.StockAllocationStatusReleased, + "released_at": now, + "note": note, + }) + if releaseResult.Error != nil { + err := releaseResult.Error + s.Log.Errorf("Failed to release population allocation for chickin %d: %+v", chickinID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal release alokasi population chickin") + } + if releaseResult.RowsAffected > 0 { + s.Log.Infof( + "Delete chickin rollback population id=%d released_population_alloc=%d", + chickinID, + releaseResult.RowsAffected, + ) + } + + deleteResult := tx.WithContext(ctx). + Where("id IN ?", populationIDs). + Delete(&entity.ProjectFlockPopulation{}) + if deleteResult.Error != nil { + err := deleteResult.Error + s.Log.Errorf("Failed to delete populations for chickin %d: %+v", chickinID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus population chickin") + } + if deleteResult.RowsAffected > 0 { + s.Log.Infof( + "Delete chickin rollback population id=%d deleted_population=%d", + chickinID, + deleteResult.RowsAffected, + ) + } + + return nil +} + func isForeignKeyViolation(err error) bool { if err == nil { return false @@ -561,6 +952,9 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + if err := s.ensurePopulationRouteScope(c.Context(), dbTransaction); err != nil { + return err + } approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) chickinRepoTx := repository.NewChickinRepository(dbTransaction) @@ -818,6 +1212,9 @@ func (s *chickinService) ReplenishChickinStocks(ctx context.Context, tx *gorm.DB if asOf.IsZero() { asOf = chickin.CreatedAt } + if err := s.ensurePopulationRouteScope(ctx, tx); err != nil { + return err + } return reflowChickinScope(ctx, s.FifoStockV2Svc, tx, targetPW.Id, &asOf) } @@ -828,14 +1225,298 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, if tx == nil { return errors.New("transaction is required") } + if chickin.ProductWarehouseId == 0 { + return s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, 0, 0) + } + qtyBefore, hasQtyBefore := s.tryLoadWarehouseQty(ctx, tx, chickin.ProductWarehouseId) - if err := s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, 0, 0); err != nil { + var activeConsumeCount int64 + if err := tx.WithContext(ctx). + Model(&entity.StockAllocation{}). + Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", + fifo.UsableKeyProjectChickin.String(), + chickin.Id, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Count(&activeConsumeCount).Error; err != nil { return err } + if activeConsumeCount == 0 || s.FifoStockV2Svc == nil { + s.Log.Infof( + "Release chickin stock fallback id=%d active_consume_alloc=%d fifo_available=%t", + chickin.Id, + activeConsumeCount, + s.FifoStockV2Svc != nil, + ) + if err := s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, 0, 0); err != nil { + return err + } + s.logWarehouseQtySnapshot( + ctx, + tx, + chickin.ProductWarehouseId, + "release_chickin_fallback_no_active_alloc", + chickin.Id, + hasQtyBefore, + qtyBefore, + ) + return nil + } + + shouldRestoreWarehouseQty := true + if s.ProjectflockKandangRepo != nil && chickin.ProjectFlockKandangId != 0 { + pfk, err := s.ProjectflockKandangRepo.GetByIDLight(ctx, chickin.ProjectFlockKandangId) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if err == nil && pfk != nil { + category := strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category)) + if category == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) { + shouldRestoreWarehouseQty = false + } + } + } + + if !shouldRestoreWarehouseQty { + var affectedTransferTargetIDs []uint + if err := tx.WithContext(ctx). + Model(&entity.StockAllocation{}). + Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ? AND stockable_type = ?", + fifo.UsableKeyProjectChickin.String(), + chickin.Id, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + fifo.StockableKeyTransferToLayingIn.String(), + ). + Pluck("stockable_id", &affectedTransferTargetIDs).Error; err != nil { + return err + } + + now := time.Now().UTC() + releaseResult := tx.WithContext(ctx). + Model(&entity.StockAllocation{}). + Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", + fifo.UsableKeyProjectChickin.String(), + chickin.Id, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Updates(map[string]any{ + "status": entity.StockAllocationStatusReleased, + "released_at": now, + "note": "chickin rollback without qty adjust", + }) + if releaseResult.Error != nil { + err := releaseResult.Error + return err + } + s.Log.Infof( + "Release chickin stock laying id=%d released_consume_alloc=%d transfer_targets=%d", + chickin.Id, + releaseResult.RowsAffected, + len(affectedTransferTargetIDs), + ) + if err := s.resyncTransferTargetUsageFromAllocations(ctx, tx, affectedTransferTargetIDs); err != nil { + return err + } + if err := s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, 0, 0); err != nil { + return err + } + s.logWarehouseQtySnapshot( + ctx, + tx, + chickin.ProductWarehouseId, + "release_chickin_laying_no_restore", + chickin.Id, + hasQtyBefore, + qtyBefore, + ) + return nil + } + + rollbackResult, err := s.FifoStockV2Svc.Rollback(ctx, commonSvc.FifoStockV2RollbackRequest{ + ProductWarehouseID: chickin.ProductWarehouseId, + Usable: commonSvc.FifoStockV2Ref{ + ID: chickin.Id, + LegacyTypeKey: fifo.UsableKeyProjectChickin.String(), + FunctionCode: "CHICKIN_OUT", + }, + Reason: "delete/reject chickin rollback", + Tx: tx, + }) + if err != nil { + s.Log.Errorf("Failed to rollback FIFO v2 for chickin %d: %+v", chickin.Id, err) + return err + } + releasedQty := 0.0 + detailCount := 0 + if rollbackResult != nil { + releasedQty = rollbackResult.ReleasedQty + detailCount = len(rollbackResult.Details) + } + s.Log.Infof( + "Release chickin stock fifo rollback id=%d released_qty=%.3f detail_count=%d", + chickin.Id, + releasedQty, + detailCount, + ) + s.logWarehouseQtySnapshot( + ctx, + tx, + chickin.ProductWarehouseId, + "release_chickin_fifo_rollback", + chickin.Id, + hasQtyBefore, + qtyBefore, + ) + return nil } +func (s *chickinService) tryLoadWarehouseQty(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (float64, bool) { + if tx == nil || productWarehouseID == 0 { + return 0, false + } + + type row struct { + Qty float64 `gorm:"column:qty"` + } + out := row{} + if err := tx.WithContext(ctx). + Table("product_warehouses"). + Select("COALESCE(qty, 0) AS qty"). + Where("id = ?", productWarehouseID). + Take(&out).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, false + } + errText := strings.ToLower(strings.TrimSpace(err.Error())) + if strings.Contains(errText, "no such column") && strings.Contains(errText, "qty") { + return 0, false + } + if strings.Contains(errText, "column") && strings.Contains(errText, "qty") && strings.Contains(errText, "does not exist") { + return 0, false + } + s.Log.Warnf("Failed to load warehouse qty snapshot pw=%d: %+v", productWarehouseID, err) + return 0, false + } + + return out.Qty, true +} + +func (s *chickinService) logWarehouseQtySnapshot( + ctx context.Context, + tx *gorm.DB, + productWarehouseID uint, + stage string, + chickinID uint, + hasBefore bool, + beforeQty float64, +) { + afterQty, hasAfter := s.tryLoadWarehouseQty(ctx, tx, productWarehouseID) + if !hasBefore && !hasAfter { + return + } + + if hasBefore && hasAfter { + s.Log.Infof( + "Warehouse qty snapshot stage=%s chickin_id=%d pw=%d before=%.3f after=%.3f delta=%.3f", + stage, + chickinID, + productWarehouseID, + beforeQty, + afterQty, + afterQty-beforeQty, + ) + return + } + + if hasAfter { + s.Log.Infof( + "Warehouse qty snapshot stage=%s chickin_id=%d pw=%d after=%.3f", + stage, + chickinID, + productWarehouseID, + afterQty, + ) + return + } + + s.Log.Infof( + "Warehouse qty snapshot stage=%s chickin_id=%d pw=%d before=%.3f", + stage, + chickinID, + productWarehouseID, + beforeQty, + ) +} + +func (s *chickinService) resyncTransferTargetUsageFromAllocations(ctx context.Context, tx *gorm.DB, transferTargetIDs []uint) error { + if tx == nil || len(transferTargetIDs) == 0 { + return nil + } + + unique := make([]uint, 0, len(transferTargetIDs)) + seen := make(map[uint]struct{}, len(transferTargetIDs)) + for _, id := range transferTargetIDs { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + unique = append(unique, id) + } + if len(unique) == 0 { + return nil + } + + if err := tx.WithContext(ctx). + Model(&entity.LayingTransferTarget{}). + Where("id IN ?", unique). + Update("total_used", 0).Error; err != nil { + return err + } + + type usageRow struct { + StockableID uint `gorm:"column:stockable_id"` + Used float64 `gorm:"column:used"` + } + var usageRows []usageRow + if err := tx.WithContext(ctx). + Table("stock_allocations"). + Select("stockable_id, COALESCE(SUM(qty), 0) AS used"). + Where("stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()). + Where("status = ?", entity.StockAllocationStatusActive). + Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). + Where("stockable_id IN ?", unique). + Group("stockable_id"). + Scan(&usageRows).Error; err != nil { + return err + } + + for _, row := range usageRows { + if err := tx.WithContext(ctx). + Model(&entity.LayingTransferTarget{}). + Where("id = ?", row.StockableID). + Update("total_used", row.Used).Error; err != nil { + return err + } + } + + return nil +} + +func normalizeDateOnlyUTC(value time.Time) time.Time { + if value.IsZero() { + return time.Time{} + } + return time.Date(value.UTC().Year(), value.UTC().Month(), value.UTC().Day(), 0, 0, 0, 0, time.UTC) +} + func (s *chickinService) syncChickinTraceForProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) error { if productWarehouseID == 0 { return nil @@ -849,14 +1530,9 @@ func (s *chickinService) syncChickinTraceForProductWarehouse(ctx context.Context return s.syncChickinTraceForProductWarehouse(ctx, innerTx, productWarehouseID) }) } - - flagGroupCode, err := s.resolveFlagGroupByProductWarehouse(ctx, tx, productWarehouseID) - if err != nil { + if err := s.ensurePopulationRouteScope(ctx, tx); err != nil { return err } - if strings.TrimSpace(flagGroupCode) == "" { - return nil - } now := time.Now() if err := tx.WithContext(ctx). @@ -874,6 +1550,14 @@ func (s *chickinService) syncChickinTraceForProductWarehouse(ctx context.Context return err } + flagGroupCode, err := s.resolveFlagGroupByProductWarehouse(ctx, tx, productWarehouseID) + if err != nil { + return err + } + if strings.TrimSpace(flagGroupCode) == "" { + return nil + } + type chickinTraceRow struct { ID uint `gorm:"column:id"` UsageQty float64 `gorm:"column:usage_qty"` diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index 3df6ad45..a1335598 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -300,6 +300,12 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { mapped := warehouseDTO.ToWarehouseRelationDTO(*warehouse) dtoResult.Warehouse = &mapped } + if isTransition, isLaying, serr := u.ProjectflockService.GetProjectFlockKandangTransferState(c, result.Id); serr != nil { + return serr + } else { + dtoResult.IsTransition = isTransition + dtoResult.IsLaying = isLaying + } if withPopulation { population := dtoResult.AvailableQuantity dtoResult.Population = &population diff --git a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go index 5c055a1d..a3034307 100644 --- a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go @@ -40,6 +40,8 @@ type ProjectFlockKandangDTO struct { AvailableQuantity float64 `json:"available_quantity"` Population *float64 `json:"population,omitempty"` ChickInDate *time.Time `json:"chick_in_date,omitempty"` + IsTransition bool `json:"is_transition"` + IsLaying bool `json:"is_laying"` } func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangDTO { diff --git a/internal/modules/production/project_flocks/module.go b/internal/modules/production/project_flocks/module.go index 98e4a630..935814b7 100644 --- a/internal/modules/production/project_flocks/module.go +++ b/internal/modules/production/project_flocks/module.go @@ -17,6 +17,7 @@ import ( rProjectBudget "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" + rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories" sProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" utils "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -35,6 +36,7 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid projectflockKandangRepo := rProjectflock.NewProjectFlockKandangRepository(db) projectFlockPopulationRepo := rProjectflock.NewProjectFlockPopulationRepository(db) recordingRepo := rRecording.NewRecordingRepository(db) + transferLayingRepo := rTransferLaying.NewTransferLayingRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) projectBudgetRepo := rProjectBudget.NewProjectBudgetRepository(db) @@ -46,7 +48,7 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid panic(fmt.Sprintf("failed to register project flock approval workflow: %v", err)) } - projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, warehouseRepo, productWarehouseRepo, projectBudgetRepo, nonstockRepo, projectFlockPopulationRepo, recordingRepo, approvalService, validate) + projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, warehouseRepo, productWarehouseRepo, projectBudgetRepo, nonstockRepo, projectFlockPopulationRepo, recordingRepo, transferLayingRepo, approvalService, validate) userService := sUser.NewUserService(userRepo, validate) ProjectflockRoutes(router, userService, projectflockService) diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 4caf7540..9007fd1b 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -23,6 +23,7 @@ import ( pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" + transferLayingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories" uniformityRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories" purchaseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" utils "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -44,6 +45,7 @@ type ProjectflockService interface { GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, float64, error) GetProjectFlockKandangPopulation(ctx *fiber.Ctx, projectFlockKandangID uint) (float64, error) GetProjectFlockKandangChickinDate(ctx *fiber.Ctx, projectFlockKandangID uint) (*time.Time, error) + GetProjectFlockKandangTransferState(ctx *fiber.Ctx, projectFlockKandangID uint) (bool, bool, error) GetPeriodSummary(ctx *fiber.Ctx, locationID uint) ([]KandangPeriodSummary, error) GetProjectPeriods(ctx *fiber.Ctx, projectIDs []uint) (map[uint]int, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) @@ -64,6 +66,7 @@ type projectflockService struct { PivotRepo repository.ProjectFlockKandangRepository PopulationRepo repository.ProjectFlockPopulationRepository RecordingRepo recordingRepo.RecordingRepository + TransferLayingRepo transferLayingRepo.TransferLayingRepository ApprovalSvc commonSvc.ApprovalService approvalWorkflow approvalutils.ApprovalWorkflowKey } @@ -85,6 +88,7 @@ func NewProjectflockService( nonstockRepo nonstockRepository.NonstockRepository, populationRepo repository.ProjectFlockPopulationRepository, recordingRepo recordingRepo.RecordingRepository, + transferLayingRepo transferLayingRepo.TransferLayingRepository, approvalSvc commonSvc.ApprovalService, validate *validator.Validate, @@ -102,6 +106,7 @@ func NewProjectflockService( PivotRepo: pivotRepo, PopulationRepo: populationRepo, RecordingRepo: recordingRepo, + TransferLayingRepo: transferLayingRepo, ApprovalSvc: approvalSvc, approvalWorkflow: utils.ApprovalWorkflowProjectFlock, } @@ -538,6 +543,63 @@ func (s projectflockService) GetProjectFlockKandangChickinDate(ctx *fiber.Ctx, p return earliest, nil } +func (s projectflockService) GetProjectFlockKandangTransferState(ctx *fiber.Ctx, projectFlockKandangID uint) (bool, bool, error) { + if projectFlockKandangID == 0 || s.TransferLayingRepo == nil || s.PivotRepo == nil { + return false, false, nil + } + + pfk, err := s.PivotRepo.GetByIDLight(ctx.Context(), projectFlockKandangID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, false, nil + } + s.Log.Errorf("Failed to resolve project flock kandang %d for transfer state: %+v", projectFlockKandangID, err) + return false, false, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve transfer state") + } + + category := strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category)) + var transfer *entity.LayingTransfer + switch category { + case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)): + transfer, err = s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx.Context(), projectFlockKandangID) + 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) { + 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") + } + if transfer == nil { + return false, false, nil + } + + physicalMoveDate := normalizeDateOnlyUTC(transfer.TransferDate) + if physicalMoveDate.IsZero() { + return false, false, nil + } + + economicCutoffDate := physicalMoveDate + if transfer.EconomicCutoffDate != nil && !transfer.EconomicCutoffDate.IsZero() { + economicCutoffDate = normalizeDateOnlyUTC(*transfer.EconomicCutoffDate) + } else if transfer.EffectiveMoveDate != nil && !transfer.EffectiveMoveDate.IsZero() { + economicCutoffDate = normalizeDateOnlyUTC(*transfer.EffectiveMoveDate) + } + if economicCutoffDate.Before(physicalMoveDate) { + economicCutoffDate = physicalMoveDate + } + + referenceDate := normalizeDateOnlyUTC(time.Now().UTC()) + isTransition := !referenceDate.Before(physicalMoveDate) && referenceDate.Before(economicCutoffDate) + isLaying := !referenceDate.Before(economicCutoffDate) + + return isTransition, isLaying, nil +} + func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, float64, error) { idStr = strings.TrimSpace(idStr) projectFlockIdStr = strings.TrimSpace(projectFlockIdStr) @@ -579,6 +641,10 @@ func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idSt return s.GetProjectFlockKandangByProjectAndKandang(ctx, uint(pfid), uint(kid)) } +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) +} + func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) { if s.PopulationRepo == nil { return 0, fiber.NewError(fiber.StatusInternalServerError, "Project flock population repository is not configured") diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index e71bc0c5..278f8aec 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -86,6 +86,8 @@ type RecordingRelationDTO struct { EggWeight float64 `json:"egg_weight"` PopulationCanChange bool `json:"population_can_change"` TransferExecuted *bool `json:"transfer_executed,omitempty"` + IsTransition bool `json:"is_transition"` + IsLaying bool `json:"is_laying"` Approval approvalDTO.ApprovalRelationDTO `json:"approval"` } @@ -247,6 +249,8 @@ func toRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { EggWeight: floatValue(e.EggWeight), PopulationCanChange: boolValueDefault(e.PopulationCanChange, true), TransferExecuted: e.TransferExecuted, + IsTransition: boolValueDefault(e.IsTransition, false), + IsLaying: boolValueDefault(e.IsLaying, false), Approval: latestApproval, } } diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go index 5cdb6c1c..d17c8958 100644 --- a/internal/modules/production/recordings/module.go +++ b/internal/modules/production/recordings/module.go @@ -125,6 +125,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate nonstockRepo, projectFlockPopulationRepo, recordingRepo, + transferLayingRepo, approvalService, validate, ) diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 8d2cc4be..2f99dce0 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -185,12 +185,14 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti rate := recordingutil.ComputeDepletionRate(prev, current, totalChick) recordings[i].DepletionRate = &rate - populationCanChange, transferExecuted, _, _, stateErr := s.evaluatePopulationMutationState(c.Context(), &recordings[i]) + populationCanChange, transferExecuted, isTransition, isLaying, _, _, stateErr := s.evaluatePopulationMutationState(c.Context(), &recordings[i]) if stateErr != nil { return nil, 0, stateErr } recordings[i].PopulationCanChange = boolPtr(populationCanChange) recordings[i].TransferExecuted = boolPtr(transferExecuted) + recordings[i].IsTransition = boolPtr(isTransition) + recordings[i].IsLaying = boolPtr(isLaying) } return recordings, total, nil } @@ -251,12 +253,14 @@ func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, erro recording.DepletionRate = &rate } - populationCanChange, transferExecuted, _, _, stateErr := s.evaluatePopulationMutationState(c.Context(), recording) + populationCanChange, transferExecuted, isTransition, isLaying, _, _, stateErr := s.evaluatePopulationMutationState(c.Context(), recording) if stateErr != nil { return nil, stateErr } recording.PopulationCanChange = boolPtr(populationCanChange) recording.TransferExecuted = boolPtr(transferExecuted) + recording.IsTransition = boolPtr(isTransition) + recording.IsLaying = boolPtr(isLaying) return recording, nil } @@ -990,46 +994,58 @@ func (s *recordingService) resolveRecordingCategory(ctx context.Context, recordi return strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category)), nil } -func (s *recordingService) evaluatePopulationMutationState(ctx context.Context, recording *entity.Recording) (bool, bool, *entity.LayingTransfer, time.Time, error) { +func (s *recordingService) evaluatePopulationMutationState(ctx context.Context, recording *entity.Recording) (bool, bool, bool, bool, *entity.LayingTransfer, time.Time, error) { if recording == nil || recording.ProjectFlockKandangId == 0 || s.TransferLayingRepo == nil { - return true, false, nil, time.Time{}, nil + return true, false, false, false, nil, time.Time{}, nil } category, err := s.resolveRecordingCategory(ctx, recording) if err != nil { s.Log.Errorf("Failed to resolve recording category for population mutation check (recording=%d): %+v", recording.Id, err) - return true, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording") - } - if category != strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)) { - return true, false, nil, time.Time{}, nil + return true, false, false, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording") } - transfer, err := s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, recording.ProjectFlockKandangId) + var transfer *entity.LayingTransfer + switch category { + case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)): + transfer, err = s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, recording.ProjectFlockKandangId) + 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) { - return true, false, nil, time.Time{}, nil + return true, false, false, false, nil, time.Time{}, nil } - s.Log.Errorf("Failed to resolve approved transfer by source kandang for recording %d: %+v", recording.Id, err) - return true, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording") + 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") } if transfer == nil { - return true, false, nil, time.Time{}, nil + return true, false, false, false, nil, time.Time{}, nil } transferDate := transferPhysicalMoveDate(transfer) if transferDate.IsZero() { - return true, false, transfer, transferDate, nil + return true, false, false, false, transfer, transferDate, nil } transferExecuted := transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero() recordDate := normalizeDateOnlyUTC(recording.RecordDatetime) - populationCanChange := !(transferExecuted && !recordDate.Before(transferDate)) + _, economicCutoffDate := transferRecordingWindow(transfer) + isTransition := !recordDate.Before(transferDate) && recordDate.Before(economicCutoffDate) + isLaying := !recordDate.Before(economicCutoffDate) - return populationCanChange, transferExecuted, transfer, transferDate, nil + populationCanChange := true + if category == strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)) { + populationCanChange = !(transferExecuted && !recordDate.Before(transferDate)) + } + + return populationCanChange, transferExecuted, isTransition, isLaying, transfer, transferDate, nil } func (s *recordingService) ensurePopulationMutationAllowed(ctx context.Context, recording *entity.Recording, operation string) error { - populationCanChange, _, transfer, transferDate, err := s.evaluatePopulationMutationState(ctx, recording) + populationCanChange, _, _, _, transfer, transferDate, err := s.evaluatePopulationMutationState(ctx, recording) if err != nil { return err } @@ -1590,7 +1606,7 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm var feedIntake float64 if remainingChick > 0 && usageInGrams > 0 { - feedIntake = (usageInGrams / remainingChick) * 1000 + feedIntake = usageInGrams / remainingChick updates["feed_intake"] = feedIntake recording.FeedIntake = &feedIntake } else { diff --git a/internal/modules/production/transfer_layings/services/fifo_stock_v2_helper.go b/internal/modules/production/transfer_layings/services/fifo_stock_v2_helper.go index 3df37dbf..31a89ee8 100644 --- a/internal/modules/production/transfer_layings/services/fifo_stock_v2_helper.go +++ b/internal/modules/production/transfer_layings/services/fifo_stock_v2_helper.go @@ -15,6 +15,11 @@ const ( transferLayingInFunctionCode = "TRANSFER_TO_LAYING_IN" transferLayingStockableLane = "STOCKABLE" transferLayingSourceTable = "laying_transfer_targets" + + transferLayingOutFunctionCode = "TRANSFER_TO_LAYING_OUT" + transferLayingUsableLane = "USABLE" + transferLayingUsableSourceTable = "laying_transfers" + transferLayingLegacyUsableSourceTable = "laying_transfer_sources" ) func reflowTransferLayingScope( @@ -85,3 +90,90 @@ func resolveTransferLayingFlagGroupByProductWarehouse(ctx context.Context, tx *g return strings.TrimSpace(selected.FlagGroupCode), nil } + +type transferLayingUsableRouteRule struct { + FlagGroupCode string `gorm:"column:flag_group_code"` + SourceTable string `gorm:"column:source_table"` +} + +func resolveTransferLayingUsableFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (string, error) { + rows := make([]transferLayingUsableRouteRule, 0) + err := tx.WithContext(ctx). + Table("fifo_stock_v2_route_rules rr"). + Select("rr.flag_group_code, rr.source_table"). + Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE"). + Where("rr.is_active = TRUE"). + Where("rr.lane = ?", transferLayingUsableLane). + Where("rr.function_code = ?", transferLayingOutFunctionCode). + Where(` + EXISTS ( + SELECT 1 + FROM product_warehouses pw + JOIN flags f ON f.flagable_id = pw.product_id + JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE + WHERE pw.id = ? + AND f.flagable_type = ? + AND fm.flag_group_code = rr.flag_group_code + ) + `, productWarehouseID, entity.FlagableTypeProduct). + Order("rr.id ASC"). + Find(&rows).Error + if err != nil { + return "", err + } + + return validateTransferLayingUsableRouteRules(rows, productWarehouseID) +} + +func validateTransferLayingUsableRouteRules(rows []transferLayingUsableRouteRule, productWarehouseID uint) (string, error) { + if len(rows) == 0 { + return "", fmt.Errorf( + "konfigurasi FIFO v2 TRANSFER_TO_LAYING_OUT tidak ditemukan untuk source warehouse %d", + productWarehouseID, + ) + } + + var selectedFlagGroup string + hasHeaderRule := false + hasLegacyRule := false + + for _, row := range rows { + sourceTable := strings.ToLower(strings.TrimSpace(row.SourceTable)) + flagGroupCode := strings.TrimSpace(row.FlagGroupCode) + + switch sourceTable { + case transferLayingUsableSourceTable: + if flagGroupCode == "" { + return "", fmt.Errorf("konfigurasi FIFO v2 TRANSFER_TO_LAYING_OUT memiliki flag_group_code kosong") + } + hasHeaderRule = true + if selectedFlagGroup == "" { + selectedFlagGroup = flagGroupCode + continue + } + if selectedFlagGroup != flagGroupCode { + return "", fmt.Errorf( + "konfigurasi FIFO v2 TRANSFER_TO_LAYING_OUT ambigu untuk source warehouse %d", + productWarehouseID, + ) + } + case transferLayingLegacyUsableSourceTable: + hasLegacyRule = true + } + } + + if hasLegacyRule { + return "", fmt.Errorf( + "konfigurasi FIFO v2 legacy untuk TRANSFER_TO_LAYING_OUT masih aktif (source_table=%s)", + transferLayingLegacyUsableSourceTable, + ) + } + if !hasHeaderRule { + return "", fmt.Errorf( + "konfigurasi FIFO v2 TRANSFER_TO_LAYING_OUT aktif untuk source_table=%s tidak ditemukan", + transferLayingUsableSourceTable, + ) + } + + return selectedFlagGroup, nil +} diff --git a/internal/modules/production/transfer_layings/services/fifo_stock_v2_helper_test.go b/internal/modules/production/transfer_layings/services/fifo_stock_v2_helper_test.go new file mode 100644 index 00000000..7ad3d8ae --- /dev/null +++ b/internal/modules/production/transfer_layings/services/fifo_stock_v2_helper_test.go @@ -0,0 +1,56 @@ +package service + +import ( + "strings" + "testing" +) + +func TestValidateTransferLayingUsableRouteRules(t *testing.T) { + t.Run("valid header rule", func(t *testing.T) { + flagGroup, err := validateTransferLayingUsableRouteRules([]transferLayingUsableRouteRule{ + {FlagGroupCode: "AYAM", SourceTable: transferLayingUsableSourceTable}, + }, 10) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if flagGroup != "AYAM" { + t.Fatalf("unexpected flag group: %s", flagGroup) + } + }) + + t.Run("missing usable header rule", func(t *testing.T) { + _, err := validateTransferLayingUsableRouteRules(nil, 10) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(strings.ToLower(err.Error()), "tidak ditemukan") { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("legacy rule still active", func(t *testing.T) { + _, err := validateTransferLayingUsableRouteRules([]transferLayingUsableRouteRule{ + {FlagGroupCode: "AYAM", SourceTable: transferLayingUsableSourceTable}, + {FlagGroupCode: "AYAM", SourceTable: transferLayingLegacyUsableSourceTable}, + }, 10) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(strings.ToLower(err.Error()), "legacy") { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("ambiguous active header rules", func(t *testing.T) { + _, err := validateTransferLayingUsableRouteRules([]transferLayingUsableRouteRule{ + {FlagGroupCode: "AYAM", SourceTable: transferLayingUsableSourceTable}, + {FlagGroupCode: "PAKAN", SourceTable: transferLayingUsableSourceTable}, + }, 10) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(strings.ToLower(err.Error()), "ambigu") { + t.Fatalf("unexpected error: %v", err) + } + }) +} 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 5a7ef3c8..1f3300b5 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -1026,6 +1026,33 @@ func (s transferLayingService) Unexecute(c *fiber.Ctx, id uint) (*entity.LayingT } } + flagGroupCode, err := resolveTransferLayingUsableFlagGroupByProductWarehouse( + c.Context(), + dbTransaction, + *transfer.SourceProductWarehouseId, + ) + if err != nil { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Konfigurasi FIFO v2 transfer laying tidak valid: %v", err), + ) + } + activeConsumeAllocCount, err := s.countActiveTransferSourceConsumeAllocations( + c.Context(), + dbTransaction, + transfer.Id, + *transfer.SourceProductWarehouseId, + ) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi alokasi FIFO source transfer laying") + } + if transfer.SourceUsageQty > 1e-6 && activeConsumeAllocCount == 0 { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Unexecute transfer laying %s gagal: alokasi FIFO source tidak ditemukan", transfer.TransferNumber), + ) + } + for _, target := range targets { if target.ProductWarehouseId == nil || *target.ProductWarehouseId == 0 { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", transfer.Id)) @@ -1067,21 +1094,40 @@ func (s transferLayingService) Unexecute(c *fiber.Ctx, id uint) (*entity.LayingT } } - asOf := normalizeDateOnlyUTC(transfer.TransferDate) + rollbackResult, err := s.FifoStockV2Svc.Rollback(c.Context(), commonSvc.FifoStockV2RollbackRequest{ + ProductWarehouseID: *transfer.SourceProductWarehouseId, + Usable: commonSvc.FifoStockV2Ref{ + ID: transfer.Id, + LegacyTypeKey: fifo.UsableKeyTransferToLayingOut.String(), + FunctionCode: transferLayingOutFunctionCode, + }, + Reason: fmt.Sprintf("transfer laying unexecute #%s [%s]", transfer.TransferNumber, flagGroupCode), + Tx: dbTransaction, + }) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback FIFO v2 source transfer laying: %v", err)) + } + releasedQty := 0.0 + if rollbackResult != nil { + releasedQty = rollbackResult.ReleasedQty + } + if transfer.SourceUsageQty > 1e-6 && releasedQty < transfer.SourceUsageQty-1e-6 { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf( + "Rollback FIFO v2 source transfer laying tidak lengkap. Dibutuhkan %.3f, terlepas %.3f", + transfer.SourceUsageQty, + releasedQty, + ), + ) + } + if err := repoTx.PatchOne(c.Context(), transfer.Id, map[string]any{ "source_usage_qty": 0, "source_pending_usage_qty": 0, }, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal reset kuantitas source transfer laying") } - if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{ - FlagGroupCode: transferToLayingFlagGroupCode, - ProductWarehouseID: *transfer.SourceProductWarehouseId, - AsOf: &asOf, - Tx: dbTransaction, - }); err != nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback FIFO v2 source transfer laying: %v", err)) - } if err := fifoV2.ReleasePopulationConsumptionByUsable( c.Context(), dbTransaction, @@ -1576,6 +1622,34 @@ func (s *transferLayingService) hasDownstreamRecordingOnTarget( return true, normalizeDateOnlyUTC(earliest.RecordDatetime), nil } +func (s *transferLayingService) countActiveTransferSourceConsumeAllocations( + ctx context.Context, + tx *gorm.DB, + transferID uint, + productWarehouseID uint, +) (int64, error) { + if transferID == 0 || productWarehouseID == 0 { + return 0, nil + } + if tx == nil { + return 0, errors.New("transaction is required") + } + + var count int64 + if err := tx.WithContext(ctx). + Model(&entity.StockAllocation{}). + Where("product_warehouse_id = ?", productWarehouseID). + Where("usable_type = ?", fifo.UsableKeyTransferToLayingOut.String()). + Where("usable_id = ?", transferID). + Where("status = ?", entity.StockAllocationStatusActive). + Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). + Count(&count).Error; err != nil { + return 0, err + } + + return count, nil +} + func (s *transferLayingService) resyncPopulationUsageByProjectFlockKandang(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error { if projectFlockKandangID == 0 { return nil