From c9dee7d1c49392d45d87078df8fdf41a743cc1e0 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 17 Mar 2026 11:02:37 +0700 Subject: [PATCH] add paired adjustment triger depletion adjustment --- .../common/service/fifo_stock_v2/allocate.go | 56 +- ...djustment_id_to_adjustment_stocks.down.sql | 14 + ..._adjustment_id_to_adjustment_stocks.up.sql | 86 +++ internal/entities/adjustment_stock.go | 2 + .../adjustment_stock.repository.go | 239 ++++++- .../services/adjustment.service.go | 598 ++++++++---------- .../chickins/services/chickin.service.go | 97 +++ scripts/sql/orphan_allocations_audit.sql | 76 +++ scripts/sql/orphan_allocations_cleanup.sql | 52 ++ 9 files changed, 839 insertions(+), 381 deletions(-) create mode 100644 internal/database/migrations/20260316080836_add_paired_adjustment_id_to_adjustment_stocks.down.sql create mode 100644 internal/database/migrations/20260316080836_add_paired_adjustment_id_to_adjustment_stocks.up.sql create mode 100644 scripts/sql/orphan_allocations_audit.sql create mode 100644 scripts/sql/orphan_allocations_cleanup.sql diff --git a/internal/common/service/fifo_stock_v2/allocate.go b/internal/common/service/fifo_stock_v2/allocate.go index 42d6ff30..e7588286 100644 --- a/internal/common/service/fifo_stock_v2/allocate.go +++ b/internal/common/service/fifo_stock_v2/allocate.go @@ -701,16 +701,17 @@ func (s *fifoStockV2Service) resolveRollbackFlagGroup(ctx context.Context, tx *g FlagGroupCode string `gorm:"column:flag_group_code"` } var latest row - err := tx.WithContext(ctx). + latestQuery := tx.WithContext(ctx). Table("stock_allocations"). Select("flag_group_code"). Where("usable_type = ? AND usable_id = ?", req.Usable.LegacyTypeKey, req.Usable.ID). Where("engine_version = 'v2'"). Where("allocation_purpose = ?", defaultAllocationPurpose()). - Where("flag_group_code IS NOT NULL AND flag_group_code <> ''"). - Order("id DESC"). - Limit(1). - Take(&latest).Error + Where("flag_group_code IS NOT NULL AND flag_group_code <> ''") + if code := strings.TrimSpace(req.Usable.FunctionCode); code != "" { + latestQuery = latestQuery.Where("function_code = ?", code) + } + err := latestQuery.Order("id DESC").Limit(1).Take(&latest).Error if err == nil && strings.TrimSpace(latest.FlagGroupCode) != "" { return latest.FlagGroupCode, nil } @@ -718,19 +719,56 @@ func (s *fifoStockV2Service) resolveRollbackFlagGroup(ctx context.Context, tx *g return "", err } - var rules []routeRule - err = tx.WithContext(ctx). + rulesQuery := tx.WithContext(ctx). Table("fifo_stock_v2_route_rules"). Where("is_active = TRUE"). Where("lane = ?", string(LaneUsable)). - Where("legacy_type_key = ?", req.Usable.LegacyTypeKey). - Find(&rules).Error + Where("legacy_type_key = ?", req.Usable.LegacyTypeKey) + if code := strings.TrimSpace(req.Usable.FunctionCode); code != "" { + rulesQuery = rulesQuery.Where("function_code = ?", code) + } + + var rules []routeRule + err = rulesQuery.Find(&rules).Error if err != nil { return "", err } if len(rules) == 0 { return "", fmt.Errorf("cannot resolve flag group for usable type %s", req.Usable.LegacyTypeKey) } + if len(rules) > 1 && req.ProductWarehouseID != 0 { + type candidateRow struct { + FlagGroupCode string `gorm:"column:flag_group_code"` + } + var candidates []candidateRow + byProductQuery := tx.WithContext(ctx). + Table("fifo_stock_v2_route_rules rr"). + Select("DISTINCT 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 = ?", string(LaneUsable)). + Where("rr.legacy_type_key = ?", req.Usable.LegacyTypeKey). + 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 = 'products' + AND fm.flag_group_code = rr.flag_group_code + ) + `, req.ProductWarehouseID) + if code := strings.TrimSpace(req.Usable.FunctionCode); code != "" { + byProductQuery = byProductQuery.Where("rr.function_code = ?", code) + } + if err := byProductQuery.Order("rr.flag_group_code ASC").Scan(&candidates).Error; err != nil { + return "", err + } + if len(candidates) == 1 { + return strings.TrimSpace(candidates[0].FlagGroupCode), nil + } + } if len(rules) > 1 { return "", fmt.Errorf("ambiguous rollback flag group for usable type %s", req.Usable.LegacyTypeKey) } diff --git a/internal/database/migrations/20260316080836_add_paired_adjustment_id_to_adjustment_stocks.down.sql b/internal/database/migrations/20260316080836_add_paired_adjustment_id_to_adjustment_stocks.down.sql new file mode 100644 index 00000000..1d7db48c --- /dev/null +++ b/internal/database/migrations/20260316080836_add_paired_adjustment_id_to_adjustment_stocks.down.sql @@ -0,0 +1,14 @@ +BEGIN; + +ALTER TABLE adjustment_stocks + DROP CONSTRAINT IF EXISTS chk_adjustment_stocks_paired_not_self; + +ALTER TABLE adjustment_stocks + DROP CONSTRAINT IF EXISTS fk_adjustment_stocks_paired_adjustment_id; + +DROP INDEX IF EXISTS idx_adjustment_stocks_paired_adjustment_id; + +ALTER TABLE adjustment_stocks + DROP COLUMN IF EXISTS paired_adjustment_id; + +COMMIT; diff --git a/internal/database/migrations/20260316080836_add_paired_adjustment_id_to_adjustment_stocks.up.sql b/internal/database/migrations/20260316080836_add_paired_adjustment_id_to_adjustment_stocks.up.sql new file mode 100644 index 00000000..8793b55c --- /dev/null +++ b/internal/database/migrations/20260316080836_add_paired_adjustment_id_to_adjustment_stocks.up.sql @@ -0,0 +1,86 @@ +BEGIN; + +ALTER TABLE adjustment_stocks + ADD COLUMN IF NOT EXISTS paired_adjustment_id BIGINT NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_adjustment_stocks_paired_adjustment_id' + ) THEN + ALTER TABLE adjustment_stocks + ADD CONSTRAINT fk_adjustment_stocks_paired_adjustment_id + FOREIGN KEY (paired_adjustment_id) + REFERENCES adjustment_stocks(id) + ON DELETE SET NULL + ON UPDATE CASCADE; + END IF; +END $$; + +ALTER TABLE adjustment_stocks + DROP CONSTRAINT IF EXISTS chk_adjustment_stocks_paired_not_self; + +ALTER TABLE adjustment_stocks + ADD CONSTRAINT chk_adjustment_stocks_paired_not_self + CHECK (paired_adjustment_id IS NULL OR paired_adjustment_id <> id); + +CREATE INDEX IF NOT EXISTS idx_adjustment_stocks_paired_adjustment_id + ON adjustment_stocks(paired_adjustment_id); + +-- Backfill pairing untuk depletion-out <-> depletion-in existing records. +WITH candidates AS ( + SELECT + src.id AS src_id, + dst.id AS dst_id, + ABS(EXTRACT(EPOCH FROM (dst.created_at - src.created_at))) AS ts_diff, + ABS(dst.id - src.id) AS id_diff, + ROW_NUMBER() OVER ( + PARTITION BY src.id + ORDER BY ABS(EXTRACT(EPOCH FROM (dst.created_at - src.created_at))) ASC, + ABS(dst.id - src.id) ASC, + dst.id ASC + ) AS rn_src, + ROW_NUMBER() OVER ( + PARTITION BY dst.id + ORDER BY ABS(EXTRACT(EPOCH FROM (dst.created_at - src.created_at))) ASC, + ABS(dst.id - src.id) ASC, + src.id ASC + ) AS rn_dst + FROM adjustment_stocks src + JOIN adjustment_stocks dst + ON dst.id <> src.id + AND dst.transaction_type = src.transaction_type + AND dst.function_code = 'RECORDING_DEPLETION_IN' + AND src.function_code = 'RECORDING_DEPLETION_OUT' + AND dst.paired_adjustment_id IS NULL + AND src.paired_adjustment_id IS NULL + AND ABS((COALESCE(src.usage_qty, 0) + COALESCE(src.pending_qty, 0)) - COALESCE(dst.total_qty, 0)) < 0.0001 + AND COALESCE(src.price, 0) = COALESCE(dst.price, 0) + AND COALESCE(src.grand_total, 0) = COALESCE(dst.grand_total, 0) + AND ABS(EXTRACT(EPOCH FROM (dst.created_at - src.created_at))) <= 120 +), +chosen AS ( + SELECT src_id, dst_id + FROM candidates + WHERE rn_src = 1 + AND rn_dst = 1 +) +UPDATE adjustment_stocks src +SET paired_adjustment_id = c.dst_id +FROM chosen c +WHERE src.id = c.src_id + AND src.paired_adjustment_id IS NULL; + +WITH chosen AS ( + SELECT a.id AS src_id, a.paired_adjustment_id AS dst_id + FROM adjustment_stocks a + WHERE a.function_code = 'RECORDING_DEPLETION_OUT' + AND a.paired_adjustment_id IS NOT NULL +) +UPDATE adjustment_stocks dst +SET paired_adjustment_id = c.src_id +FROM chosen c +WHERE dst.id = c.dst_id + AND dst.paired_adjustment_id IS NULL; + +COMMIT; diff --git a/internal/entities/adjustment_stock.go b/internal/entities/adjustment_stock.go index ec3326d8..487784a6 100644 --- a/internal/entities/adjustment_stock.go +++ b/internal/entities/adjustment_stock.go @@ -5,6 +5,7 @@ import "time" type AdjustmentStock struct { Id uint `gorm:"primaryKey"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + PairedAdjustmentId *uint `gorm:"column:paired_adjustment_id"` TransactionType string `gorm:"column:transaction_type;type:varchar(100);not null;default:LEGACY"` FunctionCode string `gorm:"column:function_code;type:varchar(64)"` TotalQty float64 `gorm:"column:total_qty;default:0"` @@ -18,5 +19,6 @@ type AdjustmentStock struct { AdjNumber string `gorm:"column:adj_number;uniqueIndex;not null"` ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` + PairedAdjustment *AdjustmentStock `gorm:"foreignKey:PairedAdjustmentId;references:Id"` StockLog *StockLog `gorm:"polymorphic:Loggable;polymorphicType:LoggableType;polymorphicId:LoggableId;polymorphicValue:ADJUSTMENT"` } diff --git a/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go b/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go index ca3f4ff8..a916be1e 100644 --- a/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go +++ b/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go @@ -2,12 +2,12 @@ package repositories import ( "context" - "errors" "fmt" "strconv" "strings" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -15,9 +15,19 @@ import ( type AdjustmentStockRepository interface { CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.AdjustmentStock, error) + GetByIDForUpdate(ctx context.Context, id uint) (*entity.AdjustmentStock, error) FindKandangIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (uint, error) + FindProductIDByProductWarehouseID(ctx context.Context, productWarehouseID uint) (uint, error) FindRoutesByFunctionCode(ctx context.Context, productID uint, functionCode string) ([]AdjustmentRouteResolution, error) - FindOverconsumeRule(ctx context.Context, lane, flagGroupCode, functionCode string) (*bool, error) + LoadDownstreamDependencies(ctx context.Context, stockableType string, stockableIDs []uint) ([]AdjustmentDownstreamDependency, error) + FindAyamSourceProductWarehouse(ctx context.Context, warehouseID uint, projectFlockKandangID uint) (*entity.ProductWarehouse, error) + IsAyamProduct(ctx context.Context, productID uint) (bool, error) + CountActiveConsumeAllocationsByUsable(ctx context.Context, usableType string, usableID uint) (int64, error) + UpdateTotalQty(ctx context.Context, id uint, qty float64) error + UpdatePairedAdjustmentID(ctx context.Context, id uint, pairedID uint) error + DeleteStockLogsByAdjustmentID(ctx context.Context, adjustmentID uint) error + DeleteAdjustmentByID(ctx context.Context, id uint) error + ResyncProjectFlockPopulationUsage(ctx context.Context, projectFlockKandangID uint) error FindHistory(ctx context.Context, filter AdjustmentHistoryFilter, modifier func(*gorm.DB) *gorm.DB) ([]*entity.AdjustmentStock, int64, error) WithTx(tx *gorm.DB) AdjustmentStockRepository DB() *gorm.DB @@ -44,6 +54,13 @@ type AdjustmentHistoryFilter struct { Limit int } +type AdjustmentDownstreamDependency struct { + UsableType string `gorm:"column:usable_type"` + UsableID uint64 `gorm:"column:usable_id"` + FunctionCode string `gorm:"column:function_code"` + FlagGroupCode string `gorm:"column:flag_group_code"` +} + type adjustmentStockRepositoryImpl struct { db *gorm.DB } @@ -73,6 +90,17 @@ func (r *adjustmentStockRepositoryImpl) GetByID(ctx context.Context, id uint, mo return &record, nil } +func (r *adjustmentStockRepositoryImpl) GetByIDForUpdate(ctx context.Context, id uint) (*entity.AdjustmentStock, error) { + var record entity.AdjustmentStock + if err := r.db.WithContext(ctx). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ?", id). + Take(&record).Error; err != nil { + return nil, err + } + return &record, nil +} + func (r *adjustmentStockRepositoryImpl) FindKandangIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (uint, error) { type pfkRow struct { KandangID uint `gorm:"column:kandang_id"` @@ -91,6 +119,21 @@ func (r *adjustmentStockRepositoryImpl) FindKandangIDByProjectFlockKandangID(ctx return pfk.KandangID, nil } +func (r *adjustmentStockRepositoryImpl) FindProductIDByProductWarehouseID(ctx context.Context, productWarehouseID uint) (uint, error) { + type productRow struct { + ProductID uint `gorm:"column:product_id"` + } + var row productRow + if err := r.db.WithContext(ctx). + Table("product_warehouses"). + Select("product_id"). + Where("id = ?", productWarehouseID). + Take(&row).Error; err != nil { + return 0, err + } + return row.ProductID, nil +} + func (r *adjustmentStockRepositoryImpl) FindRoutesByFunctionCode( ctx context.Context, productID uint, @@ -122,37 +165,183 @@ func (r *adjustmentStockRepositoryImpl) FindRoutesByFunctionCode( return rows, nil } -func (r *adjustmentStockRepositoryImpl) FindOverconsumeRule( +func (r *adjustmentStockRepositoryImpl) LoadDownstreamDependencies( ctx context.Context, - lane string, - flagGroupCode string, - functionCode string, -) (*bool, error) { - type selectedRow struct { - AllowOverconsume bool `gorm:"column:allow_overconsume"` + stockableType string, + stockableIDs []uint, +) ([]AdjustmentDownstreamDependency, error) { + if strings.TrimSpace(stockableType) == "" || len(stockableIDs) == 0 { + return nil, nil } - var selected selectedRow + var rows []AdjustmentDownstreamDependency err := r.db.WithContext(ctx). - Table("fifo_stock_v2_overconsume_rules"). - Select("allow_overconsume"). - Where("is_active = TRUE"). - Where("lane = ?", lane). - Where("(flag_group_code IS NULL OR flag_group_code = ?)", flagGroupCode). - Where("(function_code IS NULL OR function_code = ?)", functionCode). - Order("CASE WHEN flag_group_code IS NULL THEN 1 ELSE 0 END ASC"). - Order("CASE WHEN function_code IS NULL THEN 1 ELSE 0 END ASC"). - Order("priority ASC, id ASC"). - Limit(1). - Take(&selected).Error + Table("stock_allocations"). + Select("usable_type, usable_id, COALESCE(function_code,'') AS function_code, COALESCE(flag_group_code,'') AS flag_group_code"). + Where("stockable_type = ?", strings.ToUpper(strings.TrimSpace(stockableType))). + Where("stockable_id IN ?", stockableIDs). + Where("status = ?", entity.StockAllocationStatusActive). + Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). + Where("deleted_at IS NULL"). + Where( + "(usable_type <> ? OR EXISTS (SELECT 1 FROM project_chickins pc WHERE pc.id = stock_allocations.usable_id AND pc.deleted_at IS NULL))", + "PROJECT_CHICKIN", + ). + Group("usable_type, usable_id, function_code, flag_group_code"). + Scan(&rows).Error if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } return nil, err } - return &selected.AllowOverconsume, nil + return rows, nil +} + +func (r *adjustmentStockRepositoryImpl) FindAyamSourceProductWarehouse( + ctx context.Context, + warehouseID uint, + projectFlockKandangID uint, +) (*entity.ProductWarehouse, error) { + var sourcePW entity.ProductWarehouse + err := r.db.WithContext(ctx). + Model(&entity.ProductWarehouse{}). + Where("project_flock_kandang_id = ?", projectFlockKandangID). + Where(` + EXISTS ( + SELECT 1 + FROM flags f + JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE + WHERE f.flagable_type = ? + AND f.flagable_id = product_warehouses.product_id + AND fm.flag_group_code = ? + ) + `, entity.FlagableTypeProduct, "AYAM"). + Order(gorm.Expr("CASE WHEN warehouse_id = ? THEN 0 ELSE 1 END ASC", warehouseID)). + Order("id ASC"). + Take(&sourcePW).Error + if err != nil { + return nil, err + } + return &sourcePW, nil +} + +func (r *adjustmentStockRepositoryImpl) IsAyamProduct(ctx context.Context, productID uint) (bool, error) { + if productID == 0 { + return false, nil + } + + var count int64 + if err := r.db.WithContext(ctx). + Table("flags f"). + Joins("JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.flag_group_code = ? AND fm.is_active = TRUE", "AYAM"). + Where("f.flagable_type = ?", entity.FlagableTypeProduct). + Where("f.flagable_id = ?", productID). + Count(&count).Error; err != nil { + return false, err + } + + return count > 0, nil +} + +func (r *adjustmentStockRepositoryImpl) CountActiveConsumeAllocationsByUsable( + ctx context.Context, + usableType string, + usableID uint, +) (int64, error) { + if strings.TrimSpace(usableType) == "" || usableID == 0 { + return 0, nil + } + + var count int64 + err := r.db.WithContext(ctx). + Table("stock_allocations"). + Where("usable_type = ?", strings.ToUpper(strings.TrimSpace(usableType))). + Where("usable_id = ?", usableID). + Where("status = ?", entity.StockAllocationStatusActive). + Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). + Where("deleted_at IS NULL"). + Count(&count).Error + if err != nil { + return 0, err + } + + return count, nil +} + +func (r *adjustmentStockRepositoryImpl) UpdateTotalQty(ctx context.Context, id uint, qty float64) error { + return r.db.WithContext(ctx). + Model(&entity.AdjustmentStock{}). + Where("id = ?", id). + Update("total_qty", qty).Error +} + +func (r *adjustmentStockRepositoryImpl) UpdatePairedAdjustmentID(ctx context.Context, id uint, pairedID uint) error { + return r.db.WithContext(ctx). + Model(&entity.AdjustmentStock{}). + Where("id = ?", id). + Update("paired_adjustment_id", pairedID).Error +} + +func (r *adjustmentStockRepositoryImpl) DeleteStockLogsByAdjustmentID(ctx context.Context, adjustmentID uint) error { + return r.db.WithContext(ctx). + Where("loggable_type = ? AND loggable_id = ?", string(utils.StockLogTypeAdjustment), adjustmentID). + Delete(&entity.StockLog{}).Error +} + +func (r *adjustmentStockRepositoryImpl) DeleteAdjustmentByID(ctx context.Context, id uint) error { + return r.db.WithContext(ctx). + Where("id = ?", id). + Delete(&entity.AdjustmentStock{}).Error +} + +func (r *adjustmentStockRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context.Context, projectFlockKandangID uint) error { + if projectFlockKandangID == 0 { + return nil + } + + idsSubquery := ` + SELECT pfp.id + FROM project_flock_populations pfp + JOIN project_chickins pc ON pc.id = pfp.project_chickin_id + WHERE pc.project_flock_kandang_id = ? + ` + + updateWithAlloc := ` + UPDATE project_flock_populations p + SET total_used_qty = COALESCE(a.used, 0) + FROM ( + SELECT stockable_id, SUM(qty) AS used + FROM stock_allocations + WHERE stockable_type = 'PROJECT_FLOCK_POPULATION' + AND status = 'ACTIVE' + AND allocation_purpose = 'CONSUME' + GROUP BY stockable_id + ) a + WHERE p.id = a.stockable_id + AND p.id IN (` + idsSubquery + `) + ` + + resetMissing := ` + UPDATE project_flock_populations p + SET total_used_qty = 0 + WHERE p.id IN (` + idsSubquery + `) + AND NOT EXISTS ( + SELECT 1 + FROM stock_allocations sa + WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION' + AND sa.status = 'ACTIVE' + AND sa.allocation_purpose = 'CONSUME' + AND sa.stockable_id = p.id + ) + ` + + db := r.db.WithContext(ctx) + if err := db.Exec(updateWithAlloc, projectFlockKandangID).Error; err != nil { + return err + } + if err := db.Exec(resetMissing, projectFlockKandangID).Error; err != nil { + return err + } + return nil } func (r *adjustmentStockRepositoryImpl) FindHistory( diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 753cee3c..bfd0d767 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -25,7 +25,6 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gorm.io/gorm" - "gorm.io/gorm/clause" ) type AdjustmentService interface { @@ -51,16 +50,8 @@ type adjustmentService struct { const ( adjustmentLaneStockable = "STOCKABLE" adjustmentLaneUsable = "USABLE" - flagGroupAyam = "AYAM" ) -type adjustmentDownstreamDependency struct { - UsableType string `gorm:"column:usable_type"` - UsableID uint64 `gorm:"column:usable_id"` - FunctionCode string `gorm:"column:function_code"` - FlagGroupCode string `gorm:"column:flag_group_code"` -} - func NewAdjustmentService( productRepo productRepo.ProductRepository, stockLogsRepo stockLogsRepo.StockLogRepository, @@ -86,23 +77,21 @@ func NewAdjustmentService( } } -func (s *adjustmentService) withRelations(db *gorm.DB) *gorm.DB { - return db. - Preload("ProductWarehouse"). - Preload("ProductWarehouse.Product"). - Preload("ProductWarehouse.Warehouse"). - Preload("ProductWarehouse.Warehouse.Location"). - Preload("ProductWarehouse.ProjectFlockKandang"). - Preload("ProductWarehouse.ProjectFlockKandang.ProjectFlock"). - Preload("StockLog.CreatedUser") -} - func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) { if err := m.EnsureStockLogAccess(c, s.StockLogsRepository.DB(), id); err != nil { return nil, err } - adjustmentStock, err := s.AdjustmentStockRepository.GetByID(c.Context(), id, s.withRelations) + adjustmentStock, err := s.AdjustmentStockRepository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + return db. + Preload("ProductWarehouse"). + Preload("ProductWarehouse.Product"). + Preload("ProductWarehouse.Warehouse"). + Preload("ProductWarehouse.Warehouse.Location"). + Preload("ProductWarehouse.ProjectFlockKandang"). + Preload("ProductWarehouse.ProjectFlockKandang.ProjectFlock"). + Preload("StockLog.CreatedUser") + }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found") @@ -132,154 +121,232 @@ func (s *adjustmentService) DeleteOne(c *fiber.Ctx, id uint) error { } return s.StockLogsRepository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var adjustment entity.AdjustmentStock - if err := tx.WithContext(ctx). - Clauses(clause.Locking{Strength: "UPDATE"}). - Where("id = ?", id). - Take(&adjustment).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Adjustment not found") - } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to load adjustment") - } - - type productRow struct { - ProductID uint `gorm:"column:product_id"` - } - var prod productRow - if err := tx.WithContext(ctx). - Table("product_warehouses"). - Select("product_id"). - Where("id = ?", adjustment.ProductWarehouseId). - Take(&prod).Error; err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to load product warehouse context") - } - - routeMeta, err := s.resolveRouteByFunctionCode(ctx, prod.ProductID, adjustment.FunctionCode) + adjustments, err := s.collectAdjustmentsForDelete(ctx, tx, id) if err != nil { return err } - isAyamProduct, err := s.isAyamProduct(ctx, tx, prod.ProductID) - if err != nil { - s.Log.Errorf("Failed to resolve AYAM flag for product %d: %+v", prod.ProductID, err) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product flag") - } - - stockLogRepoTx := stockLogsRepo.NewStockLogRepository(tx) - notes := fmt.Sprintf("ADJUSTMENT DELETE#%s", strings.TrimSpace(adjustment.AdjNumber)) - - switch routeMeta.Lane { - case adjustmentLaneStockable: - deps, allowPending, err := s.resolveAdjustmentDependenciesAndPolicy( - ctx, - tx, - fifo.StockableKeyAdjustmentIn.String(), - []uint{adjustment.Id}, - ) - if err != nil { + for _, item := range adjustments { + if err := s.deleteSingleAdjustmentInTx(ctx, tx, item, actorID); err != nil { return err } - if len(deps) > 0 && isAyamProduct { - return fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf( - "Adjustment tidak dapat dihapus karena produk AYAM sudah dipakai transaksi turunan. Dependensi aktif: %s. Alasan block: produk AYAM yang sudah terpakai tidak dapat dihapus.", - formatAdjustmentDependencySummary(deps), - ), - ) - } - if len(deps) > 0 && !allowPending { - return fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf( - "Adjustment tidak dapat dihapus karena stok adjustment sudah dipakai transaksi turunan. Dependensi aktif: %s. Alasan block: pending disabled by config.", - formatAdjustmentDependencySummary(deps), - ), - ) - } - - oldQty := adjustment.TotalQty - if oldQty > 0 { - if err := tx.WithContext(ctx). - Model(&entity.AdjustmentStock{}). - Where("id = ?", adjustment.Id). - Update("total_qty", 0).Error; err != nil { - return err - } - asOf := adjustment.CreatedAt - if _, err := s.FifoStockV2Svc.Reflow(ctx, common.FifoStockV2ReflowRequest{ - FlagGroupCode: routeMeta.FlagGroupCode, - ProductWarehouseID: adjustment.ProductWarehouseId, - AsOf: &asOf, - Tx: tx, - }); err != nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to reflow stock via FIFO v2: %v", err)) - } - if err := s.createAdjustmentStockLog( - ctx, - stockLogRepoTx, - adjustment.Id, - adjustment.ProductWarehouseId, - notes, - actorID, - 0, - oldQty, - ); err != nil { - return err - } - } - case adjustmentLaneUsable: - rollbackRes, err := s.FifoStockV2Svc.Rollback(ctx, common.FifoStockV2RollbackRequest{ - ProductWarehouseID: adjustment.ProductWarehouseId, - Usable: common.FifoStockV2Ref{ - ID: adjustment.Id, - LegacyTypeKey: routeMeta.LegacyTypeKey, - FunctionCode: routeMeta.FunctionCode, - }, - Reason: notes, - Tx: tx, - }) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to rollback FIFO v2 adjustment: %v", err)) - } - - releasedQty := 0.0 - if rollbackRes != nil { - releasedQty = rollbackRes.ReleasedQty - } - if releasedQty > 0 { - if err := s.createAdjustmentStockLog( - ctx, - stockLogRepoTx, - adjustment.Id, - adjustment.ProductWarehouseId, - notes, - actorID, - releasedQty, - 0, - ); err != nil { - return err - } - } - default: - return fiber.NewError(fiber.StatusBadRequest, "Unsupported adjustment lane") } - - if err := tx.WithContext(ctx). - Where("loggable_type = ? AND loggable_id = ?", string(utils.StockLogTypeAdjustment), adjustment.Id). - Delete(&entity.StockLog{}).Error; err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete adjustment stock logs") - } - if err := tx.WithContext(ctx). - Where("id = ?", adjustment.Id). - Delete(&entity.AdjustmentStock{}).Error; err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete adjustment") - } - return nil }) } +func (s *adjustmentService) collectAdjustmentsForDelete(ctx context.Context, tx *gorm.DB, id uint) ([]entity.AdjustmentStock, error) { + repoTx := s.AdjustmentStockRepository.WithTx(tx) + adjustment, err := repoTx.GetByIDForUpdate(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load adjustment") + } + + adjustments := []entity.AdjustmentStock{*adjustment} + leftPairCode := utils.NormalizeUpper(adjustment.FunctionCode) + isDepletionCode := leftPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn) || + leftPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut) + if !isDepletionCode { + return adjustments, nil + } + if adjustment.PairedAdjustmentId == nil || *adjustment.PairedAdjustmentId == 0 { + return nil, fiber.NewError( + fiber.StatusBadRequest, + "Adjustment depletion tidak memiliki pasangan valid. Data harus diperbaiki terlebih dahulu untuk mencegah orphan.", + ) + } + + pair, err := repoTx.GetByIDForUpdate(ctx, *adjustment.PairedAdjustmentId) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Pasangan adjustment depletion (%d) tidak ditemukan. Data harus diperbaiki terlebih dahulu untuk mencegah orphan.", *adjustment.PairedAdjustmentId), + ) + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load paired adjustment") + } + rightPairCode := utils.NormalizeUpper(pair.FunctionCode) + isPairDepletionCode := rightPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn) || + rightPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut) + if !isPairDepletionCode { + return nil, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Pasangan adjustment %d bukan depletion pair yang valid", pair.Id), + ) + } + if pair.PairedAdjustmentId == nil || *pair.PairedAdjustmentId != adjustment.Id { + return nil, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Pasangan adjustment depletion tidak konsisten (%d <-> %d). Perbaiki pairing terlebih dahulu.", adjustment.Id, pair.Id), + ) + } + isValidPair := (leftPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn) && + rightPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut)) || + (leftPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut) && + rightPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn)) + if !isValidPair { + return nil, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Pasangan function_code depletion tidak valid (%s <-> %s)", adjustment.FunctionCode, pair.FunctionCode), + ) + } + + adjustments = append(adjustments, *pair) + sort.Slice(adjustments, func(i, j int) bool { + return adjustments[i].Id < adjustments[j].Id + }) + return adjustments, nil +} + +func (s *adjustmentService) deleteSingleAdjustmentInTx( + ctx context.Context, + tx *gorm.DB, + adjustment entity.AdjustmentStock, + actorID uint, +) error { + repoTx := s.AdjustmentStockRepository.WithTx(tx) + productID, err := repoTx.FindProductIDByProductWarehouseID(ctx, adjustment.ProductWarehouseId) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to load product warehouse context") + } + + routeMeta, err := s.resolveRouteByFunctionCode(ctx, productID, adjustment.FunctionCode) + if err != nil { + return err + } + isAyamProduct, err := repoTx.IsAyamProduct(ctx, productID) + if err != nil { + s.Log.Errorf("Failed to resolve AYAM flag for product %d: %+v", productID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product flag") + } + + stockLogRepoTx := stockLogsRepo.NewStockLogRepository(tx) + notes := fmt.Sprintf("ADJUSTMENT DELETE#%s", utils.NormalizeTrim(adjustment.AdjNumber)) + + switch routeMeta.Lane { + case adjustmentLaneStockable: + deps, allowPending, err := s.resolveAdjustmentDependenciesAndPolicy( + ctx, + tx, + fifo.StockableKeyAdjustmentIn.String(), + []uint{adjustment.Id}, + ) + if err != nil { + return err + } + if len(deps) > 0 && isAyamProduct { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf( + "Adjustment tidak dapat dihapus karena produk AYAM sudah dipakai transaksi turunan. Dependensi aktif: %s. Alasan block: produk AYAM yang sudah terpakai tidak dapat dihapus.", + formatAdjustmentDependencySummary(deps), + ), + ) + } + if len(deps) > 0 && !allowPending { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf( + "Adjustment tidak dapat dihapus karena stok adjustment sudah dipakai transaksi turunan. Dependensi aktif: %s. Alasan block: pending disabled by config.", + formatAdjustmentDependencySummary(deps), + ), + ) + } + + oldQty := adjustment.TotalQty + if oldQty > 0 { + if err := repoTx.UpdateTotalQty(ctx, adjustment.Id, 0); err != nil { + return err + } + asOf := adjustment.CreatedAt + if _, err := s.FifoStockV2Svc.Reflow(ctx, common.FifoStockV2ReflowRequest{ + FlagGroupCode: routeMeta.FlagGroupCode, + ProductWarehouseID: adjustment.ProductWarehouseId, + AsOf: &asOf, + Tx: tx, + }); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to reflow stock via FIFO v2: %v", err)) + } + if err := s.createAdjustmentStockLog( + ctx, + stockLogRepoTx, + adjustment.Id, + adjustment.ProductWarehouseId, + notes, + actorID, + 0, + oldQty, + ); err != nil { + return err + } + } + case adjustmentLaneUsable: + activeBeforeRollback, err := repoTx.CountActiveConsumeAllocationsByUsable(ctx, fifo.UsableKeyAdjustmentOut.String(), adjustment.Id) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate adjustment allocations before rollback") + } + rollbackRes, err := s.FifoStockV2Svc.Rollback(ctx, common.FifoStockV2RollbackRequest{ + ProductWarehouseID: adjustment.ProductWarehouseId, + Usable: common.FifoStockV2Ref{ + ID: adjustment.Id, + LegacyTypeKey: fifo.UsableKeyAdjustmentOut.String(), + }, + Reason: notes, + Tx: tx, + }) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to rollback FIFO v2 adjustment: %v", err)) + } + activeAfterRollback, err := repoTx.CountActiveConsumeAllocationsByUsable(ctx, fifo.UsableKeyAdjustmentOut.String(), adjustment.Id) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate adjustment allocations after rollback") + } + if activeAfterRollback > 0 { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf( + "Adjustment tidak dapat dihapus karena masih ada alokasi aktif ADJUSTMENT_OUT=%d (sebelum rollback=%d, sesudah rollback=%d).", + adjustment.Id, + activeBeforeRollback, + activeAfterRollback, + ), + ) + } + + releasedQty := 0.0 + if rollbackRes != nil { + releasedQty = rollbackRes.ReleasedQty + } + if releasedQty > 0 { + if err := s.createAdjustmentStockLog( + ctx, + stockLogRepoTx, + adjustment.Id, + adjustment.ProductWarehouseId, + notes, + actorID, + releasedQty, + 0, + ); err != nil { + return err + } + } + default: + return fiber.NewError(fiber.StatusBadRequest, "Unsupported adjustment lane") + } + + if err := repoTx.DeleteStockLogsByAdjustmentID(ctx, adjustment.Id); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete adjustment stock logs") + } + if err := repoTx.DeleteAdjustmentByID(ctx, adjustment.Id); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete adjustment") + } + return nil +} + func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error) { if err := s.Validate.Struct(req); err != nil { return nil, err @@ -298,12 +365,12 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero") } - functionCode := strings.ToUpper(strings.TrimSpace(req.TransactionSubtype)) + functionCode := utils.NormalizeUpper(req.TransactionSubtype) if functionCode == "" { - functionCode = strings.ToUpper(strings.TrimSpace(req.TransactionSubType)) + functionCode = utils.NormalizeUpper(req.TransactionSubType) } if functionCode == "" { - functionCode = strings.ToUpper(strings.TrimSpace(req.FunctionCode)) + functionCode = utils.NormalizeUpper(req.FunctionCode) } if functionCode == "" { return nil, fiber.NewError(fiber.StatusBadRequest, "Transaction subtype is required") @@ -320,9 +387,9 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return nil, err } - note := strings.TrimSpace(req.Notes) + note := utils.NormalizeTrim(req.Notes) if note == "" { - note = strings.TrimSpace(req.Note) + note = utils.NormalizeTrim(req.Note) } grandTotal := math.Round((qty*req.Price)*1000) / 1000 @@ -404,8 +471,11 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available") } - sourcePW, err := s.resolveAyamSourceProductWarehouse(ctx, tx, warehouseID, *projectFlockKandangID) + sourcePW, err := adjustmentStockRepoTX.FindAyamSourceProductWarehouse(ctx, warehouseID, *projectFlockKandangID) if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, "Produk sumber AYAM pada project flock kandang yang sama tidak ditemukan") + } return err } if err := common.EnsureProjectFlockNotClosedForProductWarehouses( @@ -461,6 +531,14 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e if err := adjustmentStockRepoTX.CreateOne(ctx, destinationAdjustment, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create depletion destination adjustment stock record") } + if err := adjustmentStockRepoTX.UpdatePairedAdjustmentID(ctx, sourceAdjustment.Id, destinationAdjustment.Id); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to link depletion source adjustment pair") + } + if err := adjustmentStockRepoTX.UpdatePairedAdjustmentID(ctx, destinationAdjustment.Id, sourceAdjustment.Id); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to link depletion destination adjustment pair") + } + sourceAdjustment.PairedAdjustmentId = &destinationAdjustment.Id + destinationAdjustment.PairedAdjustmentId = &sourceAdjustment.Id sourceAsOf := sourceAdjustment.CreatedAt if _, err := s.FifoStockV2Svc.Reflow(ctx, common.FifoStockV2ReflowRequest{ @@ -502,7 +580,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e ); err != nil { return err } - if err := s.resyncProjectFlockPopulationUsage(ctx, tx, *projectFlockKandangID); err != nil { + if err := adjustmentStockRepoTX.ResyncProjectFlockPopulationUsage(ctx, *projectFlockKandangID); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to resync project flock population usage") } } @@ -678,38 +756,13 @@ func (s *adjustmentService) resolveRouteByFunctionCode( } } -func (s *adjustmentService) resolveOverconsumePolicy( - ctx context.Context, - route *adjustmentStockRepo.AdjustmentRouteResolution, -) (bool, error) { - if route == nil { - return false, fmt.Errorf("route is required") - } - - defaultValue := route.AllowPendingDefault - selected, err := s.AdjustmentStockRepository.FindOverconsumeRule( - ctx, - route.Lane, - route.FlagGroupCode, - route.FunctionCode, - ) - if err != nil { - return false, err - } - if selected == nil { - return defaultValue, nil - } - - return *selected, nil -} - func (s *adjustmentService) resolveAdjustmentDependenciesAndPolicy( ctx context.Context, tx *gorm.DB, stockableType string, stockableIDs []uint, -) ([]adjustmentDownstreamDependency, bool, error) { - deps, err := s.loadAdjustmentDownstreamDependencies(ctx, tx, stockableType, stockableIDs) +) ([]adjustmentStockRepo.AdjustmentDownstreamDependency, bool, error) { + deps, err := s.AdjustmentStockRepository.WithTx(tx).LoadDownstreamDependencies(ctx, stockableType, stockableIDs) if err != nil { s.Log.Errorf("Failed to load downstream adjustment dependencies: %+v", err) return nil, false, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate downstream adjustment dependencies") @@ -739,50 +792,14 @@ func (s *adjustmentService) resolveAdjustmentDependenciesAndPolicy( return deps, allowPending, nil } -func (s *adjustmentService) loadAdjustmentDownstreamDependencies( - ctx context.Context, - tx *gorm.DB, - stockableType string, - stockableIDs []uint, -) ([]adjustmentDownstreamDependency, error) { - if strings.TrimSpace(stockableType) == "" || len(stockableIDs) == 0 { - return nil, nil - } - - db := s.AdjustmentStockRepository.DB().WithContext(ctx) - if tx != nil { - db = tx.WithContext(ctx) - } - - var rows []adjustmentDownstreamDependency - err := db.Table("stock_allocations"). - Select("usable_type, usable_id, COALESCE(function_code,'') AS function_code, COALESCE(flag_group_code,'') AS flag_group_code"). - Where("stockable_type = ?", strings.ToUpper(strings.TrimSpace(stockableType))). - Where("stockable_id IN ?", stockableIDs). - Where("status = ?", entity.StockAllocationStatusActive). - Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). - Where("deleted_at IS NULL"). - Where( - "(usable_type <> ? OR EXISTS (SELECT 1 FROM project_chickins pc WHERE pc.id = stock_allocations.usable_id AND pc.deleted_at IS NULL))", - fifo.UsableKeyProjectChickin.String(), - ). - Group("usable_type, usable_id, function_code, flag_group_code"). - Scan(&rows).Error - if err != nil { - return nil, err - } - - return rows, nil -} - -func formatAdjustmentDependencySummary(rows []adjustmentDownstreamDependency) string { +func formatAdjustmentDependencySummary(rows []adjustmentStockRepo.AdjustmentDownstreamDependency) string { if len(rows) == 0 { return "-" } grouped := make(map[string]map[uint64]struct{}) for _, row := range rows { - label := strings.ToUpper(strings.TrimSpace(row.UsableType)) + label := utils.NormalizeUpper(row.UsableType) if label == "" { label = "UNKNOWN" } @@ -841,68 +858,6 @@ func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context, return uint(projectFlockKandang.Id), nil } -func (s *adjustmentService) resolveAyamSourceProductWarehouse( - ctx context.Context, - tx *gorm.DB, - warehouseID uint, - projectFlockKandangID uint, -) (*entity.ProductWarehouse, error) { - if tx == nil { - return nil, fmt.Errorf("transaction is required") - } - if projectFlockKandangID == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id tidak valid untuk depletion conversion") - } - - var sourcePW entity.ProductWarehouse - err := tx.WithContext(ctx). - Model(&entity.ProductWarehouse{}). - Where("project_flock_kandang_id = ?", projectFlockKandangID). - Where(` - EXISTS ( - SELECT 1 - FROM flags f - JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE - WHERE f.flagable_type = ? - AND f.flagable_id = product_warehouses.product_id - AND fm.flag_group_code = ? - ) - `, entity.FlagableTypeProduct, flagGroupAyam). - Order(gorm.Expr("CASE WHEN warehouse_id = ? THEN 0 ELSE 1 END ASC", warehouseID)). - Order("id ASC"). - Take(&sourcePW).Error - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusBadRequest, "Produk sumber AYAM pada project flock kandang yang sama tidak ditemukan") - } - return nil, err - } - - return &sourcePW, nil -} - -func (s *adjustmentService) isAyamProduct(ctx context.Context, tx *gorm.DB, productID uint) (bool, error) { - if productID == 0 { - return false, nil - } - - db := s.AdjustmentStockRepository.DB().WithContext(ctx) - if tx != nil { - db = tx.WithContext(ctx) - } - - var count int64 - if err := db.Table("flags f"). - Joins("JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.flag_group_code = ? AND fm.is_active = TRUE", flagGroupAyam). - Where("f.flagable_type = ?", entity.FlagableTypeProduct). - Where("f.flagable_id = ?", productID). - Count(&count).Error; err != nil { - return false, err - } - - return count > 0, nil -} - func (s *adjustmentService) createAdjustmentStockLog( ctx context.Context, stockLogRepo stockLogsRepo.StockLogRepository, @@ -986,57 +941,6 @@ func (s *adjustmentService) allocatePopulationForDepletionAdjustment( ) } -func (s *adjustmentService) resyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error { - if tx == nil || projectFlockKandangID == 0 { - return nil - } - - idsSubquery := ` - SELECT pfp.id - FROM project_flock_populations pfp - JOIN project_chickins pc ON pc.id = pfp.project_chickin_id - WHERE pc.project_flock_kandang_id = ? - ` - - updateWithAlloc := ` - UPDATE project_flock_populations p - SET total_used_qty = COALESCE(a.used, 0) - FROM ( - SELECT stockable_id, SUM(qty) AS used - FROM stock_allocations - WHERE stockable_type = 'PROJECT_FLOCK_POPULATION' - AND status = 'ACTIVE' - AND allocation_purpose = 'CONSUME' - GROUP BY stockable_id - ) a - WHERE p.id = a.stockable_id - AND p.id IN (` + idsSubquery + `) - ` - - resetMissing := ` - UPDATE project_flock_populations p - SET total_used_qty = 0 - WHERE p.id IN (` + idsSubquery + `) - AND NOT EXISTS ( - SELECT 1 - FROM stock_allocations sa - WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION' - AND sa.status = 'ACTIVE' - AND sa.allocation_purpose = 'CONSUME' - AND sa.stockable_id = p.id - ) - ` - - db := tx.WithContext(ctx) - if err := db.Exec(updateWithAlloc, projectFlockKandangID).Error; err != nil { - return err - } - if err := db.Exec(resetMissing, projectFlockKandangID).Error; err != nil { - return err - } - return nil -} - func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error) { if err := s.Validate.Struct(query); err != nil { return nil, 0, err @@ -1079,11 +983,11 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu } } - functionCode := strings.ToUpper(strings.TrimSpace(query.TransactionSubtype)) + functionCode := utils.NormalizeUpper(query.TransactionSubtype) if functionCode == "" { - functionCode = strings.ToUpper(strings.TrimSpace(query.FunctionCode)) + functionCode = utils.NormalizeUpper(query.FunctionCode) } - transactionType := strings.ToUpper(strings.TrimSpace(query.TransactionType)) + transactionType := utils.NormalizeUpper(query.TransactionType) adjustmentStocks, total, err := s.AdjustmentStockRepository.FindHistory( c.Context(), diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 96957f66..3a342646 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -725,8 +725,21 @@ FROM ( 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{}{} @@ -740,6 +753,24 @@ FROM ( 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 { @@ -766,6 +797,72 @@ FROM ( 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 diff --git a/scripts/sql/orphan_allocations_audit.sql b/scripts/sql/orphan_allocations_audit.sql new file mode 100644 index 00000000..4ab48b65 --- /dev/null +++ b/scripts/sql/orphan_allocations_audit.sql @@ -0,0 +1,76 @@ +-- Audit orphan stock_allocations (ACTIVE + CONSUME) +-- Usage: +-- psql -U app_lti_user -d db_lti_erp -f scripts/sql/orphan_allocations_audit.sql + +\pset pager off + +WITH active_alloc AS ( + SELECT id, usable_type, usable_id, stockable_type, stockable_id, product_warehouse_id, qty + FROM stock_allocations + WHERE status = 'ACTIVE' + AND allocation_purpose = 'CONSUME' + AND deleted_at IS NULL +), +orphan AS ( + SELECT a.* + FROM active_alloc a + WHERE + (a.usable_type = 'ADJUSTMENT_OUT' AND NOT EXISTS (SELECT 1 FROM adjustment_stocks ad WHERE ad.id = a.usable_id)) + OR (a.usable_type = 'MARKETING_DELIVERY' AND NOT EXISTS (SELECT 1 FROM marketing_delivery_products mdp WHERE mdp.id = a.usable_id)) + OR (a.usable_type = 'RECORDING_STOCK' AND NOT EXISTS ( + SELECT 1 FROM recording_stocks rs JOIN recordings r ON r.id = rs.recording_id + WHERE rs.id = a.usable_id AND r.deleted_at IS NULL + )) + OR (a.usable_type = 'RECORDING_DEPLETION' AND NOT EXISTS ( + SELECT 1 FROM recording_depletions rd JOIN recordings r ON r.id = rd.recording_id + WHERE rd.id = a.usable_id AND r.deleted_at IS NULL + )) + OR (a.usable_type = 'STOCKTRANSFER_OUT' AND NOT EXISTS ( + SELECT 1 FROM stock_transfer_details std + JOIN stock_transfers st ON st.id = std.stock_transfer_id + WHERE std.id = a.usable_id AND std.deleted_at IS NULL AND st.deleted_at IS NULL + )) + OR (a.usable_type = 'TRANSFERTOLAYING_OUT' AND NOT EXISTS ( + SELECT 1 FROM laying_transfers lt WHERE lt.id = a.usable_id AND lt.deleted_at IS NULL + )) +) +SELECT usable_type, COUNT(*) AS rows, COALESCE(SUM(qty),0) AS total_qty +FROM orphan +GROUP BY usable_type +ORDER BY usable_type; + +-- Detail rows (limit) +WITH active_alloc AS ( + SELECT id, usable_type, usable_id, stockable_type, stockable_id, product_warehouse_id, qty + FROM stock_allocations + WHERE status = 'ACTIVE' + AND allocation_purpose = 'CONSUME' + AND deleted_at IS NULL +), +orphan AS ( + SELECT a.* + FROM active_alloc a + WHERE + (a.usable_type = 'ADJUSTMENT_OUT' AND NOT EXISTS (SELECT 1 FROM adjustment_stocks ad WHERE ad.id = a.usable_id)) + OR (a.usable_type = 'MARKETING_DELIVERY' AND NOT EXISTS (SELECT 1 FROM marketing_delivery_products mdp WHERE mdp.id = a.usable_id)) + OR (a.usable_type = 'RECORDING_STOCK' AND NOT EXISTS ( + SELECT 1 FROM recording_stocks rs JOIN recordings r ON r.id = rs.recording_id + WHERE rs.id = a.usable_id AND r.deleted_at IS NULL + )) + OR (a.usable_type = 'RECORDING_DEPLETION' AND NOT EXISTS ( + SELECT 1 FROM recording_depletions rd JOIN recordings r ON r.id = rd.recording_id + WHERE rd.id = a.usable_id AND r.deleted_at IS NULL + )) + OR (a.usable_type = 'STOCKTRANSFER_OUT' AND NOT EXISTS ( + SELECT 1 FROM stock_transfer_details std + JOIN stock_transfers st ON st.id = std.stock_transfer_id + WHERE std.id = a.usable_id AND std.deleted_at IS NULL AND st.deleted_at IS NULL + )) + OR (a.usable_type = 'TRANSFERTOLAYING_OUT' AND NOT EXISTS ( + SELECT 1 FROM laying_transfers lt WHERE lt.id = a.usable_id AND lt.deleted_at IS NULL + )) +) +SELECT * +FROM orphan +ORDER BY usable_type, usable_id, id +LIMIT 200; diff --git a/scripts/sql/orphan_allocations_cleanup.sql b/scripts/sql/orphan_allocations_cleanup.sql new file mode 100644 index 00000000..54830e7b --- /dev/null +++ b/scripts/sql/orphan_allocations_cleanup.sql @@ -0,0 +1,52 @@ +-- Cleanup orphan stock_allocations (ACTIVE + CONSUME) by releasing them. +-- IMPORTANT: run audit first. +-- Usage: +-- psql -U app_lti_user -d db_lti_erp -f scripts/sql/orphan_allocations_cleanup.sql + +BEGIN; + +WITH active_alloc AS ( + SELECT id, usable_type, usable_id + FROM stock_allocations + WHERE status = 'ACTIVE' + AND allocation_purpose = 'CONSUME' + AND deleted_at IS NULL +), +orphan AS ( + SELECT a.id + FROM active_alloc a + WHERE + (a.usable_type = 'ADJUSTMENT_OUT' AND NOT EXISTS (SELECT 1 FROM adjustment_stocks ad WHERE ad.id = a.usable_id)) + OR (a.usable_type = 'MARKETING_DELIVERY' AND NOT EXISTS (SELECT 1 FROM marketing_delivery_products mdp WHERE mdp.id = a.usable_id)) + OR (a.usable_type = 'RECORDING_STOCK' AND NOT EXISTS ( + SELECT 1 FROM recording_stocks rs JOIN recordings r ON r.id = rs.recording_id + WHERE rs.id = a.usable_id AND r.deleted_at IS NULL + )) + OR (a.usable_type = 'RECORDING_DEPLETION' AND NOT EXISTS ( + SELECT 1 FROM recording_depletions rd JOIN recordings r ON r.id = rd.recording_id + WHERE rd.id = a.usable_id AND r.deleted_at IS NULL + )) + OR (a.usable_type = 'STOCKTRANSFER_OUT' AND NOT EXISTS ( + SELECT 1 FROM stock_transfer_details std + JOIN stock_transfers st ON st.id = std.stock_transfer_id + WHERE std.id = a.usable_id AND std.deleted_at IS NULL AND st.deleted_at IS NULL + )) + OR (a.usable_type = 'TRANSFERTOLAYING_OUT' AND NOT EXISTS ( + SELECT 1 FROM laying_transfers lt WHERE lt.id = a.usable_id AND lt.deleted_at IS NULL + )) +), +updated AS ( + UPDATE stock_allocations sa + SET + status = 'RELEASED', + released_at = NOW(), + note = CONCAT(COALESCE(sa.note, ''), CASE WHEN COALESCE(sa.note, '') = '' THEN '' ELSE ' | ' END, 'orphan_cleanup') + WHERE sa.id IN (SELECT id FROM orphan) + RETURNING sa.id, sa.usable_type, sa.usable_id, sa.qty +) +SELECT usable_type, COUNT(*) AS rows, COALESCE(SUM(qty),0) AS total_qty +FROM updated +GROUP BY usable_type +ORDER BY usable_type; + +COMMIT;