package service import ( "context" "errors" "fmt" "math" "sort" "strings" "time" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" 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" KandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/validations" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/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" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/jackc/pgconn" pgconnv5 "github.com/jackc/pgx/v5/pgconn" "github.com/sirupsen/logrus" "gorm.io/gorm" "gorm.io/gorm/clause" ) const ( chickinDeletePopulationGuardMessage = "Chickin tidak dapat dihapus karena masih memiliki population aktif" chickinDeleteDownstreamGuardMessage = "Chickin tidak bisa dihapus karena masih dipakai oleh transaksi turunan. Hapus/unexecute Marketing, Recording, Transfer, Adjustment, dan Transfer to Laying terlebih dahulu." chickinAdjustmentSourceTable = "adjustment_stocks" ) type ChickinService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectChickin, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) ([]entity.ProjectChickin, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectChickin, error) DeleteOne(ctx *fiber.Ctx, id uint) error Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectChickin, error) EnsureChickInExists(ctx context.Context, projectFlockKandangID uint) error } type chickinService struct { Log *logrus.Logger Validate *validator.Validate Repository repository.ProjectChickinRepository KandangRepo KandangRepo.KandangRepository WarehouseRepo rWarehouse.WarehouseRepository ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository ProductRepo rProduct.ProductRepository ProjectFlockRepo rProjectFlock.ProjectflockRepository ProjectflockKandangRepo rProjectFlock.ProjectFlockKandangRepository ProjectflockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository ProjectChickinDetailRepo repository.ProjectChickinDetailRepository TransferLayingRepo rTransferLaying.TransferLayingRepository FifoStockV2Svc commonSvc.FifoStockV2Service StockLogRepo rStockLogs.StockLogRepository } func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, productRepo rProduct.ProductRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, transferLayingRepo rTransferLaying.TransferLayingRepository, validate *validator.Validate, fifoStockV2Svc commonSvc.FifoStockV2Service) ChickinService { return &chickinService{ Log: utils.Log, Validate: validate, Repository: repo, KandangRepo: kandangRepo, WarehouseRepo: warehouseRepo, ProductWarehouseRepo: productWarehouseRepo, ProductRepo: productRepo, ProjectFlockRepo: projectFlockRepo, ProjectflockKandangRepo: projectflockkandangRepo, ProjectflockPopulationRepo: projectflockpopulationRepo, ProjectChickinDetailRepo: projectChickinDetailRepo, TransferLayingRepo: transferLayingRepo, FifoStockV2Svc: fifoStockV2Svc, StockLogRepo: rStockLogs.NewStockLogRepository(repo.DB()), } } func (s chickinService) withRelations(db *gorm.DB) *gorm.DB { return db. Preload("CreatedUser"). Preload("ProjectFlockKandang.Kandang"). Preload("ProjectFlockKandang.Kandang.Location"). Preload("ProjectFlockKandang.Kandang.Location.Area"). Preload("ProjectFlockKandang.Kandang.Pic"). Preload("ProjectFlockKandang.ProjectFlock"). Preload("ProjectFlockKandang.ProjectFlock.Area"). Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard.ProductionStandardDetails"). Preload("ProjectFlockKandang.ProjectFlock.Location"). Preload("ProjectFlockKandang.ProjectFlock.Location.Area") } func resolveWarehouseForProjectFlockKandang(ctx context.Context, warehouseRepo rWarehouse.WarehouseRepository, kandangID uint, locationID uint) (*entity.Warehouse, error) { if warehouseRepo == nil { return nil, gorm.ErrRecordNotFound } if kandangID == 0 { return nil, gorm.ErrRecordNotFound } if locationID != 0 { warehouse, err := warehouseRepo.GetByKandangIDAndLocationID(ctx, kandangID, locationID) if err == nil { return warehouse, nil } if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } } return warehouseRepo.GetByKandangID(ctx, kandangID) } func (s chickinService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err } offset := (params.Page - 1) * params.Limit chickins, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.ProjectFlockKandangId != 0 { return db.Where("project_flock_kandang_id = ?", params.ProjectFlockKandangId) } return db.Order("created_at DESC").Order("updated_at DESC") }) if err != nil { return nil, 0, err } return chickins, total, nil } func (s chickinService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectChickin, error) { chickin, err := s.Repository.GetByID(c.Context(), id, s.withRelations) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Chickin not found") } if err != nil { return nil, err } return chickin, nil } func (s chickinService) ensureNotTransferred(ctx context.Context, projectFlockKandangID uint) error { if projectFlockKandangID == 0 || s.TransferLayingRepo == nil { 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 checkExecuted(sourceTransfer) { return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang sudah dipindahkan ke laying") } return nil } func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]entity.ProjectChickin, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } if err := s.ensureNotTransferred(c.Context(), req.ProjectFlockKandangId); err != nil { return nil, err } projectFlockKandang, err := s.ProjectflockKandangRepo.GetByID(c.Context(), req.ProjectFlockKandangId) if err != nil { return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock Kandang not found") } warehouse, err := resolveWarehouseForProjectFlockKandang( c.Context(), s.WarehouseRepo, projectFlockKandang.KandangId, projectFlockKandang.ProjectFlock.LocationId, ) if err != nil { return nil, fiber.NewError(fiber.StatusNotFound, "Warehouse for Kandang not found") } actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err } newChikins := make([]*entity.ProjectChickin, 0) chickinQtyMap := make(map[uint]float64) flockCategory := strings.ToUpper(strings.TrimSpace(projectFlockKandang.ProjectFlock.Category)) for idx, chickinReq := range req.ChickinRequests { productWarehouse, err := s.ProductWarehouseRepo.GetByID(c.Context(), chickinReq.ProductWarehouseId, func(db *gorm.DB) *gorm.DB { return db.Preload("Product.Flags") }) if err != nil { return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Product warehouse %d not found", chickinReq.ProductWarehouseId)) } if productWarehouse.WarehouseId != warehouse.Id { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product warehouse %d is not bound to kandang's warehouse", chickinReq.ProductWarehouseId)) } if productWarehouse.ProjectFlockKandangId != nil && *productWarehouse.ProjectFlockKandangId != req.ProjectFlockKandangId { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product warehouse %d belongs to different flock. Only product warehouses with project_flock_kandang_id = NULL or = %d can be used", chickinReq.ProductWarehouseId, req.ProjectFlockKandangId)) } if productWarehouse.Product.Id != 0 { if flockCategory != string(utils.ProjectFlockCategoryGrowing) && flockCategory != string(utils.ProjectFlockCategoryLaying) { return nil, fmt.Errorf("invalid flock category for chickin") } hasAyamFlag := false for _, flag := range productWarehouse.Product.Flags { if utils.CanonicalFlagType(flag.Name) == utils.FlagAyam { hasAyamFlag = true break } } if !hasAyamFlag { return nil, fmt.Errorf( "product warehouse %d cannot be used for %s chickin. Product must have AYAM flag (or legacy alias DOC/PULLET/LAYER) (product ID: %d, warehouse ID: %d)", chickinReq.ProductWarehouseId, projectFlockKandang.ProjectFlock.Category, productWarehouse.Product.Id, productWarehouse.Id, ) } } chickinDate, err := utils.ParseDateString(chickinReq.ChickInDate) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid ChickInDate format for product warehouse %d", chickinReq.ProductWarehouseId)) } newChickin := &entity.ProjectChickin{ ProjectFlockKandangId: req.ProjectFlockKandangId, ChickInDate: chickinDate, UsageQty: 0, PendingUsageQty: 0, ProductWarehouseId: chickinReq.ProductWarehouseId, Notes: chickinReq.Note, CreatedBy: actorID, } newChikins = append(newChikins, newChickin) totalPopulationQty, err := s.ProjectflockPopulationRepo.GetTotalQtyByProductWarehouseID(c.Context(), chickinReq.ProductWarehouseId) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get total population quantity for product warehouse %d", chickinReq.ProductWarehouseId)) } availableQty := productWarehouse.Quantity - totalPopulationQty if availableQty < 0 { availableQty = 0 } if flockCategory == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) { sourceAvailable, err := s.resolveLayingSourceAvailableQty(c.Context(), nil, chickinReq.ProductWarehouseId, &chickinDate) 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 sourceAvailable < 0 { sourceAvailable = 0 } if sourceAvailable < availableQty { availableQty = sourceAvailable } } chickinQtyMap[uint(idx)] = availableQty } if len(newChikins) == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "No chickins to create") } 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) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusInternalServerError, "Failed to check existing chickins") } isFirstTime := len(existingChikins) == 0 pendingQtyMap := make(map[uint]float64) for _, existingChickin := range existingChikins { if existingChickin.PendingUsageQty > 0 { pendingQtyMap[existingChickin.ProductWarehouseId] += existingChickin.PendingUsageQty } } for idx, chickin := range newChikins { pendingQty := pendingQtyMap[chickin.ProductWarehouseId] desiredQty := chickinQtyMap[uint(idx)] availableQty := desiredQty - pendingQty if availableQty < 0 { availableQty = 0 } if availableQty <= 0 { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No available stock in product warehouse %d for chickin. Warehouse: %.0f, Pending: %.0f, Available: %.0f", chickin.ProductWarehouseId, desiredQty, pendingQty, availableQty)) } chickinQtyMap[uint(idx)] = availableQty pendingQtyMap[chickin.ProductWarehouseId] += availableQty } approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) if err := s.Repository.WithTx(dbTransaction).CreateMany(c.Context(), newChikins, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create chickins") } for idx, chickin := range newChikins { desiredQty := chickinQtyMap[uint(idx)] if err := s.StageChickinStocks(c.Context(), dbTransaction, chickin, desiredQty, actorID); err != nil { return err } } latest, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, projectFlockKandang.Id, nil) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get latest approval") } var approvalAction entity.ApprovalAction if isFirstTime { approvalAction = entity.ApprovalActionCreated } else { approvalAction = entity.ApprovalActionUpdated } if latest == nil { if _, err := approvalSvcTx.CreateApproval( c.Context(), utils.ApprovalWorkflowChickin, projectFlockKandang.Id, utils.ChickinStepPengajuan, &approvalAction, actorID, nil); err != nil { if !errors.Is(err, gorm.ErrDuplicatedKey) { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create approval") } } } else if latest.StepNumber != uint16(utils.ChickinStepPengajuan) { if _, err := approvalSvcTx.CreateApproval( c.Context(), utils.ApprovalWorkflowChickin, projectFlockKandang.Id, utils.ChickinStepPengajuan, &approvalAction, actorID, nil); err != nil { if !errors.Is(err, gorm.ErrDuplicatedKey) { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create approval") } } } return nil }) if err != nil { if fiberErr, ok := err.(*fiber.Error); ok { return nil, fiberErr } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create chickins") } result := make([]entity.ProjectChickin, 0, len(newChikins)) for _, chickin := range newChikins { loaded, err := s.GetOne(c, chickin.Id) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to reload chickin %d with relations: %v", chickin.Id, err)) } result = append(result, *loaded) } if len(result) == 0 { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load created chickins") } return result, nil } func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectChickin, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } chickin, err := s.Repository.GetByID(c.Context(), id, nil) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Chickin not found") } return nil, err } if err := s.ensureNotTransferred(c.Context(), chickin.ProjectFlockKandangId); err != nil { return nil, err } updateBody := make(map[string]any) if req.ChickInDate != "" { updateBody["chick_in_date"] = req.ChickInDate } if req.Note != "" { updateBody["notes"] = req.Note } if len(updateBody) == 0 { return s.GetOne(c, id) } if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Chickin not found") } return nil, err } updated, err := s.GetOne(c, id) if err != nil { return nil, err } if updated.UsageQty > 0 { if err := s.syncChickinTraceForProductWarehouse(c.Context(), nil, updated.ProductWarehouseId); err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to sync chickin stock trace") } } return updated, nil } func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { 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 } actorID, err := m.ActorIDFromContext(c) if err != nil { return err } 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) 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.ensureNoDownstreamConsumptionForDelete(c.Context(), tx, lockedChickin.Id); 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 } } if err := s.rollbackChickinPopulation(c.Context(), tx, lockedChickin.Id); err != nil { return err } if err := chickinRepoTx.DeleteOne(c.Context(), id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Chickin not found") } if isForeignKeyViolation(err) { return fiber.NewError(fiber.StatusBadRequest, chickinDeletePopulationGuardMessage) } return err } 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 { if fiberErr, ok := err.(*fiber.Error); ok { return fiberErr } return err } return nil } func (s *chickinService) resolveLayingSourceAvailableQty(ctx context.Context, tx *gorm.DB, productWarehouseID uint, asOf *time.Time) (float64, error) { if productWarehouseID == 0 || s.FifoStockV2Svc == nil { return 0, nil } db := s.Repository.DB() if tx != nil { db = tx } flagGroupCode, err := resolveChickinFlagGroupByProductWarehouse(ctx, db, productWarehouseID) if err != nil { return 0, err } if strings.TrimSpace(flagGroupCode) == "" { return 0, nil } gatherRows, err := s.FifoStockV2Svc.Gather(ctx, commonSvc.FifoStockV2GatherRequest{ FlagGroupCode: flagGroupCode, Lane: commonSvc.FifoStockV2Lane("STOCKABLE"), AllocationPurpose: entity.StockAllocationPurposeConsume, ProductWarehouseID: productWarehouseID, AsOf: nil, Limit: 10000, Tx: tx, }) if err != nil { return 0, err } available := 0.0 hasAsOf := asOf != nil && !asOf.IsZero() for _, row := range gatherRows { if row.AvailableQuantity <= 0 { continue } if hasAsOf && !strings.EqualFold(strings.TrimSpace(row.SourceTable), chickinAdjustmentSourceTable) && row.SortAt.After(*asOf) { continue } available += row.AvailableQuantity } 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) ensureNoDownstreamConsumptionForDelete(ctx context.Context, tx *gorm.DB, chickinID uint) error { if chickinID == 0 { return nil } db := s.Repository.DB().WithContext(ctx) if tx != nil { db = tx.WithContext(ctx) } type downstreamRow struct { UsableType string `gorm:"column:usable_type"` UsableID uint `gorm:"column:usable_id"` } var rows []downstreamRow dependencyTypes := []string{ fifo.UsableKeyMarketingDelivery.String(), fifo.UsableKeyRecordingStock.String(), fifo.UsableKeyRecordingDepletion.String(), fifo.UsableKeyStockTransferOut.String(), fifo.UsableKeyAdjustmentOut.String(), fifo.UsableKeyTransferToLayingOut.String(), } query := ` WITH chickin_sources AS ( SELECT DISTINCT sa.stockable_type, sa.stockable_id FROM stock_allocations sa WHERE sa.usable_type = ? AND sa.usable_id = ? AND sa.status = ? AND sa.allocation_purpose = ? AND sa.deleted_at IS NULL ), downstream_by_population AS ( SELECT sa.usable_type, sa.usable_id FROM project_flock_populations pfp JOIN stock_allocations sa ON sa.stockable_type = ? AND sa.stockable_id = pfp.id WHERE pfp.project_chickin_id = ? AND pfp.deleted_at IS NULL AND sa.status = ? AND sa.allocation_purpose = ? AND sa.deleted_at IS NULL AND sa.usable_type IN ? ), downstream_by_source AS ( SELECT sa.usable_type, sa.usable_id FROM chickin_sources cs JOIN stock_allocations sa ON sa.stockable_type = cs.stockable_type AND sa.stockable_id = cs.stockable_id WHERE sa.status = ? AND sa.allocation_purpose = ? AND sa.deleted_at IS NULL AND sa.usable_type IN ? ) SELECT dep.usable_type, dep.usable_id FROM ( SELECT usable_type, usable_id FROM downstream_by_population UNION SELECT usable_type, usable_id FROM downstream_by_source ) dep ` if err := db.Raw( query, fifo.UsableKeyProjectChickin.String(), chickinID, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume, fifo.StockableKeyProjectFlockPopulation.String(), chickinID, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume, dependencyTypes, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume, dependencyTypes, ).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{}) transferIDs := make(map[uint]struct{}) adjustmentIDs := make(map[uint]struct{}) transferLayingIDs := make(map[uint]struct{}) orphanIDs := make(map[string]map[uint]struct{}) for _, row := range rows { exists, existsErr := s.usableReferenceExistsForChickinDelete(ctx, db, row.UsableType, row.UsableID) if existsErr != nil { s.Log.Errorf("Failed to validate downstream usable reference %s:%d for chickin %d: %+v", row.UsableType, row.UsableID, chickinID, existsErr) return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi referensi transaksi turunan chickin") } if !exists { if _, ok := orphanIDs[row.UsableType]; !ok { orphanIDs[row.UsableType] = make(map[uint]struct{}) } orphanIDs[row.UsableType][row.UsableID] = struct{}{} continue } switch row.UsableType { case fifo.UsableKeyMarketingDelivery.String(): marketingIDs[row.UsableID] = struct{}{} case fifo.UsableKeyRecordingStock.String(), fifo.UsableKeyRecordingDepletion.String(): recordingIDs[row.UsableID] = struct{}{} case fifo.UsableKeyStockTransferOut.String(): transferIDs[row.UsableID] = struct{}{} case fifo.UsableKeyAdjustmentOut.String(): adjustmentIDs[row.UsableID] = struct{}{} case fifo.UsableKeyTransferToLayingOut.String(): transferLayingIDs[row.UsableID] = struct{}{} } } if len(orphanIDs) > 0 { orphanDetails := make([]string, 0, len(orphanIDs)) for usableType, idsMap := range orphanIDs { ids := sortedIDs(idsMap) if len(ids) == 0 { continue } orphanDetails = append(orphanDetails, fmt.Sprintf("%s=%s", usableType, joinUint(ids))) } sort.Strings(orphanDetails) return fiber.NewError( fiber.StatusBadRequest, fmt.Sprintf( "Delete chickin diblok karena ditemukan orphan stock allocation pada transaksi turunan: %s. Bersihkan orphan terlebih dahulu.", strings.Join(orphanDetails, ", "), ), ) } details := make([]string, 0, 5) 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(transferIDs); len(ids) > 0 { details = append(details, fmt.Sprintf("Transfer=%s", joinUint(ids))) } if ids := sortedIDs(adjustmentIDs); len(ids) > 0 { details = append(details, fmt.Sprintf("Adjustment=%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 (s *chickinService) usableReferenceExistsForChickinDelete(ctx context.Context, db *gorm.DB, usableType string, usableID uint) (bool, error) { if usableID == 0 { return false, nil } if db == nil { return false, fmt.Errorf("db is required") } var count int64 switch usableType { case fifo.UsableKeyAdjustmentOut.String(): if err := db.WithContext(ctx). Table("adjustment_stocks"). Where("id = ?", usableID). Count(&count).Error; err != nil { return false, err } case fifo.UsableKeyMarketingDelivery.String(): if err := db.WithContext(ctx). Table("marketing_delivery_products"). Where("id = ?", usableID). Count(&count).Error; err != nil { return false, err } case fifo.UsableKeyRecordingStock.String(): if err := db.WithContext(ctx). Table("recording_stocks rs"). Joins("JOIN recordings r ON r.id = rs.recording_id"). Where("rs.id = ?", usableID). Where("r.deleted_at IS NULL"). Count(&count).Error; err != nil { return false, err } case fifo.UsableKeyRecordingDepletion.String(): if err := db.WithContext(ctx). Table("recording_depletions rd"). Joins("JOIN recordings r ON r.id = rd.recording_id"). Where("rd.id = ?", usableID). Where("r.deleted_at IS NULL"). Count(&count).Error; err != nil { return false, err } case fifo.UsableKeyStockTransferOut.String(): if err := db.WithContext(ctx). Table("stock_transfer_details std"). Joins("JOIN stock_transfers st ON st.id = std.stock_transfer_id"). Where("std.id = ?", usableID). Where("std.deleted_at IS NULL"). Where("st.deleted_at IS NULL"). Count(&count).Error; err != nil { return false, err } case fifo.UsableKeyTransferToLayingOut.String(): if err := db.WithContext(ctx). Table("laying_transfers"). Where("id = ?", usableID). Where("deleted_at IS NULL"). Count(&count).Error; err != nil { return false, err } default: return true, nil } return count > 0, nil } 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) { 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 } var pgErr *pgconn.PgError if errors.As(err, &pgErr) { return pgErr.Code == "23503" } var pgErrV5 *pgconnv5.PgError if errors.As(err, &pgErrV5) { return pgErrV5.Code == "23503" } return false } func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.ProjectChickin, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err } approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.Repository.DB())) var action entity.ApprovalAction switch strings.ToUpper(strings.TrimSpace(req.Action)) { case string(entity.ApprovalActionRejected): action = entity.ApprovalActionRejected case string(entity.ApprovalActionApproved): action = entity.ApprovalActionApproved default: return nil, fiber.NewError(fiber.StatusBadRequest, "action must be APPROVED or REJECTED") } approvableIDs := utils.UniqueUintSlice(req.ApprovableIds) if len(approvableIDs) == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id") } for _, id := range approvableIDs { if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "ProjectFlockKandang", ID: &id, Exists: s.ProjectflockKandangRepo.IdExists}); err != nil { return nil, err } if err := s.ensureNotTransferred(c.Context(), id); err != nil { return nil, err } latestApproval, err := approvalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, id, nil) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check approval status") } if latestApproval == nil { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No approval found for ProjectFlockKandang %d - chickins must be created first", id)) } if latestApproval.StepNumber != uint16(utils.ChickinStepPengajuan) { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("ProjectFlockKandang %d cannot be approved - current status is not in PENGAJUAN stage", id)) } } step := utils.ChickinStepPengajuan if action == entity.ApprovalActionApproved { step = utils.ChickinStepDisetujui } 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) ProjectFlockPopulationRepotx := s.ProjectflockPopulationRepo.WithTx(dbTransaction) touchedProductWarehouseIDs := make(map[uint]struct{}) for _, approvableID := range approvableIDs { // Re-check latest approval inside transaction to prevent double-approve races. var latest entity.Approval if err := dbTransaction.WithContext(c.Context()). Table("approvals"). Where("approvable_type = ? AND approvable_id = ?", utils.ApprovalWorkflowChickin.String(), approvableID). Order("id DESC"). Limit(1). Clauses(clause.Locking{Strength: "UPDATE"}). Take(&latest).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusInternalServerError, "Failed to recheck approval status") } if latest.Id != 0 && latest.StepNumber != uint16(utils.ChickinStepPengajuan) { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("ProjectFlockKandang %d sudah tidak berada di tahap PENGAJUAN", approvableID)) } if _, err := approvalSvc.CreateApproval( c.Context(), utils.ApprovalWorkflowChickin, approvableID, step, &action, actorID, req.Notes, ); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create approval") } if action == entity.ApprovalActionApproved { chickins, err := chickinRepoTx.GetByProjectFlockKandangID(c.Context(), approvableID) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get chickins for approval %d", approvableID)) } kandangForApproval, err := s.ProjectflockKandangRepo.GetByID(c.Context(), approvableID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("ProjectFlockKandang %d not found", approvableID)) } return fiber.NewError(fiber.StatusInternalServerError, "Failed to get ProjectFlockKandang") } category := strings.ToUpper(strings.TrimSpace(kandangForApproval.ProjectFlock.Category)) var targetFlag utils.FlagType if category == string(utils.ProjectFlockCategoryGrowing) { targetFlag = utils.FlagPullet } else if category == string(utils.ProjectFlockCategoryLaying) { targetFlag = utils.FlagLayer } else { continue } for _, chickin := range chickins { approvedQty := chickin.UsageQty if approvedQty <= 0 { approvedQty = chickin.PendingUsageQty } if approvedQty < 0 { approvedQty = 0 } if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, &chickin, approvedQty, actorID); err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to finalize usage qty for chickin %d", chickin.Id)) } chickin.UsageQty = approvedQty chickin.PendingUsageQty = 0 touchedProductWarehouseIDs[chickin.ProductWarehouseId] = struct{}{} populationExists, err := ProjectFlockPopulationRepotx.ExistsByProjectChickinID(c.Context(), chickin.Id) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to check population for chickin %d", chickin.Id)) } if populationExists { continue } sourcePW, err := s.ProductWarehouseRepo.GetByID(c.Context(), chickin.ProductWarehouseId, func(db *gorm.DB) *gorm.DB { return db.Preload("Product.Flags") }) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get product warehouse for chickin %d", chickin.Id)) } if err := s.autoAddFlagToProduct(c.Context(), dbTransaction, sourcePW.Product.Id, targetFlag); err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to auto-add flag to product %d", sourcePW.Product.Id)) } population := &entity.ProjectFlockPopulation{ ProjectChickinId: chickin.Id, ProductWarehouseId: sourcePW.Id, TotalQty: 0, TotalUsedQty: 0, Notes: chickin.Notes, CreatedBy: actorID, } if err := ProjectFlockPopulationRepotx.CreateOne(c.Context(), population, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to create population for chickin %d", chickin.Id)) } if err := s.ReplenishChickinStocks(c.Context(), dbTransaction, &chickin, sourcePW, population, actorID); err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to replenish stock for chickin %d", chickin.Id)) } } } if action == entity.ApprovalActionRejected { chickins, err := chickinRepoTx.GetByProjectFlockKandangID(c.Context(), approvableID) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get pending chickins for rejection %d", approvableID)) } if len(chickins) == 0 { continue } for _, chickin := range chickins { populationExists, err := ProjectFlockPopulationRepotx.ExistsByProjectChickinID(c.Context(), chickin.Id) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to check population for chickin %d", chickin.Id)) } if populationExists { continue } if chickin.UsageQty <= 0 && chickin.PendingUsageQty <= 0 { continue } if err := s.ReleaseChickinStocks(c.Context(), dbTransaction, &chickin, actorID); err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock for rejected chickin %d: %v", chickin.Id, err)) } touchedProductWarehouseIDs[chickin.ProductWarehouseId] = struct{}{} if err := chickinRepoTx.DeleteOne(c.Context(), chickin.Id); err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to delete rejected chickin %d", chickin.Id)) } } } } } for productWarehouseID := range touchedProductWarehouseIDs { if err := s.syncChickinTraceForProductWarehouse(c.Context(), dbTransaction, productWarehouseID); err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to sync chickin trace for product warehouse %d", productWarehouseID)) } } return nil }) if err != nil { if fiberErr, ok := err.(*fiber.Error); ok { return nil, fiberErr } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record approval") } updated := make([]entity.ProjectChickin, 0) for _, kandangID := range approvableIDs { var chickins []entity.ProjectChickin if err := s.Repository.DB().WithContext(c.Context()).Where("project_flock_kandang_id = ?", kandangID).Find(&chickins).Error; err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load approved chickins") } updated = append(updated, chickins...) } return updated, nil } func (s *chickinService) autoAddFlagToProduct(ctx context.Context, tx *gorm.DB, productID uint, targetFlag utils.FlagType) error { if s.ProductRepo == nil { return nil } currentFlags, err := s.ProductRepo.GetFlags(ctx, productID) if err != nil { return fmt.Errorf("failed to get product flags: %w", err) } hasTargetFlag := false currentFlagNames := make([]string, 0, len(currentFlags)) for _, flag := range currentFlags { currentFlagNames = append(currentFlagNames, flag.Name) if flag.Name == string(targetFlag) { hasTargetFlag = true } } if hasTargetFlag { return nil } newFlags := append(currentFlagNames, string(targetFlag)) if err := s.ProductRepo.SyncFlags(ctx, tx, productID, newFlags); err != nil { return fmt.Errorf("failed to sync flags: %w", err) } return nil } func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, desiredQty float64, actorID uint) error { if chickin == nil { return nil } if tx == nil { return errors.New("transaction is required") } if desiredQty < 0 { return errors.New("desired quantity must be zero or greater") } return s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, desiredQty, 0) } func (s *chickinService) StageChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, desiredQty float64, actorID uint) error { if chickin == nil { return nil } if tx == nil { return errors.New("transaction is required") } if desiredQty < 0 { return errors.New("desired quantity must be zero or greater") } return s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, 0, desiredQty) } func (s *chickinService) ReplenishChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, targetPW *entity.ProductWarehouse, population *entity.ProjectFlockPopulation, actorID uint) error { if chickin == nil || targetPW == nil || population == nil { return nil } if tx == nil { return errors.New("transaction is required") } if s.FifoStockV2Svc == nil { return errors.New("fifo v2 service is not available") } if err := tx.WithContext(ctx). Model(&entity.ProjectFlockPopulation{}). Where("id = ?", population.Id). Update("total_qty", chickin.UsageQty).Error; err != nil { return err } asOf := chickin.ChickInDate 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) } func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, actorID uint) error { if chickin == nil { return nil } 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) 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 { affectedStockables, err := s.listActiveConsumeStockableRefsByUsable(ctx, tx, chickin.Id) if 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 stock_transfer_sources=%d purchase_sources=%d adjustment_sources=%d", chickin.Id, releaseResult.RowsAffected, len(affectedStockables[fifo.StockableKeyTransferToLayingIn.String()]), len(affectedStockables[fifo.StockableKeyStockTransferIn.String()]), len(affectedStockables[fifo.StockableKeyPurchaseItems.String()]), len(affectedStockables[fifo.StockableKeyAdjustmentIn.String()]), ) if err := s.resyncStockableSourceUsageAfterRelease(ctx, tx, affectedStockables); 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) listActiveConsumeStockableRefsByUsable(ctx context.Context, tx *gorm.DB, chickinID uint) (map[string][]uint, error) { result := map[string][]uint{ fifo.StockableKeyTransferToLayingIn.String(): nil, fifo.StockableKeyStockTransferIn.String(): nil, fifo.StockableKeyPurchaseItems.String(): nil, fifo.StockableKeyAdjustmentIn.String(): nil, } if tx == nil || chickinID == 0 { return result, nil } type row struct { StockableType string `gorm:"column:stockable_type"` StockableID uint `gorm:"column:stockable_id"` } var rows []row if err := tx.WithContext(ctx). Table("stock_allocations"). Select("stockable_type, stockable_id"). Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", fifo.UsableKeyProjectChickin.String(), chickinID, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume, ). Where("stockable_type IN ?", []string{ fifo.StockableKeyTransferToLayingIn.String(), fifo.StockableKeyStockTransferIn.String(), fifo.StockableKeyPurchaseItems.String(), fifo.StockableKeyAdjustmentIn.String(), }). Group("stockable_type, stockable_id"). Scan(&rows).Error; err != nil { return nil, err } for _, row := range rows { if row.StockableID == 0 { continue } result[row.StockableType] = append(result[row.StockableType], row.StockableID) } for key, ids := range result { result[key] = uniqueUint(ids) } return result, nil } func (s *chickinService) resyncStockableSourceUsageAfterRelease(ctx context.Context, tx *gorm.DB, stockableRefs map[string][]uint) error { if tx == nil || len(stockableRefs) == 0 { return nil } if err := s.resetAndResyncUsedQuantity( ctx, tx, "laying_transfer_targets", "id", "total_used", fifo.StockableKeyTransferToLayingIn.String(), stockableRefs[fifo.StockableKeyTransferToLayingIn.String()], ); err != nil { return err } if err := s.resetAndResyncUsedQuantity( ctx, tx, "stock_transfer_details", "id", "total_used", fifo.StockableKeyStockTransferIn.String(), stockableRefs[fifo.StockableKeyStockTransferIn.String()], ); err != nil { return err } if err := s.resetAndResyncUsedQuantity( ctx, tx, "purchase_items", "id", "total_used", fifo.StockableKeyPurchaseItems.String(), stockableRefs[fifo.StockableKeyPurchaseItems.String()], ); err != nil { return err } if err := s.resetAndResyncUsedQuantity( ctx, tx, "adjustment_stocks", "id", "total_used", fifo.StockableKeyAdjustmentIn.String(), stockableRefs[fifo.StockableKeyAdjustmentIn.String()], ); err != nil { return err } return nil } func (s *chickinService) resetAndResyncUsedQuantity( ctx context.Context, tx *gorm.DB, tableName string, idColumn string, usedColumn string, stockableType string, ids []uint, ) error { ids = uniqueUint(ids) if tx == nil || len(ids) == 0 { return nil } if err := tx.WithContext(ctx). Table(tableName). Where(fmt.Sprintf("%s IN ?", idColumn), ids). Update(usedColumn, 0).Error; err != nil { return err } query := fmt.Sprintf(` UPDATE %s AS t SET %s = a.used FROM ( SELECT stockable_id, COALESCE(SUM(qty), 0) AS used FROM stock_allocations WHERE stockable_type = ? AND status = ? AND allocation_purpose = ? AND stockable_id IN ? GROUP BY stockable_id ) AS a WHERE t.%s = a.stockable_id `, tableName, usedColumn, idColumn) if err := tx.WithContext(ctx).Exec( query, stockableType, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume, ids, ).Error; err != nil { return err } return nil } func uniqueUint(values []uint) []uint { if len(values) == 0 { return nil } out := make([]uint, 0, len(values)) seen := make(map[uint]struct{}, len(values)) for _, value := range values { if value == 0 { continue } if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} out = append(out, value) } return out } 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 } if s.FifoStockV2Svc == nil { return nil } if tx == nil { return s.Repository.DB().WithContext(ctx).Transaction(func(innerTx *gorm.DB) error { return s.syncChickinTraceForProductWarehouse(ctx, innerTx, productWarehouseID) }) } if err := s.ensurePopulationRouteScope(ctx, tx); err != nil { return err } now := time.Now() if err := tx.WithContext(ctx). Table("stock_allocations"). Where("product_warehouse_id = ?", productWarehouseID). Where("usable_type = ?", fifo.UsableKeyProjectChickin.String()). Where("allocation_purpose = ?", entity.StockAllocationPurposeTraceChickin). Where("status = ?", entity.StockAllocationStatusActive). Updates(map[string]any{ "status": entity.StockAllocationStatusReleased, "released_at": now, "updated_at": now, "note": "chickin_trace_reflow_reset", }).Error; err != nil { 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"` ChickIn time.Time `gorm:"column:chick_in_date"` } chickins := make([]chickinTraceRow, 0) if err := tx.WithContext(ctx). Table("project_chickins"). Select("id, usage_qty, chick_in_date"). Where("product_warehouse_id = ?", productWarehouseID). Where("deleted_at IS NULL"). Where("usage_qty > 0"). Order("chick_in_date ASC, id ASC"). Scan(&chickins).Error; err != nil { return err } if len(chickins) == 0 { return nil } gatherRows, err := s.FifoStockV2Svc.Gather(ctx, commonSvc.FifoStockV2GatherRequest{ FlagGroupCode: flagGroupCode, Lane: "STOCKABLE", ProductWarehouseID: productWarehouseID, Limit: 50000, Tx: tx, }) if err != nil { return err } if len(gatherRows) == 0 { return nil } type lotKey struct { StockableType string StockableID uint } remainingByLot := make(map[lotKey]float64, len(gatherRows)) for _, row := range gatherRows { key := lotKey{StockableType: row.Ref.LegacyTypeKey, StockableID: row.Ref.ID} remainingByLot[key] = row.AvailableQuantity } lotIndex := 0 traceNow := time.Now() for _, chickin := range chickins { remaining := chickin.UsageQty for remaining > 1e-6 && lotIndex < len(gatherRows) { lot := gatherRows[lotIndex] key := lotKey{StockableType: lot.Ref.LegacyTypeKey, StockableID: lot.Ref.ID} available := remainingByLot[key] if available <= 1e-6 { lotIndex++ continue } portion := math.Min(remaining, available) if portion <= 1e-6 { lotIndex++ continue } insert := map[string]any{ "product_warehouse_id": productWarehouseID, "stockable_type": lot.Ref.LegacyTypeKey, "stockable_id": lot.Ref.ID, "usable_type": fifo.UsableKeyProjectChickin.String(), "usable_id": chickin.ID, "qty": portion, "status": entity.StockAllocationStatusActive, "allocation_purpose": entity.StockAllocationPurposeTraceChickin, "engine_version": "v2", "flag_group_code": flagGroupCode, "function_code": "CHICKIN_TRACE", "created_at": traceNow, "updated_at": traceNow, } if err := tx.WithContext(ctx).Table("stock_allocations").Create(insert).Error; err != nil { return err } remaining -= portion remainingByLot[key] = available - portion } if remaining > 1e-6 { s.Log.Warnf( "chickin trace partial allocation for product_warehouse_id=%d chickin_id=%d: remaining=%.3f", productWarehouseID, chickin.ID, remaining, ) } } return nil } func (s *chickinService) resolveFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (string, error) { type row struct { FlagGroupCode string `gorm:"column:flag_group_code"` } selected := row{} err := tx.WithContext(ctx). Table("fifo_stock_v2_route_rules rr"). Select("rr.flag_group_code"). 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 = 'STOCKABLE'"). 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("fg.priority ASC, rr.id ASC"). Limit(1). Take(&selected).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return "", nil } return "", err } return selected.FlagGroupCode, nil } func (s chickinService) EnsureChickInExists(ctx context.Context, projectFlockKandangID uint) error { if projectFlockKandangID == 0 { return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") } populations, err := s.ProjectflockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) if err != nil { s.Log.Errorf("Failed to check project flock population for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err) return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa data chick in") } if len(populations) == 0 { return fiber.NewError(fiber.StatusBadRequest, "Project flock belum memiliki chick in yang disetujui sehingga belum dapat membuat recording") } for _, population := range populations { if population.TotalQty > 0 { return nil } } return fiber.NewError(fiber.StatusBadRequest, "Chick in project flock belum disetujui sehingga belum dapat membuat recording") }