diff --git a/internal/common/service/fifo_pending_policy.go b/internal/common/service/fifo_pending_policy.go new file mode 100644 index 00000000..5f3ce62b --- /dev/null +++ b/internal/common/service/fifo_pending_policy.go @@ -0,0 +1,104 @@ +package service + +import ( + "context" + "errors" + "strings" + + "gorm.io/gorm" +) + +type FifoPendingPolicyInput struct { + Lane string + FlagGroupCode string + FunctionCode string + LegacyTypeKey string +} + +type FifoPendingPolicyResult struct { + AllowPending bool + RuleSource string + Found bool +} + +func ResolveFifoPendingPolicy(ctx context.Context, tx *gorm.DB, input FifoPendingPolicyInput) (*FifoPendingPolicyResult, error) { + if tx == nil { + return nil, gorm.ErrInvalidDB + } + + lane := strings.ToUpper(strings.TrimSpace(input.Lane)) + flagGroupCode := strings.ToUpper(strings.TrimSpace(input.FlagGroupCode)) + functionCode := strings.ToUpper(strings.TrimSpace(input.FunctionCode)) + legacyTypeKey := strings.ToUpper(strings.TrimSpace(input.LegacyTypeKey)) + if lane == "" { + return &FifoPendingPolicyResult{ + AllowPending: false, + RuleSource: "SAFE_DEFAULT_BLOCK", + Found: false, + }, nil + } + + type overconsumeRuleRow struct { + Allow bool `gorm:"column:allow_overconsume"` + } + var overconsume overconsumeRuleRow + overconsumeErr := tx.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(&overconsume).Error + if overconsumeErr == nil { + return &FifoPendingPolicyResult{ + AllowPending: overconsume.Allow, + RuleSource: "OVERCONSUME_RULE", + Found: true, + }, nil + } + if !errors.Is(overconsumeErr, gorm.ErrRecordNotFound) { + return nil, overconsumeErr + } + + type routeRuleRow struct { + AllowPendingDefault bool `gorm:"column:allow_pending_default"` + } + var routeRule routeRuleRow + routeQuery := tx.WithContext(ctx). + Table("fifo_stock_v2_route_rules"). + Select("allow_pending_default"). + Where("is_active = TRUE"). + Where("lane = ?", lane). + Where("flag_group_code = ?", flagGroupCode) + if legacyTypeKey != "" { + routeQuery = routeQuery.Where("legacy_type_key = ?", legacyTypeKey) + } + if functionCode != "" { + routeQuery = routeQuery.Where("function_code = ?", functionCode) + } + routeErr := routeQuery. + Order("id ASC"). + Limit(1). + Take(&routeRule).Error + if routeErr == nil { + return &FifoPendingPolicyResult{ + AllowPending: routeRule.AllowPendingDefault, + RuleSource: "ROUTE_RULE_DEFAULT", + Found: true, + }, nil + } + if !errors.Is(routeErr, gorm.ErrRecordNotFound) { + return nil, routeErr + } + + return &FifoPendingPolicyResult{ + AllowPending: false, + RuleSource: "SAFE_DEFAULT_BLOCK", + Found: false, + }, nil +} diff --git a/internal/database/migrations/20260313061525_adjust_marketing_out_overconsume_to_ayam_only.down.sql b/internal/database/migrations/20260313061525_adjust_marketing_out_overconsume_to_ayam_only.down.sql new file mode 100644 index 00000000..24cdfe3f --- /dev/null +++ b/internal/database/migrations/20260313061525_adjust_marketing_out_overconsume_to_ayam_only.down.sql @@ -0,0 +1,118 @@ +BEGIN; + +-- MARKETING_OUT: if AYAM-only rule exists, convert back to global rule. +UPDATE fifo_stock_v2_overconsume_rules +SET + flag_group_code = NULL, + allow_overconsume = FALSE, + priority = 20, + reason = 'fifo_v2_exception_marketing_block', + is_active = TRUE +WHERE lane = 'USABLE' + AND function_code = 'MARKETING_OUT' + AND flag_group_code = 'AYAM' + AND reason = 'fifo_v2_exception_marketing_block_ayam_only'; + +-- MARKETING_OUT: if global row already exists, keep it active. +UPDATE fifo_stock_v2_overconsume_rules +SET + allow_overconsume = FALSE, + priority = 20, + is_active = TRUE +WHERE lane = 'USABLE' + AND function_code = 'MARKETING_OUT' + AND flag_group_code IS NULL + AND reason = 'fifo_v2_exception_marketing_block'; + +-- MARKETING_OUT: insert global rule if still missing. +INSERT INTO fifo_stock_v2_overconsume_rules( + flag_group_code, + function_code, + lane, + allow_overconsume, + priority, + reason, + is_active +) +SELECT NULL, 'MARKETING_OUT', 'USABLE', FALSE, 20, 'fifo_v2_exception_marketing_block', TRUE +WHERE NOT EXISTS ( + SELECT 1 + FROM fifo_stock_v2_overconsume_rules + WHERE lane = 'USABLE' + AND function_code = 'MARKETING_OUT' + AND flag_group_code IS NULL + AND reason = 'fifo_v2_exception_marketing_block' +); + +-- MARKETING_OUT: deactivate AYAM-only duplicates if any remain. +UPDATE fifo_stock_v2_overconsume_rules +SET + is_active = FALSE +WHERE lane = 'USABLE' + AND function_code = 'MARKETING_OUT' + AND flag_group_code = 'AYAM' + AND reason = 'fifo_v2_exception_marketing_block_ayam_only'; + +-- STOCK_TRANSFER_OUT: if AYAM-only rule exists, convert back to global rule. +UPDATE fifo_stock_v2_overconsume_rules +SET + flag_group_code = NULL, + allow_overconsume = FALSE, + priority = 30, + reason = 'fifo_v2_exception_transfer_block', + is_active = TRUE +WHERE lane = 'USABLE' + AND function_code = 'STOCK_TRANSFER_OUT' + AND flag_group_code = 'AYAM' + AND reason = 'fifo_v2_exception_transfer_block_ayam_only'; + +-- STOCK_TRANSFER_OUT: if global row already exists, keep it active. +UPDATE fifo_stock_v2_overconsume_rules +SET + allow_overconsume = FALSE, + priority = 30, + is_active = TRUE +WHERE lane = 'USABLE' + AND function_code = 'STOCK_TRANSFER_OUT' + AND flag_group_code IS NULL + AND reason = 'fifo_v2_exception_transfer_block'; + +-- STOCK_TRANSFER_OUT: insert global rule if still missing. +INSERT INTO fifo_stock_v2_overconsume_rules( + flag_group_code, + function_code, + lane, + allow_overconsume, + priority, + reason, + is_active +) +SELECT NULL, 'STOCK_TRANSFER_OUT', 'USABLE', FALSE, 30, 'fifo_v2_exception_transfer_block', TRUE +WHERE NOT EXISTS ( + SELECT 1 + FROM fifo_stock_v2_overconsume_rules + WHERE lane = 'USABLE' + AND function_code = 'STOCK_TRANSFER_OUT' + AND flag_group_code IS NULL + AND reason = 'fifo_v2_exception_transfer_block' +); + +-- STOCK_TRANSFER_OUT: deactivate AYAM-only duplicates if any remain. +UPDATE fifo_stock_v2_overconsume_rules +SET + is_active = FALSE +WHERE lane = 'USABLE' + AND function_code = 'STOCK_TRANSFER_OUT' + AND flag_group_code = 'AYAM' + AND reason = 'fifo_v2_exception_transfer_block_ayam_only'; + +-- CHICKIN_OUT: rollback AYAM-only hard-block added by up migration. +UPDATE fifo_stock_v2_overconsume_rules +SET + is_active = FALSE +WHERE lane = 'USABLE' + AND function_code = 'CHICKIN_OUT' + AND flag_group_code = 'AYAM' + AND reason = 'fifo_v2_exception_chickin_block_ayam_only'; + +COMMIT; diff --git a/internal/database/migrations/20260313061525_adjust_marketing_out_overconsume_to_ayam_only.up.sql b/internal/database/migrations/20260313061525_adjust_marketing_out_overconsume_to_ayam_only.up.sql new file mode 100644 index 00000000..27d2659e --- /dev/null +++ b/internal/database/migrations/20260313061525_adjust_marketing_out_overconsume_to_ayam_only.up.sql @@ -0,0 +1,139 @@ +BEGIN; + +-- MARKETING_OUT: if global rule exists, convert to AYAM-specific. +UPDATE fifo_stock_v2_overconsume_rules +SET + flag_group_code = 'AYAM', + allow_overconsume = FALSE, + priority = 20, + reason = 'fifo_v2_exception_marketing_block_ayam_only', + is_active = TRUE +WHERE lane = 'USABLE' + AND function_code = 'MARKETING_OUT' + AND flag_group_code IS NULL + AND reason = 'fifo_v2_exception_marketing_block'; + +-- MARKETING_OUT: if AYAM-specific row already exists, enforce desired value. +UPDATE fifo_stock_v2_overconsume_rules +SET + allow_overconsume = FALSE, + priority = 20, + is_active = TRUE +WHERE lane = 'USABLE' + AND function_code = 'MARKETING_OUT' + AND flag_group_code = 'AYAM' + AND reason = 'fifo_v2_exception_marketing_block_ayam_only'; + +-- MARKETING_OUT: insert AYAM-specific if no suitable row exists. +INSERT INTO fifo_stock_v2_overconsume_rules( + flag_group_code, + function_code, + lane, + allow_overconsume, + priority, + reason, + is_active +) +SELECT 'AYAM', 'MARKETING_OUT', 'USABLE', FALSE, 20, 'fifo_v2_exception_marketing_block_ayam_only', TRUE +WHERE NOT EXISTS ( + SELECT 1 + FROM fifo_stock_v2_overconsume_rules + WHERE lane = 'USABLE' + AND function_code = 'MARKETING_OUT' + AND flag_group_code = 'AYAM' + AND reason = 'fifo_v2_exception_marketing_block_ayam_only' +); + +-- MARKETING_OUT: deactivate remaining global rule (if any duplicate row exists). +UPDATE fifo_stock_v2_overconsume_rules +SET + is_active = FALSE +WHERE lane = 'USABLE' + AND function_code = 'MARKETING_OUT' + AND flag_group_code IS NULL + AND reason = 'fifo_v2_exception_marketing_block'; + +-- STOCK_TRANSFER_OUT: if global rule exists, convert to AYAM-specific. +UPDATE fifo_stock_v2_overconsume_rules +SET + flag_group_code = 'AYAM', + allow_overconsume = FALSE, + priority = 30, + reason = 'fifo_v2_exception_transfer_block_ayam_only', + is_active = TRUE +WHERE lane = 'USABLE' + AND function_code = 'STOCK_TRANSFER_OUT' + AND flag_group_code IS NULL + AND reason = 'fifo_v2_exception_transfer_block'; + +-- STOCK_TRANSFER_OUT: if AYAM-specific row already exists, enforce desired value. +UPDATE fifo_stock_v2_overconsume_rules +SET + allow_overconsume = FALSE, + priority = 30, + is_active = TRUE +WHERE lane = 'USABLE' + AND function_code = 'STOCK_TRANSFER_OUT' + AND flag_group_code = 'AYAM' + AND reason = 'fifo_v2_exception_transfer_block_ayam_only'; + +-- STOCK_TRANSFER_OUT: insert AYAM-specific if no suitable row exists. +INSERT INTO fifo_stock_v2_overconsume_rules( + flag_group_code, + function_code, + lane, + allow_overconsume, + priority, + reason, + is_active +) +SELECT 'AYAM', 'STOCK_TRANSFER_OUT', 'USABLE', FALSE, 30, 'fifo_v2_exception_transfer_block_ayam_only', TRUE +WHERE NOT EXISTS ( + SELECT 1 + FROM fifo_stock_v2_overconsume_rules + WHERE lane = 'USABLE' + AND function_code = 'STOCK_TRANSFER_OUT' + AND flag_group_code = 'AYAM' + AND reason = 'fifo_v2_exception_transfer_block_ayam_only' +); + +-- STOCK_TRANSFER_OUT: deactivate remaining global rule (if any duplicate row exists). +UPDATE fifo_stock_v2_overconsume_rules +SET + is_active = FALSE +WHERE lane = 'USABLE' + AND function_code = 'STOCK_TRANSFER_OUT' + AND flag_group_code IS NULL + AND reason = 'fifo_v2_exception_transfer_block'; + +-- CHICKIN_OUT: enforce AYAM-specific hard-block (cannot pending). +UPDATE fifo_stock_v2_overconsume_rules +SET + allow_overconsume = FALSE, + priority = 25, + is_active = TRUE +WHERE lane = 'USABLE' + AND function_code = 'CHICKIN_OUT' + AND flag_group_code = 'AYAM' + AND reason = 'fifo_v2_exception_chickin_block_ayam_only'; + +INSERT INTO fifo_stock_v2_overconsume_rules( + flag_group_code, + function_code, + lane, + allow_overconsume, + priority, + reason, + is_active +) +SELECT 'AYAM', 'CHICKIN_OUT', 'USABLE', FALSE, 25, 'fifo_v2_exception_chickin_block_ayam_only', TRUE +WHERE NOT EXISTS ( + SELECT 1 + FROM fifo_stock_v2_overconsume_rules + WHERE lane = 'USABLE' + AND function_code = 'CHICKIN_OUT' + AND flag_group_code = 'AYAM' + AND reason = 'fifo_v2_exception_chickin_block_ayam_only' +); + +COMMIT; diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index aa31719f..60082b0d 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -38,9 +38,10 @@ const ( P_ExpenseDocumentRealizations = "lti.expense.document.realization" ) const ( - P_AdjustmentGetAll = "lti.inventory.list" - P_AdjustmentCreate = "lti.inventory.create" - P_AdjustmentGetOne = "lti.inventory.detail" + P_AdjustmentGetAll = "lti.inventory.list" + P_AdjustmentCreate = "lti.inventory.create" + P_AdjustmentGetOne = "lti.inventory.detail" + P_AdjustmentDeleteOne = "lti.inventory.delete" ) const ( P_ApprovalGetAll = "lti.approval.list" diff --git a/internal/modules/inventory/adjustments/controllers/adjustment.controller.go b/internal/modules/inventory/adjustments/controllers/adjustment.controller.go index e3f46b9f..537d1ad9 100644 --- a/internal/modules/inventory/adjustments/controllers/adjustment.controller.go +++ b/internal/modules/inventory/adjustments/controllers/adjustment.controller.go @@ -103,3 +103,22 @@ func (u *AdjustmentController) GetOne(c *fiber.Ctx) error { Data: dto.ToAdjustmentDetailDTO(stockLog), }) } + +func (u *AdjustmentController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := u.AdjustmentService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete adjustment successfully", + }) +} diff --git a/internal/modules/inventory/adjustments/route.go b/internal/modules/inventory/adjustments/route.go index f99fe01e..18488ade 100644 --- a/internal/modules/inventory/adjustments/route.go +++ b/internal/modules/inventory/adjustments/route.go @@ -15,8 +15,9 @@ func AdjustmentRoutes(v1 fiber.Router, u user.UserService, s adjustment.Adjustme route := v1.Group("/adjustments") route.Use(m.Auth(u)) // Standard CRUD routes following master data pattern - route.Get("/",m.RequirePermissions(m.P_AdjustmentGetAll), ctrl.AdjustmentHistory) // Get all with pagination and filters - route.Post("/",m.RequirePermissions(m.P_AdjustmentCreate), ctrl.Adjustment) // Create adjustment - route.Get("/:id",m.RequirePermissions(m.P_AdjustmentGetOne), ctrl.GetOne) + route.Get("/", m.RequirePermissions(m.P_AdjustmentGetAll), ctrl.AdjustmentHistory) // Get all with pagination and filters + route.Post("/", m.RequirePermissions(m.P_AdjustmentCreate), ctrl.Adjustment) // Create adjustment + route.Get("/:id", m.RequirePermissions(m.P_AdjustmentGetOne), ctrl.GetOne) + route.Delete("/:id", m.RequirePermissions(m.P_AdjustmentDeleteOne), ctrl.DeleteOne) } diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 72a15e3a..753cee3c 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "math" + "sort" "strings" "github.com/go-playground/validator/v10" @@ -24,11 +25,13 @@ 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 { Adjustment(ctx *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) + DeleteOne(ctx *fiber.Ctx, id uint) error AdjustmentHistory(ctx *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error) } @@ -51,6 +54,13 @@ const ( 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, @@ -104,6 +114,172 @@ func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentSto return adjustmentStock, nil } +func (s *adjustmentService) DeleteOne(c *fiber.Ctx, id uint) error { + if id == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid adjustment id") + } + if s.FifoStockV2Svc == nil { + return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available") + } + if err := m.EnsureStockLogAccess(c, s.StockLogsRepository.DB(), id); err != nil { + return err + } + + ctx := c.Context() + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return err + } + + 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) + 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 { + 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) Adjustment(c *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error) { if err := s.Validate.Struct(req); err != nil { return nil, err @@ -527,6 +703,118 @@ func (s *adjustmentService) resolveOverconsumePolicy( 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) + 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") + } + if len(deps) == 0 { + return nil, true, nil + } + + allowPending := true + for _, dep := range deps { + policy, policyErr := common.ResolveFifoPendingPolicy(ctx, tx, common.FifoPendingPolicyInput{ + Lane: adjustmentLaneUsable, + FlagGroupCode: dep.FlagGroupCode, + FunctionCode: dep.FunctionCode, + LegacyTypeKey: dep.UsableType, + }) + if policyErr != nil { + s.Log.Errorf("Failed to resolve FIFO pending policy for adjustment dependency: %+v", policyErr) + return nil, false, fiber.NewError(fiber.StatusInternalServerError, "Failed to read FIFO v2 configuration") + } + if !policy.Found || !policy.AllowPending { + allowPending = false + break + } + } + + 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 { + if len(rows) == 0 { + return "-" + } + + grouped := make(map[string]map[uint64]struct{}) + for _, row := range rows { + label := strings.ToUpper(strings.TrimSpace(row.UsableType)) + if label == "" { + label = "UNKNOWN" + } + if _, ok := grouped[label]; !ok { + grouped[label] = make(map[uint64]struct{}) + } + grouped[label][row.UsableID] = struct{}{} + } + + labels := make([]string, 0, len(grouped)) + for label := range grouped { + labels = append(labels, label) + } + sort.Strings(labels) + + parts := make([]string, 0, len(labels)) + for _, label := range labels { + ids := make([]uint64, 0, len(grouped[label])) + for id := range grouped[label] { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + idParts := make([]string, 0, len(ids)) + for _, id := range ids { + idParts = append(idParts, fmt.Sprintf("%d", id)) + } + parts = append(parts, fmt.Sprintf("%s=%s", label, strings.Join(idParts, "|"))) + } + + return strings.Join(parts, ", ") +} + func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) { warehouse, err := s.WarehouseRepo.GetByID(ctx, warehouseID, nil) if err != nil { @@ -593,6 +881,28 @@ func (s *adjustmentService) resolveAyamSourceProductWarehouse( 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, diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index d1dfc6ca..28d1f9c3 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -13,7 +13,6 @@ import ( validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/validations" kandangrepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gorm.io/gorm" ) @@ -259,28 +258,28 @@ func (s productWarehouseService) applyTransferAvailableQty(c *fiber.Ctx, params return rows, nil } - type usageRow struct { + type populationRemainingRow struct { ProductWarehouseID uint `gorm:"column:product_warehouse_id"` - UsedQty float64 `gorm:"column:used_qty"` - } - usageRows := make([]usageRow, 0) - if err := s.Repository.DB().WithContext(c.Context()). - Table("stock_allocations"). - Select("product_warehouse_id, COALESCE(SUM(qty), 0) AS used_qty"). - Where("product_warehouse_id IN ?", ayamPWIDs). - Where("usable_type = ?", fifo.UsableKeyProjectChickin.String()). - Where("status = ?", entity.StockAllocationStatusActive). - Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). - Where("deleted_at IS NULL"). - Group("product_warehouse_id"). - Scan(&usageRows).Error; err != nil { - s.Log.Errorf("Failed to calculate available transfer stock after chickin consumption: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menghitung stok tersedia untuk transfer") + RemainingQty float64 `gorm:"column:remaining_qty"` } - usageMap := make(map[uint]float64, len(usageRows)) - for _, row := range usageRows { - usageMap[row.ProductWarehouseID] = row.UsedQty + var populationRows []populationRemainingRow + if err := s.Repository.DB().WithContext(c.Context()). + Table("project_flock_populations pfp"). + Select("pfp.product_warehouse_id, COALESCE(SUM(GREATEST(pfp.total_qty - pfp.total_used_qty, 0)), 0) AS remaining_qty"). + Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). + Where("pfp.product_warehouse_id IN ?", ayamPWIDs). + Where("pfp.deleted_at IS NULL"). + Where("pc.deleted_at IS NULL"). + Group("pfp.product_warehouse_id"). + Scan(&populationRows).Error; err != nil { + s.Log.Errorf("Failed to resolve chickin population remaining for transfer stock filter: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve transfer stock availability") + } + + populationRemainingByPW := make(map[uint]float64, len(populationRows)) + for _, row := range populationRows { + populationRemainingByPW[row.ProductWarehouseID] = row.RemainingQty } filtered := make([]entity.ProductWarehouse, 0, len(rows)) @@ -291,7 +290,7 @@ func (s productWarehouseService) applyTransferAvailableQty(c *fiber.Ctx, params continue } - available := row.Quantity - usageMap[row.Id] + available := row.Quantity - populationRemainingByPW[row.Id] if available < 0 { available = 0 } diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 83e918fd..8d4ccdfe 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -56,6 +56,13 @@ type transferService struct { const transferDeleteDownstreamGuardMessage = "Transfer stock tidak dapat dihapus karena stok transfer sudah dipakai transaksi turunan. Hapus dependensi terkait secara manual terlebih dahulu." +type downstreamDependency 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 NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, projectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository, documentSvc commonSvc.DocumentService, fifoStockV2Svc commonSvc.FifoStockV2Service, expenseBridge TransferExpenseBridge) TransferService { return &transferService{ Log: utils.Log, @@ -674,7 +681,7 @@ func (s *transferService) DeleteOne(c *fiber.Ctx, id uint) error { for _, detail := range details { detailIDs = append(detailIDs, detail.Id) } - if err := s.ensureNoDownstreamConsumptionForDelete(c.Context(), tx, detailIDs); err != nil { + if err := s.ensureDeletePolicyForDownstreamConsumption(c.Context(), tx, detailIDs); err != nil { return err } @@ -936,37 +943,97 @@ func (s *transferService) ensureTransferAccess(ctx context.Context, id uint, c * return nil } -func (s *transferService) ensureNoDownstreamConsumptionForDelete(ctx context.Context, tx *gorm.DB, detailIDs []uint64) error { - if len(detailIDs) == 0 { +func (s *transferService) ensureDeletePolicyForDownstreamConsumption(ctx context.Context, tx *gorm.DB, detailIDs []uint64) error { + dependencies, err := s.loadActiveTransferDownstreamDependencies(ctx, tx, detailIDs) + if err != nil { + s.Log.Errorf("Failed to load downstream stock transfer consumption: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transaksi turunan transfer stock") + } + if len(dependencies) == 0 { return nil } + ayamDependency, err := s.hasAyamDownstreamConsumption(ctx, tx, detailIDs) + if err != nil { + s.Log.Errorf("Failed to validate AYAM downstream dependency for transfer delete: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi dependensi AYAM pada transfer stock") + } + if ayamDependency { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf( + "%s Dependensi aktif: %s. Alasan block: produk AYAM yang sudah terpakai tidak dapat dihapus.", + transferDeleteDownstreamGuardMessage, + formatDownstreamDependencySummary(dependencies), + ), + ) + } + + denyReason := "" + for _, dep := range dependencies { + policy, policyErr := commonSvc.ResolveFifoPendingPolicy(ctx, tx, commonSvc.FifoPendingPolicyInput{ + Lane: "USABLE", + FlagGroupCode: dep.FlagGroupCode, + FunctionCode: dep.FunctionCode, + LegacyTypeKey: dep.UsableType, + }) + if policyErr != nil { + s.Log.Errorf("Failed to resolve FIFO pending policy for transfer dependency: %+v", policyErr) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal membaca konfigurasi FIFO v2") + } + if !policy.Found || !policy.AllowPending { + denyReason = "pending disabled by config" + break + } + } + if denyReason == "" { + return nil + } + + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf( + "%s Dependensi aktif: %s. Alasan block: %s.", + transferDeleteDownstreamGuardMessage, + formatDownstreamDependencySummary(dependencies), + denyReason, + ), + ) +} + +func (s *transferService) loadActiveTransferDownstreamDependencies( + ctx context.Context, + tx *gorm.DB, + detailIDs []uint64, +) ([]downstreamDependency, error) { + if len(detailIDs) == 0 { + return nil, nil + } db := s.StockTransferRepo.DB().WithContext(ctx) if tx != nil { db = tx.WithContext(ctx) } - type downstreamRow struct { - UsableType string `gorm:"column:usable_type"` - UsableID uint64 `gorm:"column:usable_id"` - } - - var rows []downstreamRow - if err := db.Table("stock_allocations"). - Select("usable_type, usable_id"). + var rows []downstreamDependency + 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 = ?", fifo.StockableKeyStockTransferIn.String()). Where("stockable_id IN ?", detailIDs). Where("status = ?", entity.StockAllocationStatusActive). Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). Where("deleted_at IS NULL"). - Group("usable_type, usable_id"). - Scan(&rows).Error; err != nil { - s.Log.Errorf("Failed to validate downstream stock transfer consumption: %+v", err) - return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transaksi turunan transfer stock") + Group("usable_type, usable_id, function_code, flag_group_code"). + Scan(&rows).Error + if err != nil { + return nil, err } + return rows, nil +} + +func formatDownstreamDependencySummary(rows []downstreamDependency) string { if len(rows) == 0 { - return nil + return "-" } dependencyMap := make(map[string]map[uint64]struct{}) @@ -990,10 +1057,35 @@ func (s *transferService) ensureNoDownstreamConsumptionForDelete(ctx context.Con details = append(details, fmt.Sprintf("%s=%s", label, joinUint64(ids))) } - return fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("%s Dependensi aktif: %s.", transferDeleteDownstreamGuardMessage, strings.Join(details, ", ")), - ) + return strings.Join(details, ", ") +} + +func (s *transferService) hasAyamDownstreamConsumption(ctx context.Context, tx *gorm.DB, detailIDs []uint64) (bool, error) { + if len(detailIDs) == 0 { + return false, nil + } + + db := s.StockTransferRepo.DB().WithContext(ctx) + if tx != nil { + db = tx.WithContext(ctx) + } + + var found int64 + err := db.Table("stock_allocations sa"). + Joins("JOIN stock_transfer_details std ON std.id = sa.stockable_id AND std.deleted_at IS NULL"). + Joins("JOIN flags f ON f.flagable_type = ? AND f.flagable_id = std.product_id", entity.FlagableTypeProduct). + 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("sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()). + Where("sa.stockable_id IN ?", detailIDs). + Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). + Where("sa.deleted_at IS NULL"). + Count(&found).Error + if err != nil { + return false, err + } + + return found > 0, nil } func mapTransferDownstreamUsableLabel(usableType string) string { diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 693455e6..96957f66 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -646,24 +646,72 @@ func (s *chickinService) ensureNoDownstreamConsumptionForDelete(ctx context.Cont } var rows []downstreamRow - if err := db.Table("stock_allocations sa"). - Select("sa.usable_type, sa.usable_id"). - Joins("JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id"). - Where("pfp.project_chickin_id = ?", chickinID). - Where("pfp.deleted_at IS NULL"). - Where("sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()). - Where("sa.status = ?", entity.StockAllocationStatusActive). - Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). - Where("sa.deleted_at IS NULL"). - Where("sa.usable_type IN ?", []string{ - fifo.UsableKeyMarketingDelivery.String(), - fifo.UsableKeyRecordingDepletion.String(), - fifo.UsableKeyStockTransferOut.String(), - fifo.UsableKeyAdjustmentOut.String(), - fifo.UsableKeyTransferToLayingOut.String(), - }). - Group("sa.usable_type, sa.usable_id"). - Scan(&rows).Error; err != nil { + 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") } @@ -682,7 +730,7 @@ func (s *chickinService) ensureNoDownstreamConsumptionForDelete(ctx context.Cont switch row.UsableType { case fifo.UsableKeyMarketingDelivery.String(): marketingIDs[row.UsableID] = struct{}{} - case fifo.UsableKeyRecordingDepletion.String(): + case fifo.UsableKeyRecordingStock.String(), fifo.UsableKeyRecordingDepletion.String(): recordingIDs[row.UsableID] = struct{}{} case fifo.UsableKeyStockTransferOut.String(): transferIDs[row.UsableID] = struct{}{} diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index a779ed18..b7caec6b 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -522,9 +522,6 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } recordingEntity = recording - if err := s.ensurePopulationMutationAllowed(ctx, recordingEntity, "ubah"); err != nil { - return err - } pfkForRoute := recordingEntity.ProjectFlockKandang if pfkForRoute == nil || pfkForRoute.Id == 0 { fetchedPfk, fetchErr := s.ProjectFlockKandangRepo.GetByIDLight(ctx, recordingEntity.ProjectFlockKandangId) @@ -537,7 +534,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } pfkForRoute = fetchedPfk } - routePayload := buildRecordingRoutePayloadFromUpdate(req, recordingEntity) + routePayload := buildRecordingRoutePayloadFromUpdate(req) if err := s.enforceTransferRecordingRoute(ctx, pfkForRoute, recordingEntity.RecordDatetime, routePayload); err != nil { return err } @@ -594,6 +591,9 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if match { hasDepletionChanges = false } else { + if err := s.ensurePopulationMutationAllowed(ctx, recordingEntity, "ubah"); err != nil { + return err + } if err := s.ensureDepletionMutationAllowed(ctx, recordingEntity, "ubah"); err != nil { return err } @@ -935,15 +935,15 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { s.Log.Errorf("Failed to find recording: %+v", err) return err } - if err := s.ensurePopulationMutationAllowed(ctx, recording, "hapus"); err != nil { - return err - } existingDepletions, err := s.Repository.ListDepletions(tx, recording.Id) if err != nil { s.Log.Errorf("Failed to list existing depletions: %+v", err) return err } if len(existingDepletions) > 0 { + if err := s.ensurePopulationMutationAllowed(ctx, recording, "hapus"); err != nil { + return err + } if err := s.ensureDepletionMutationAllowed(ctx, recording, "hapus"); err != nil { return err } @@ -1376,60 +1376,34 @@ func buildRecordingRoutePayloadFromCreate(req *validation.Create) recordingRoute return payload } -func buildRecordingRoutePayloadFromUpdate(req *validation.Update, existing *entity.Recording) recordingRoutePayload { +func buildRecordingRoutePayloadFromUpdate(req *validation.Update) recordingRoutePayload { payload := recordingRoutePayload{} - if req == nil && existing == nil { + if req == nil { return payload } - if req != nil && req.Stocks != nil { + if req.Stocks != nil { for _, stock := range req.Stocks { if stock.Qty > 0 { payload.StockCount++ } } - } else if existing != nil { - for _, stock := range existing.Stocks { - usageQty := 0.0 - if stock.UsageQty != nil { - usageQty = *stock.UsageQty - } - pendingQty := 0.0 - if stock.PendingQty != nil { - pendingQty = *stock.PendingQty - } - if usageQty > 0 || pendingQty > 0 { - payload.StockCount++ - } - } } - if req != nil && req.Depletions != nil { + if req.Depletions != nil { for _, depletion := range req.Depletions { if depletion.Qty > 0 { payload.DepletionCount++ } } - } else if existing != nil { - for _, depletion := range existing.Depletions { - if depletion.Qty > 0 { - payload.DepletionCount++ - } - } } - if req != nil && req.Eggs != nil { + if req.Eggs != nil { for _, egg := range req.Eggs { if egg.Qty > 0 { payload.EggCount++ } } - } else if existing != nil { - for _, egg := range existing.Eggs { - if egg.Qty > 0 { - payload.EggCount++ - } - } } return payload diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 9c038bdd..5b419482 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -6,6 +6,7 @@ import ( "fmt" "math" "mime/multipart" + "sort" "strings" "time" @@ -29,6 +30,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type PurchaseService interface { @@ -67,6 +69,13 @@ type staffAdjustmentPayload struct { NewItems []*entity.PurchaseItem } +type purchaseDownstreamDependency 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 NewPurchaseService( validate *validator.Validate, purchaseRepo rPurchase.PurchaseRepository, @@ -884,8 +893,28 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation if receivedQty > item.SubQty { return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot exceed ordered quantity (%.3f)", payload.PurchaseItemID, item.SubQty)) } - if receivedQty < item.TotalUsed && isReceivingBelowUsedBlocked(item, lockedIDs) { - return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot be lower than used amount (%.3f)", payload.PurchaseItemID, item.TotalUsed)) + if receivedQty < item.TotalUsed { + deps, allowPending, err := s.resolvePurchaseDependenciesAndPendingPolicy( + ctx, + s.PurchaseRepo.DB(), + []uint{item.Id}, + ) + if err != nil { + return nil, err + } + if len(deps) > 0 && !allowPending { + return nil, utils.BadRequest( + fmt.Sprintf( + "Received quantity for item %d cannot be lower than used amount (%.3f). Dependensi aktif: %s. Alasan block: pending disabled by config.", + payload.PurchaseItemID, + item.TotalUsed, + formatPurchaseDependencySummary(deps), + ), + ) + } + if len(deps) == 0 && isReceivingBelowUsedBlocked(item, lockedIDs) { + return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot be lower than used amount (%.3f)", payload.PurchaseItemID, item.TotalUsed)) + } } if _, dup := visitedItems[payload.PurchaseItemID]; dup { @@ -1317,6 +1346,30 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { repoTx := rPurchase.NewPurchaseRepository(tx) + var lockedPurchase entity.Purchase + if err := tx.WithContext(ctx). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ?", purchase.Id). + Take(&lockedPurchase).Error; err != nil { + return err + } + + allowPending, deps, err := s.ensurePurchaseDeletePolicy(ctx, tx, toDelete) + if err != nil { + return err + } + if len(deps) > 0 && !allowPending { + return utils.BadRequest( + fmt.Sprintf( + "Purchase item tidak dapat dihapus karena sudah dipakai transaksi turunan. Dependensi aktif: %s. Alasan block: pending disabled by config.", + formatPurchaseDependencySummary(deps), + ), + ) + } + + if err := s.rollbackPurchaseStock(ctx, tx, itemsToDelete, fmt.Sprintf("Purchase-Item-Delete#%d", purchase.Id), purchase.CreatedBy); err != nil { + return err + } if err := repoTx.DeleteItems(ctx, purchase.Id, toDelete); err != nil { return err @@ -1385,12 +1438,25 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error { } transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { - lockedIDs, err := s.resolveChickinLockedItemIDs(ctx, tx, itemsToDelete) + var lockedPurchase entity.Purchase + if err := tx.WithContext(ctx). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ?", purchase.Id). + Take(&lockedPurchase).Error; err != nil { + return err + } + + allowPending, deps, err := s.ensurePurchaseDeletePolicy(ctx, tx, collectPurchaseItemIDs(itemsToDelete)) if err != nil { return err } - if len(lockedIDs) > 0 { - return utils.BadRequest("Purchase already chickin, failed to delete purchase") + if len(deps) > 0 && !allowPending { + return utils.BadRequest( + fmt.Sprintf( + "Purchase tidak dapat dihapus karena sudah dipakai transaksi turunan. Dependensi aktif: %s. Alasan block: pending disabled by config.", + formatPurchaseDependencySummary(deps), + ), + ) } if err := s.rollbackPurchaseStock(ctx, tx, itemsToDelete, note, actorID); err != nil { @@ -1795,6 +1861,125 @@ func purchaseItemHasFlag(item *entity.PurchaseItem, flag utils.FlagType) bool { return false } +func (s *purchaseService) ensurePurchaseDeletePolicy( + ctx context.Context, + tx *gorm.DB, + itemIDs []uint, +) (bool, []purchaseDownstreamDependency, error) { + deps, allowPending, err := s.resolvePurchaseDependenciesAndPendingPolicy(ctx, tx, itemIDs) + if err != nil { + return false, nil, err + } + return allowPending, deps, nil +} + +func (s *purchaseService) resolvePurchaseDependenciesAndPendingPolicy( + ctx context.Context, + tx *gorm.DB, + itemIDs []uint, +) ([]purchaseDownstreamDependency, bool, error) { + deps, err := s.loadPurchaseDownstreamDependencies(ctx, tx, itemIDs) + if err != nil { + s.Log.Errorf("Failed to load downstream dependencies for purchase items: %+v", err) + return nil, false, utils.Internal("Failed to validate downstream purchase dependencies") + } + if len(deps) == 0 { + return nil, true, nil + } + + allowPending := true + for _, dep := range deps { + policy, policyErr := commonSvc.ResolveFifoPendingPolicy(ctx, tx, commonSvc.FifoPendingPolicyInput{ + Lane: "USABLE", + FlagGroupCode: dep.FlagGroupCode, + FunctionCode: dep.FunctionCode, + LegacyTypeKey: dep.UsableType, + }) + if policyErr != nil { + s.Log.Errorf("Failed to resolve FIFO pending policy for purchase dependency: %+v", policyErr) + return nil, false, utils.Internal("Failed to read FIFO v2 configuration") + } + if !policy.Found || !policy.AllowPending { + allowPending = false + break + } + } + + return deps, allowPending, nil +} + +func (s *purchaseService) loadPurchaseDownstreamDependencies( + ctx context.Context, + tx *gorm.DB, + itemIDs []uint, +) ([]purchaseDownstreamDependency, error) { + if len(itemIDs) == 0 { + return nil, nil + } + + db := s.PurchaseRepo.DB().WithContext(ctx) + if tx != nil { + db = tx.WithContext(ctx) + } + + var rows []purchaseDownstreamDependency + 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 = ?", fifo.StockableKeyPurchaseItems.String()). + Where("stockable_id IN ?", itemIDs). + Where("status = ?", entity.StockAllocationStatusActive). + Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). + Where("deleted_at IS NULL"). + Group("usable_type, usable_id, function_code, flag_group_code"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + return rows, nil +} + +func formatPurchaseDependencySummary(rows []purchaseDownstreamDependency) string { + if len(rows) == 0 { + return "-" + } + + dependencyMap := make(map[string]map[uint64]struct{}) + for _, row := range rows { + label := strings.ToUpper(strings.TrimSpace(row.UsableType)) + if label == "" { + label = "UNKNOWN" + } + if _, ok := dependencyMap[label]; !ok { + dependencyMap[label] = make(map[uint64]struct{}) + } + dependencyMap[label][row.UsableID] = struct{}{} + } + + labels := make([]string, 0, len(dependencyMap)) + for label := range dependencyMap { + labels = append(labels, label) + } + sort.Strings(labels) + + parts := make([]string, 0, len(labels)) + for _, label := range labels { + ids := make([]uint64, 0, len(dependencyMap[label])) + for id := range dependencyMap[label] { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + + idParts := make([]string, 0, len(ids)) + for _, id := range ids { + idParts = append(idParts, fmt.Sprintf("%d", id)) + } + parts = append(parts, fmt.Sprintf("%s=%s", label, strings.Join(idParts, "|"))) + } + + return strings.Join(parts, ", ") +} + func isReceivingBelowUsedBlocked(item *entity.PurchaseItem, lockedIDs map[uint]struct{}) bool { if item == nil { return false