diff --git a/internal/common/service/fifo_stock_v2/allocate.go b/internal/common/service/fifo_stock_v2/allocate.go index 4d38dab3..42d6ff30 100644 --- a/internal/common/service/fifo_stock_v2/allocate.go +++ b/internal/common/service/fifo_stock_v2/allocate.go @@ -220,6 +220,9 @@ func shouldSkipStockableForUsable(req AllocateRequest, stockableType string) boo if (usableType == "PROJECT_CHICKIN" || functionCode == "CHICKIN_OUT") && stockable == "PROJECT_FLOCK_POPULATION" { return true } + if (usableType == "STOCK_TRANSFER_OUT" || functionCode == "STOCK_TRANSFER_OUT") && stockable == "PROJECT_FLOCK_POPULATION" { + return true + } return false } diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 9cf4789e..e04d1a8f 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -11,7 +11,6 @@ import ( "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" - fifoV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" @@ -22,7 +21,6 @@ import ( projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gorm.io/gorm" ) @@ -513,18 +511,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal", product.ProductID)) } - if strings.EqualFold(flagGroupCode, "AYAM") && outUsageQty > 0 { - if err := s.allocatePopulationForStockTransferOut( - c.Context(), - tx, - detail, - uint(*detail.SourceProductWarehouseID), - outUsageQty, - ); err != nil { - return err - } - } - stockLogDecrease := &entity.StockLog{ ProductWarehouseId: uint(*detail.SourceProductWarehouseID), CreatedBy: uint(actorID), @@ -633,57 +619,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return result, nil } -func (s *transferService) allocatePopulationForStockTransferOut( - ctx context.Context, - tx *gorm.DB, - detail *entity.StockTransferDetail, - sourceProductWarehouseID uint, - consumeQty float64, -) error { - if consumeQty <= 0 { - return nil - } - if tx == nil { - return errors.New("transaction is required") - } - if detail == nil || detail.Id == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Data transfer detail tidak valid") - } - if sourceProductWarehouseID == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Gudang sumber tidak valid") - } - - pw, err := s.ProductWarehouseRepo.WithTx(tx).GetByID(ctx, sourceProductWarehouseID, nil) - if err != nil { - return err - } - if pw.ProjectFlockKandangId == nil || *pw.ProjectFlockKandangId == 0 { - return nil - } - - populations, err := s.ProjectFlockPopulationRepo.WithTx(tx).GetByProjectFlockKandangIDAndProductWarehouseID( - ctx, - *pw.ProjectFlockKandangId, - sourceProductWarehouseID, - ) - if err != nil { - return err - } - if len(populations) == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk transfer") - } - - return fifoV2.AllocatePopulationConsumption( - ctx, - tx, - populations, - sourceProductWarehouseID, - fifo.UsableKeyStockTransferOut.String(), - uint(detail.Id), - consumeQty, - ) -} - func (s *transferService) resolveTransferFlagGroup( ctx context.Context, tx *gorm.DB, diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index ef598be3..21b3a5df 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "math" + "sort" "strings" "time" @@ -35,7 +36,7 @@ import ( 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" + chickinDeleteDownstreamGuardMessage = "Chickin tidak bisa dihapus karena masih dipakai oleh transaksi turunan. Hapus/unexecute Marketing, Recording, dan Transfer to Laying terlebih dahulu." ) type ChickinService interface { @@ -448,21 +449,13 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { - chickin, err := s.Repository.GetByID(c.Context(), id, nil) - if err != nil { + if _, err := s.Repository.GetByID(c.Context(), id, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Chickin not found") } return err } - if err := s.ensureNotTransferred(c.Context(), chickin.ProjectFlockKandangId); err != nil { - return err - } - if err := s.ensureNoExecutedTransferForDelete(c.Context(), chickin.ProjectFlockKandangId); err != nil { - return err - } - actorID, err := m.ActorIDFromContext(c) if err != nil { return err @@ -497,7 +490,7 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { traceAllocBefore, ) - if err := s.ensureNoRelatedRecording(c.Context(), tx, lockedChickin); err != nil { + if err := s.ensureNoDownstreamConsumptionForDelete(c.Context(), tx, lockedChickin.Id); err != nil { return err } @@ -561,51 +554,6 @@ 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 @@ -674,8 +622,8 @@ func (s chickinService) ensurePopulationRouteScope(ctx context.Context, tx *gorm return nil } -func (s *chickinService) ensureNoRelatedRecording(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin) error { - if chickin == nil || chickin.ProjectFlockKandangId == 0 { +func (s *chickinService) ensureNoDownstreamConsumptionForDelete(ctx context.Context, tx *gorm.DB, chickinID uint) error { + if chickinID == 0 { return nil } @@ -684,30 +632,94 @@ func (s *chickinService) ensureNoRelatedRecording(ctx context.Context, tx *gorm. 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") + type downstreamRow struct { + UsableType string `gorm:"column:usable_type"` + UsableID uint `gorm:"column:usable_id"` } - return fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf( - "%s (recording tanggal %s)", - chickinDeleteRecordingGuardMessage, - normalizeDateOnlyUTC(earliest.RecordDatetime).Format("2006-01-02"), - ), - ) + var rows []downstreamRow + if err := db.Table("stock_allocations sa"). + Select("sa.usable_type, sa.usable_id"). + Joins("JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id"). + Where("pfp.project_chickin_id = ?", chickinID). + Where("pfp.deleted_at IS NULL"). + Where("sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()). + Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). + Where("sa.deleted_at IS NULL"). + Where("sa.usable_type IN ?", []string{ + fifo.UsableKeyMarketingDelivery.String(), + fifo.UsableKeyRecordingDepletion.String(), + fifo.UsableKeyTransferToLayingOut.String(), + }). + Group("sa.usable_type, sa.usable_id"). + Scan(&rows).Error; err != nil { + s.Log.Errorf("Failed to validate downstream consumption for chickin %d: %+v", chickinID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transaksi turunan chickin") + } + + if len(rows) == 0 { + return nil + } + + marketingIDs := make(map[uint]struct{}) + recordingIDs := make(map[uint]struct{}) + transferLayingIDs := make(map[uint]struct{}) + + for _, row := range rows { + switch row.UsableType { + case fifo.UsableKeyMarketingDelivery.String(): + marketingIDs[row.UsableID] = struct{}{} + case fifo.UsableKeyRecordingDepletion.String(): + recordingIDs[row.UsableID] = struct{}{} + case fifo.UsableKeyTransferToLayingOut.String(): + transferLayingIDs[row.UsableID] = struct{}{} + } + } + + details := make([]string, 0, 3) + if ids := sortedIDs(marketingIDs); len(ids) > 0 { + details = append(details, fmt.Sprintf("Marketing=%s", joinUint(ids))) + } + if ids := sortedIDs(recordingIDs); len(ids) > 0 { + details = append(details, fmt.Sprintf("Recording=%s", joinUint(ids))) + } + if ids := sortedIDs(transferLayingIDs); len(ids) > 0 { + details = append(details, fmt.Sprintf("TransferToLaying=%s", joinUint(ids))) + } + + message := chickinDeleteDownstreamGuardMessage + if len(details) > 0 { + message = fmt.Sprintf("%s Dependensi aktif: %s.", message, strings.Join(details, ", ")) + } + + return fiber.NewError(fiber.StatusBadRequest, message) +} + +func sortedIDs(input map[uint]struct{}) []uint { + if len(input) == 0 { + return nil + } + out := make([]uint, 0, len(input)) + for id := range input { + if id == 0 { + continue + } + out = append(out, id) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +func joinUint(values []uint) string { + if len(values) == 0 { + return "-" + } + parts := make([]string, 0, len(values)) + for _, value := range values { + parts = append(parts, fmt.Sprintf("%d", value)) + } + return strings.Join(parts, "|") } func (s *chickinService) hasActiveChickinConsumeAllocations(ctx context.Context, tx *gorm.DB, chickinID uint) (bool, error) { diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index 1a30a058..6ad70ae1 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -63,6 +63,7 @@ func (u *ProjectflockController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), SortBy: c.Query("sort_by", ""), SortOrder: c.Query("sort_order", ""), + Status: strings.TrimSpace(c.Query("status", "")), } if area := c.QueryInt("area_id", 0); area > 0 { diff --git a/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go b/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go index 36fe8cbc..361bf8a3 100644 --- a/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go +++ b/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go @@ -51,6 +51,7 @@ func (r *projectFlockPopulationRepositoryImpl) GetByProjectFlockKandangID(ctx co err := r.DB().WithContext(ctx). Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID). + Where("project_chickins.deleted_at IS NULL"). Preload("ProjectChickin"). Find(&records).Error if err != nil { @@ -87,6 +88,7 @@ func (r *projectFlockPopulationRepositoryImpl) GetByProjectFlockKandangIDAndProd err := r.DB().WithContext(ctx). Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). Where("project_chickins.project_flock_kandang_id = ? AND project_flock_populations.product_warehouse_id = ?", projectFlockKandangID, productWarehouseID). + Where("project_chickins.deleted_at IS NULL"). Find(&records).Error if err != nil { return nil, err @@ -99,8 +101,10 @@ func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProjectFlockKandangI err := r.DB().WithContext(ctx). Table("project_flock_populations"). Select("COALESCE(SUM(total_qty - total_used_qty), 0) AS available_qty"). - Joins("JOIN product_warehouses pw ON project_flock_populations.product_warehouse_id = pw.id"). - Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). + Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). + Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID). + Where("project_chickins.deleted_at IS NULL"). + Where("project_flock_populations.deleted_at IS NULL"). Scan(&total).Error if err != nil { return 0, err @@ -111,9 +115,12 @@ func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProjectFlockKandangI func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProductWarehouseID(ctx context.Context, productWarehouseID uint) (float64, error) { var total float64 err := r.DB().WithContext(ctx). - Model(&entity.ProjectFlockPopulation{}). - Where("product_warehouse_id = ?", productWarehouseID). - Select("COALESCE(SUM(total_qty - total_used_qty), 0)"). + Table("project_flock_populations"). + Select("COALESCE(SUM(project_flock_populations.total_qty - project_flock_populations.total_used_qty), 0)"). + Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). + Where("project_flock_populations.product_warehouse_id = ?", productWarehouseID). + Where("project_chickins.deleted_at IS NULL"). + Where("project_flock_populations.deleted_at IS NULL"). Scan(&total).Error if err != nil { return 0, err @@ -128,6 +135,8 @@ func (r *projectFlockPopulationRepositoryImpl) GetAvailableQtyByProjectFlockKand Select("COALESCE(SUM(total_qty - total_used_qty), 0) AS total_qty"). Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID). + Where("project_chickins.deleted_at IS NULL"). + Where("project_flock_populations.deleted_at IS NULL"). Scan(&total).Error if err != nil { return 0, err @@ -145,6 +154,8 @@ func (r *projectFlockPopulationRepositoryImpl) GetTotalChickInByProjectFlockKand Select("COALESCE(SUM(project_flock_populations.total_qty - project_flock_populations.total_used_qty), 0) AS total_qty"). Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID). + Where("project_chickins.deleted_at IS NULL"). + Where("project_flock_populations.deleted_at IS NULL"). Scan(&total).Error if err != nil { return 0, err diff --git a/internal/modules/production/project_flocks/repositories/projectflock.repository.go b/internal/modules/production/project_flocks/repositories/projectflock.repository.go index cd7aaba7..6fab653f 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock.repository.go @@ -8,6 +8,7 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) @@ -110,6 +111,28 @@ func (r *ProjectflockRepositoryImpl) applyQueryFilters(db *gorm.DB, params *vali AND pfk.kandang_id IN ? )`, params.KandangIds) } + if params.Status != "" { + db = db.Where(` + EXISTS ( + SELECT 1 + FROM approvals latest_approval + WHERE latest_approval.approvable_type = ? + AND latest_approval.approvable_id = project_flocks.id + AND latest_approval.id = ( + SELECT a2.id + FROM approvals a2 + WHERE a2.approvable_type = ? + AND a2.approvable_id = project_flocks.id + ORDER BY a2.id DESC + LIMIT 1 + ) + AND LOWER(latest_approval.step_name) = LOWER(?) + )`, + utils.ApprovalWorkflowProjectFlock.String(), + utils.ApprovalWorkflowProjectFlock.String(), + params.Status, + ) + } db = r.applySearchFilters(db, params.Search) diff --git a/internal/modules/production/project_flocks/validations/projectflock.validation.go b/internal/modules/production/project_flocks/validations/projectflock.validation.go index ca347d47..c6370133 100644 --- a/internal/modules/production/project_flocks/validations/projectflock.validation.go +++ b/internal/modules/production/project_flocks/validations/projectflock.validation.go @@ -20,6 +20,7 @@ type Query struct { LocationId uint `query:"location_id" validate:"omitempty,number,gt=0"` Period int `query:"period" validate:"omitempty,number,gt=0"` Category string `query:"category" validate:"omitempty"` + Status string `query:"status" validate:"omitempty,oneof=Pengajuan Aktif Selesai"` KandangIds []uint `query:"kandang_id" validate:"omitempty,dive,gt=0"` TransferContext string `query:"transfer_context" validate:"omitempty,oneof=transfer_to_laying"` } diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 2a4d8726..a779ed18 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -2409,15 +2409,10 @@ func (s *recordingService) reflowResetRecordingDepletionsOut( return errors.New("stock log repository is not available") } logState := newRecordingStockLogState() - stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx) - for _, depletion := range depletions { if depletion.Id == 0 { continue } - if err := stockAllocationRepo.ReleaseByUsable(ctx, fifo.UsableKeyRecordingDepletion.String(), depletion.Id, nil, nil); err != nil { - return err - } s.logDepletionTrace("reflow_reset:start", depletion, "") sourceWarehouseID := uint(0)