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/common/service/fifo_stock_v2/allocate.go b/internal/common/service/fifo_stock_v2/allocate.go index 33c3887b..e7588286 100644 --- a/internal/common/service/fifo_stock_v2/allocate.go +++ b/internal/common/service/fifo_stock_v2/allocate.go @@ -220,6 +220,9 @@ func shouldSkipStockableForUsable(req AllocateRequest, stockableType string) boo if (usableType == "PROJECT_CHICKIN" || functionCode == "CHICKIN_OUT") && stockable == "PROJECT_FLOCK_POPULATION" { return true } + if (usableType == "STOCK_TRANSFER_OUT" || functionCode == "STOCK_TRANSFER_OUT") && stockable == "PROJECT_FLOCK_POPULATION" { + return true + } return false } @@ -496,10 +499,6 @@ func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*Re if len(rollbackRes.Details) > 0 { result.Rollback.Details = append(result.Rollback.Details, rollbackRes.Details...) } - minDesired := rollbackRes.ReleasedQty + usableRow.PendingQuantity - if desiredQty < minDesired { - desiredQty = minDesired - } if desiredQty <= 0 { continue @@ -702,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 } @@ -719,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/config/config.go b/internal/config/config.go index e49ed16f..040e0fdd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -261,6 +261,10 @@ func defaultString(v, def string) string { return v } +func LayingWeekStart() int { + return TransferToLayingGrowingMaxWeek +} + func joinPath(parts ...string) string { out := make([]string, 0, len(parts)) for _, part := range parts { 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/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/entities/product_warehouse.go b/internal/entities/product_warehouse.go index 8e1ece25..a0d347db 100644 --- a/internal/entities/product_warehouse.go +++ b/internal/entities/product_warehouse.go @@ -1,11 +1,12 @@ package entities type ProductWarehouse struct { - Id uint `gorm:"primaryKey;column:id"` - ProductId uint `gorm:"column:product_id;not null"` - WarehouseId uint `gorm:"column:warehouse_id;not null"` - ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id"` - Quantity float64 `gorm:"column:qty;type:numeric(15,3);default:0"` + Id uint `gorm:"primaryKey;column:id"` + ProductId uint `gorm:"column:product_id;not null"` + WarehouseId uint `gorm:"column:warehouse_id;not null"` + ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id"` + Quantity float64 `gorm:"column:qty;type:numeric(15,3);default:0"` + AvailableQty *float64 `gorm:"-"` // Relations Product Product `gorm:"foreignKey:ProductId;references:Id"` diff --git a/internal/entities/recording.go b/internal/entities/recording.go index fa20907f..19b757a4 100644 --- a/internal/entities/recording.go +++ b/internal/entities/recording.go @@ -45,4 +45,6 @@ type Recording struct { StandardFcr *float64 `gorm:"-"` PopulationCanChange *bool `gorm:"-"` TransferExecuted *bool `gorm:"-"` + IsTransition *bool `gorm:"-"` + IsLaying *bool `gorm:"-"` } diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index 131c1ad5..fa8374ba 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" @@ -70,6 +71,7 @@ const ( P_TransferGetAll = "lti.inventory.transfer.list" P_TransferGetOne = "lti.inventory.transfer.detail" P_TransferCreateOne = "lti.inventory.transfer.create" + P_TransferDeleteOne = "lti.inventory.transfer.delete" ) const ( 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/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/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..bfd0d767 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" @@ -29,6 +30,7 @@ import ( 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) } @@ -48,7 +50,6 @@ type adjustmentService struct { const ( adjustmentLaneStockable = "STOCKABLE" adjustmentLaneUsable = "USABLE" - flagGroupAyam = "AYAM" ) func NewAdjustmentService( @@ -76,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") @@ -104,6 +103,250 @@ 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 { + adjustments, err := s.collectAdjustmentsForDelete(ctx, tx, id) + if err != nil { + return err + } + for _, item := range adjustments { + if err := s.deleteSingleAdjustmentInTx(ctx, tx, item, actorID); err != nil { + return err + } + } + 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 @@ -122,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") @@ -144,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 @@ -228,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( @@ -285,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{ @@ -326,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") } } @@ -502,29 +756,80 @@ func (s *adjustmentService) resolveRouteByFunctionCode( } } -func (s *adjustmentService) resolveOverconsumePolicy( +func (s *adjustmentService) resolveAdjustmentDependenciesAndPolicy( 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, - ) + tx *gorm.DB, + stockableType string, + stockableIDs []uint, +) ([]adjustmentStockRepo.AdjustmentDownstreamDependency, bool, error) { + deps, err := s.AdjustmentStockRepository.WithTx(tx).LoadDownstreamDependencies(ctx, stockableType, stockableIDs) if err != nil { - return false, err + 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 selected == nil { - return defaultValue, nil + if len(deps) == 0 { + return nil, true, nil } - return *selected, 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 formatAdjustmentDependencySummary(rows []adjustmentStockRepo.AdjustmentDownstreamDependency) string { + if len(rows) == 0 { + return "-" + } + + grouped := make(map[string]map[uint64]struct{}) + for _, row := range rows { + label := utils.NormalizeUpper(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) { @@ -553,46 +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) createAdjustmentStockLog( ctx context.Context, stockLogRepo stockLogsRepo.StockLogRepository, @@ -676,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 @@ -769,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/inventory/product-warehouses/controllers/product_warehouse.controller.go b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go index bc6cdaed..5737e9f0 100644 --- a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go +++ b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go @@ -32,6 +32,7 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error { Flags: c.Query("flags", ""), KandangId: uint(c.QueryInt("kandang_id", 0)), TransferContext: c.Query(utils.TransferContextKey, ""), + StockMode: c.Query("stock_mode", ""), Type: c.Query("type", ""), } diff --git a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go index b9c95004..97cff885 100644 --- a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go +++ b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go @@ -12,10 +12,11 @@ import ( // === DTO Structs === type ProductWarehouseRelationDTO struct { - Id uint `json:"id"` - ProductId uint `json:"product_id"` - WarehouseId uint `json:"warehouse_id"` - Quantity float64 `json:"quantity"` + Id uint `json:"id"` + ProductId uint `json:"product_id"` + WarehouseId uint `json:"warehouse_id"` + Quantity float64 `json:"quantity"` + TransferAvailableQty *float64 `json:"transfer_available_qty,omitempty"` } type ProductWarehouseListDTO struct { @@ -61,10 +62,11 @@ type ProjectFlockRelationDTO struct { func ToProductWarehouseRelationDTO(e entity.ProductWarehouse) ProductWarehouseRelationDTO { return ProductWarehouseRelationDTO{ - Id: e.Id, - ProductId: e.ProductId, // Field yang benar dari entity - WarehouseId: e.WarehouseId, // Field yang benar dari entity - Quantity: e.Quantity, + Id: e.Id, + ProductId: e.ProductId, // Field yang benar dari entity + WarehouseId: e.WarehouseId, // Field yang benar dari entity + Quantity: e.Quantity, + TransferAvailableQty: e.AvailableQty, } } 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 188c4506..28d1f9c3 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -2,6 +2,7 @@ package service import ( "errors" + "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -27,6 +28,8 @@ type productWarehouseService struct { KandangRepo kandangrepo.KandangRepository } +const stockModeExcludeChickin = "exclude_chickin" + func NewProductWarehouseService(repo repository.ProductWarehouseRepository, validate *validator.Validate, kandangRepo kandangrepo.KandangRepository) ProductWarehouseService { return &productWarehouseService{ Log: utils.Log, @@ -189,6 +192,11 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) s.Log.Errorf("Failed to get productWarehouses: %+v", err) return nil, 0, err } + + productWarehouses, err = s.applyTransferAvailableQty(c, params, productWarehouses) + if err != nil { + return nil, 0, err + } return productWarehouses, total, nil } @@ -229,3 +237,80 @@ func (s productWarehouseService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductW } return productWarehouse, nil } + +func (s productWarehouseService) applyTransferAvailableQty(c *fiber.Ctx, params *validation.Query, rows []entity.ProductWarehouse) ([]entity.ProductWarehouse, error) { + if len(rows) == 0 { + return rows, nil + } + if params == nil || + params.TransferContext != utils.TransferContextInventoryTransfer || + params.StockMode != stockModeExcludeChickin { + return rows, nil + } + + ayamPWIDs := make([]uint, 0) + for i := range rows { + if isAyamProductByFlags(rows[i].Product.Flags) { + ayamPWIDs = append(ayamPWIDs, rows[i].Id) + } + } + if len(ayamPWIDs) == 0 { + return rows, nil + } + + type populationRemainingRow struct { + ProductWarehouseID uint `gorm:"column:product_warehouse_id"` + RemainingQty float64 `gorm:"column:remaining_qty"` + } + + 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)) + for i := range rows { + row := rows[i] + if !isAyamProductByFlags(row.Product.Flags) { + filtered = append(filtered, row) + continue + } + + available := row.Quantity - populationRemainingByPW[row.Id] + if available < 0 { + available = 0 + } + row.AvailableQty = &available + + if available <= 0 { + continue + } + + filtered = append(filtered, row) + } + + return filtered, nil +} + +func isAyamProductByFlags(flags []entity.Flag) bool { + for _, flag := range flags { + if utils.CanonicalFlagType(strings.TrimSpace(flag.Name)) == utils.FlagAyam { + return true + } + } + return false +} diff --git a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go index 5d1f4e0a..348fd96d 100644 --- a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go +++ b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go @@ -20,5 +20,6 @@ type Query struct { Flags string `query:"flags" validate:"omitempty"` KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"` TransferContext string `query:"transfer_context" validate:"omitempty,oneof=inventory_transfer"` + StockMode string `query:"stock_mode" validate:"omitempty,oneof=exclude_chickin"` Type string `query:"type" validate:"omitempty"` } diff --git a/internal/modules/inventory/transfers/controllers/transfer.controller.go b/internal/modules/inventory/transfers/controllers/transfer.controller.go index 530d70dc..64efeada 100644 --- a/internal/modules/inventory/transfers/controllers/transfer.controller.go +++ b/internal/modules/inventory/transfers/controllers/transfer.controller.go @@ -109,3 +109,23 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error { Data: dto.ToTransferDetailDTO(*result), }) } + +func (u *TransferController) 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.TransferService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete transfer successfully", + }) +} diff --git a/internal/modules/inventory/transfers/route.go b/internal/modules/inventory/transfers/route.go index f754148c..3c8bd0a8 100644 --- a/internal/modules/inventory/transfers/route.go +++ b/internal/modules/inventory/transfers/route.go @@ -18,5 +18,6 @@ func TransferRoutes(v1 fiber.Router, u user.UserService, s transfer.TransferServ route.Get("/", m.RequirePermissions(m.P_TransferGetAll), ctrl.GetAll) route.Post("/", m.RequirePermissions(m.P_TransferCreateOne), ctrl.CreateOne) route.Get("/:id", m.RequirePermissions(m.P_TransferGetOne), ctrl.GetOne) + route.Delete("/:id", m.RequirePermissions(m.P_TransferDeleteOne), ctrl.DeleteOne) } diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 9cf4789e..8d4ccdfe 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -5,13 +5,14 @@ import ( "errors" "fmt" "mime/multipart" + "sort" "strings" + "time" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" - fifoV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" @@ -25,12 +26,14 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type TransferService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.StockTransfer, error) CreateOne(ctx *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) + DeleteOne(ctx *fiber.Ctx, id uint) error } type transferService struct { @@ -51,6 +54,15 @@ type transferService struct { ExpenseBridge TransferExpenseBridge } +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, @@ -106,6 +118,7 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit transfers, total, err := s.StockTransferRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + db = db.Where("stock_transfers.deleted_at IS NULL") if scope.Restrict { if len(scope.IDs) == 0 { return db.Where("1 = 0") @@ -147,6 +160,7 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e Joins("JOIN warehouses w_from ON w_from.id = stock_transfers.from_warehouse_id"). Joins("JOIN warehouses w_to ON w_to.id = stock_transfers.to_warehouse_id"). Where("stock_transfers.id = ?", id). + Where("stock_transfers.deleted_at IS NULL"). Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs). Count(&count).Error; err != nil { return nil, err @@ -157,7 +171,7 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e } transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { - return s.withRelations(db) + return s.withRelations(db).Where("stock_transfers.deleted_at IS NULL") }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -513,18 +527,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal", product.ProductID)) } - if strings.EqualFold(flagGroupCode, "AYAM") && outUsageQty > 0 { - if err := s.allocatePopulationForStockTransferOut( - c.Context(), - tx, - detail, - uint(*detail.SourceProductWarehouseID), - outUsageQty, - ); err != nil { - return err - } - } - stockLogDecrease := &entity.StockLog{ ProductWarehouseId: uint(*detail.SourceProductWarehouseID), CreatedBy: uint(actorID), @@ -633,55 +635,208 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return result, nil } -func (s *transferService) allocatePopulationForStockTransferOut( - ctx context.Context, - tx *gorm.DB, - detail *entity.StockTransferDetail, - sourceProductWarehouseID uint, - consumeQty float64, -) error { - if consumeQty <= 0 { - return nil +func (s *transferService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := s.ensureTransferAccess(c.Context(), id, c); err != nil { + return err } - if tx == nil { - return errors.New("transaction is required") - } - if detail == nil || detail.Id == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Data transfer detail tidak valid") - } - if sourceProductWarehouseID == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Gudang sumber tidak valid") + if s.FifoStockV2Svc == nil { + return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available") } - pw, err := s.ProductWarehouseRepo.WithTx(tx).GetByID(ctx, sourceProductWarehouseID, nil) + actorID, err := m.ActorIDFromContext(c) if err != nil { return err } - if pw.ProjectFlockKandangId == nil || *pw.ProjectFlockKandangId == 0 { + + var deletedDetails []entity.StockTransferDetail + err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + stockLogRepoTx := rStockLogs.NewStockLogRepository(tx) + + var transfer entity.StockTransfer + if err := tx.WithContext(c.Context()). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ?", uint64(id)). + Where("deleted_at IS NULL"). + Take(&transfer).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Transfer dengan ID %d tidak ditemukan", id)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data transfer") + } + + var details []entity.StockTransferDetail + if err := tx.WithContext(c.Context()). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("stock_transfer_id = ?", transfer.Id). + Where("deleted_at IS NULL"). + Order("id ASC"). + Find(&details).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil detail transfer") + } + if len(details) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Transfer tidak memiliki detail produk") + } + + detailIDs := make([]uint64, 0, len(details)) + for _, detail := range details { + detailIDs = append(detailIDs, detail.Id) + } + if err := s.ensureDeletePolicyForDownstreamConsumption(c.Context(), tx, detailIDs); err != nil { + return err + } + + type reflowKey struct { + flagGroupCode string + productWarehouseID uint + } + destReflows := make(map[reflowKey]struct{}) + + for _, detail := range details { + if detail.SourceProductWarehouseID == nil || *detail.SourceProductWarehouseID == 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Detail transfer %d tidak memiliki source product warehouse valid", detail.Id)) + } + if detail.DestProductWarehouseID == nil || *detail.DestProductWarehouseID == 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Detail transfer %d tidak memiliki destination product warehouse valid", detail.Id)) + } + + flagGroupCode, err := s.resolveTransferFlagGroup(c.Context(), tx, uint(detail.ProductId)) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("FIFO v2 route tidak ditemukan untuk produk %d: %v", detail.ProductId, err)) + } + + rollbackRes, err := s.FifoStockV2Svc.Rollback(c.Context(), commonSvc.FifoStockV2RollbackRequest{ + ProductWarehouseID: uint(*detail.SourceProductWarehouseID), + Usable: commonSvc.FifoStockV2Ref{ + ID: uint(detail.Id), + LegacyTypeKey: fifo.UsableKeyStockTransferOut.String(), + FunctionCode: "STOCK_TRANSFER_OUT", + }, + Reason: fmt.Sprintf("transfer delete #%s", transfer.MovementNumber), + Tx: tx, + }) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback FIFO v2 transfer detail %d: %v", detail.Id, err)) + } + + releasedQty := 0.0 + if rollbackRes != nil { + releasedQty = rollbackRes.ReleasedQty + } + if detail.UsageQty > 1e-6 && releasedQty < detail.UsageQty-1e-6 { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Rollback FIFO v2 source transfer detail %d tidak lengkap. Dibutuhkan %.3f, terlepas %.3f", detail.Id, detail.UsageQty, releasedQty), + ) + } + + if releasedQty > 1e-6 { + if err := s.appendStockLog( + c.Context(), + stockLogRepoTx, + uint(*detail.SourceProductWarehouseID), + actorID, + releasedQty, + 0, + uint(detail.Id), + fmt.Sprintf("TRANSFER DELETE #%s", transfer.MovementNumber), + ); err != nil { + return err + } + } + + destDecreaseQty := detail.TotalQty + if destDecreaseQty <= 1e-6 { + destDecreaseQty = detail.UsageQty + } + if destDecreaseQty > 1e-6 { + if err := s.appendStockLog( + c.Context(), + stockLogRepoTx, + uint(*detail.DestProductWarehouseID), + actorID, + 0, + destDecreaseQty, + uint(detail.Id), + fmt.Sprintf("TRANSFER DELETE #%s", transfer.MovementNumber), + ); err != nil { + return err + } + } + + destReflows[reflowKey{ + flagGroupCode: flagGroupCode, + productWarehouseID: uint(*detail.DestProductWarehouseID), + }] = struct{}{} + } + + now := time.Now().UTC() + if err := tx.WithContext(c.Context()). + Where("stock_transfer_detail_id IN ?", detailIDs). + Delete(&entity.StockTransferDeliveryItem{}).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus item delivery transfer") + } + if err := tx.WithContext(c.Context()). + Model(&entity.StockTransferDelivery{}). + Where("stock_transfer_id = ?", transfer.Id). + Where("deleted_at IS NULL"). + Updates(map[string]any{ + "deleted_at": now, + "updated_at": now, + }).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus delivery transfer") + } + if err := tx.WithContext(c.Context()). + Model(&entity.StockTransferDetail{}). + Where("id IN ?", detailIDs). + Where("deleted_at IS NULL"). + Updates(map[string]any{ + "deleted_at": now, + "updated_at": now, + }).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus detail transfer") + } + + asOf := transfer.TransferDate + for key := range destReflows { + if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: key.flagGroupCode, + ProductWarehouseID: key.productWarehouseID, + AsOf: &asOf, + Tx: tx, + }); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow stok tujuan saat delete transfer: %v", err)) + } + } + + if err := tx.WithContext(c.Context()). + Model(&entity.StockTransfer{}). + Where("id = ?", transfer.Id). + Where("deleted_at IS NULL"). + Updates(map[string]any{ + "deleted_at": now, + "updated_at": now, + }).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus transfer") + } + + deletedDetails = append(deletedDetails, details...) return nil - } - - populations, err := s.ProjectFlockPopulationRepo.WithTx(tx).GetByProjectFlockKandangIDAndProductWarehouseID( - ctx, - *pw.ProjectFlockKandangId, - sourceProductWarehouseID, - ) + }) if err != nil { - return err - } - if len(populations) == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk transfer") + if fiberErr, ok := err.(*fiber.Error); ok { + return fiberErr + } + return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus transfer") } - return fifoV2.AllocatePopulationConsumption( - ctx, - tx, - populations, - sourceProductWarehouseID, - fifo.UsableKeyStockTransferOut.String(), - uint(detail.Id), - consumeQty, - ) + if len(deletedDetails) > 0 && s.ExpenseBridge != nil { + if err := s.ExpenseBridge.OnItemsDeleted(c.Context(), uint64(id), deletedDetails); err != nil { + s.Log.Errorf("Failed to cleanup transfer expense link for transfer_id=%d: %+v", id, err) + return fiber.NewError(fiber.StatusInternalServerError, "Transfer berhasil dihapus, namun sinkronisasi expense gagal. Silakan cek modul expense") + } + } + + return nil } func (s *transferService) resolveTransferFlagGroup( @@ -757,3 +912,264 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa return uint(projectFlockKandang.Id), nil } + +func (s *transferService) ensureTransferAccess(ctx context.Context, id uint, c *fiber.Ctx) error { + scope, err := m.ResolveLocationScope(c, s.StockTransferRepo.DB()) + if err != nil { + return err + } + if !scope.Restrict { + return nil + } + if len(scope.IDs) == 0 { + return fiber.NewError(fiber.StatusNotFound, "Transfer not found") + } + + var count int64 + if err := s.StockTransferRepo.DB().WithContext(ctx). + Table("stock_transfers"). + Joins("JOIN warehouses w_from ON w_from.id = stock_transfers.from_warehouse_id"). + Joins("JOIN warehouses w_to ON w_to.id = stock_transfers.to_warehouse_id"). + Where("stock_transfers.id = ?", id). + Where("stock_transfers.deleted_at IS NULL"). + Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs). + Count(&count).Error; err != nil { + return err + } + if count == 0 { + return fiber.NewError(fiber.StatusNotFound, "Transfer not found") + } + + return nil +} + +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) + } + + 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, 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 "-" + } + + dependencyMap := make(map[string]map[uint64]struct{}) + for _, row := range rows { + label := mapTransferDownstreamUsableLabel(row.UsableType) + 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) + + details := make([]string, 0, len(labels)) + for _, label := range labels { + ids := sortedUint64Keys(dependencyMap[label]) + details = append(details, fmt.Sprintf("%s=%s", label, joinUint64(ids))) + } + + 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 { + switch strings.ToUpper(strings.TrimSpace(usableType)) { + case fifo.UsableKeyRecordingStock.String(), fifo.UsableKeyRecordingDepletion.String(): + return "Recording" + case fifo.UsableKeyProjectChickin.String(): + return "Chickin" + case fifo.UsableKeyMarketingDelivery.String(): + return "Marketing" + case fifo.UsableKeyTransferToLayingOut.String(): + return "TransferToLaying" + case fifo.UsableKeyStockTransferOut.String(): + return "TransferStock" + case fifo.UsableKeyAdjustmentOut.String(): + return "Adjustment" + default: + return strings.ToUpper(strings.TrimSpace(usableType)) + } +} + +func sortedUint64Keys(input map[uint64]struct{}) []uint64 { + if len(input) == 0 { + return nil + } + out := make([]uint64, 0, len(input)) + for id := range input { + if id == 0 { + continue + } + out = append(out, id) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +func joinUint64(values []uint64) string { + if len(values) == 0 { + return "-" + } + parts := make([]string, 0, len(values)) + for _, value := range values { + parts = append(parts, fmt.Sprintf("%d", value)) + } + return strings.Join(parts, "|") +} + +func (s *transferService) appendStockLog( + ctx context.Context, + stockLogRepo rStockLogs.StockLogRepository, + productWarehouseID uint, + actorID uint, + increase float64, + decrease float64, + loggableID uint, + notes string, +) error { + if productWarehouseID == 0 || (increase <= 1e-6 && decrease <= 1e-6) { + return nil + } + + stockLog := &entity.StockLog{ + ProductWarehouseId: productWarehouseID, + CreatedBy: actorID, + Increase: increase, + Decrease: decrease, + LoggableType: string(utils.StockLogTypeTransfer), + LoggableId: loggableID, + Notes: notes, + } + + stockLogs, err := stockLogRepo.GetByProductWarehouse(ctx, productWarehouseID, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + stockLog.Stock = latestStockLog.Stock + increase - decrease + } else { + stockLog.Stock = increase - decrease + } + if err := stockLogRepo.CreateOne(ctx, stockLog, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat stock log saat delete transfer") + } + + return nil +} diff --git a/internal/modules/master/production-standards/services/production-standard.service.go b/internal/modules/master/production-standards/services/production-standard.service.go index c2841708..406dccdc 100644 --- a/internal/modules/master/production-standards/services/production-standard.service.go +++ b/internal/modules/master/production-standards/services/production-standard.service.go @@ -6,6 +6,7 @@ import ( "fmt" "strings" + "gitlab.com/mbugroup/lti-api.git/internal/config" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" @@ -343,17 +344,22 @@ func (s productionStandardService) EnsureWeekStart(ctx context.Context, standard return nil } + layingWeekStart := config.LayingWeekStart() + switch strings.ToUpper(category) { case string(utils.ProjectFlockCategoryLaying): details, err := s.ProductionStandardDetailRepo.GetByProductionStandardID(ctx, standardID) if err != nil { return err } - startWeek := 0 - if len(details) > 0 { - startWeek = details[0].Week + if len(details) == 0 { + return fiber.NewError( + fiber.StatusBadRequest, + "Standart production tidak tersedia untuk kategori laying", + ) } - if startWeek != 18 { + startWeek := details[0].Week + if startWeek > layingWeekStart { return fiber.NewError(fiber.StatusBadRequest, "Week tidak sesuai dengan standart kategori project flock") } case string(utils.ProjectFlockCategoryGrowing): @@ -361,10 +367,13 @@ func (s productionStandardService) EnsureWeekStart(ctx context.Context, standard if err != nil { return err } - startWeek := 0 - if len(details) > 0 { - startWeek = details[0].Week + if len(details) == 0 { + return fiber.NewError( + fiber.StatusBadRequest, + "Standart production tidak tersedia untuk kategori growing", + ) } + startWeek := details[0].Week if startWeek != 1 { return fiber.NewError(fiber.StatusBadRequest, "Week tidak sesuai dengan standart kategori project flock") } @@ -381,7 +390,7 @@ func (s productionStandardService) EnsureWeekAvailable(ctx context.Context, stan upperCategory := strings.ToUpper(category) weekBase := 1 if upperCategory == string(utils.ProjectFlockCategoryLaying) { - weekBase = 18 + weekBase = config.LayingWeekStart() } week := ((day - 1) / 7) + weekBase if week <= 0 { diff --git a/internal/modules/production/chickins/controllers/chickin.controller.go b/internal/modules/production/chickins/controllers/chickin.controller.go index 7f8e0d5b..0d9c67e0 100644 --- a/internal/modules/production/chickins/controllers/chickin.controller.go +++ b/internal/modules/production/chickins/controllers/chickin.controller.go @@ -151,25 +151,25 @@ func (u *ChickinController) GetOne(c *fiber.Ctx) error { // }) // } -// func (u *ChickinController) DeleteOne(c *fiber.Ctx) error { -// param := c.Params("id") +func (u *ChickinController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") -// id, err := strconv.Atoi(param) -// if err != nil { -// return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") -// } + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } -// if err := u.ChickinService.DeleteOne(c, uint(id)); err != nil { -// return err -// } + if err := u.ChickinService.DeleteOne(c, uint(id)); err != nil { + return err + } -// return c.Status(fiber.StatusOK). -// JSON(response.Common{ -// Code: fiber.StatusOK, -// Status: "success", -// Message: "Delete chickin successfully", -// }) -// } + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete chickin successfully", + }) +} func (u *ChickinController) Approval(c *fiber.Ctx) error { req := new(validation.Approve) diff --git a/internal/modules/production/chickins/dto/chickin.dto.go b/internal/modules/production/chickins/dto/chickin.dto.go index 8a4b0d09..16d51a2d 100644 --- a/internal/modules/production/chickins/dto/chickin.dto.go +++ b/internal/modules/production/chickins/dto/chickin.dto.go @@ -3,6 +3,7 @@ package dto import ( "time" + "gitlab.com/mbugroup/lti-api.git/internal/config" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" areaRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" flockRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" @@ -35,13 +36,13 @@ type ChickinRelationDTO struct { } type ProjectFlockDTO struct { - Id uint `json:"id"` - Period int `json:"period"` - Category string `json:"category"` - Flock *flockRelationDTO.FlockRelationDTO `json:"flock"` - Area *areaRelationDTO.AreaRelationDTO `json:"area"` - StandardFcr *float64 `json:"standard_fcr"` - Location *locationRelationDTO.LocationRelationDTO `json:"location"` + Id uint `json:"id"` + Period int `json:"period"` + Category string `json:"category"` + Flock *flockRelationDTO.FlockRelationDTO `json:"flock"` + Area *areaRelationDTO.AreaRelationDTO `json:"area"` + StandardFcr *float64 `json:"standard_fcr"` + Location *locationRelationDTO.LocationRelationDTO `json:"location"` } type ProjectFlockKandangDTO struct { @@ -123,13 +124,13 @@ func ToProjectFlockDTO(pfk entity.ProjectFlockKandang) ProjectFlockDTO { location = &mapped } return ProjectFlockDTO{ - Id: e.Id, - Period: pfk.Period, - Category: e.Category, - Flock: flock, - Area: area, + Id: e.Id, + Period: pfk.Period, + Category: e.Category, + Flock: flock, + Area: area, StandardFcr: resolveProjectFlockStandardFcr(e), - Location: location, + Location: location, } } @@ -219,7 +220,7 @@ func resolveProjectFlockStandardFcr(e entity.ProjectFlock) *float64 { } week := 1 if e.Category == string(utils.ProjectFlockCategoryLaying) { - week = 18 + week = config.LayingWeekStart() } for _, detail := range e.ProductionStandard.ProductionStandardDetails { if detail.Week == week && detail.StandardFCR != nil { diff --git a/internal/modules/production/chickins/route.go b/internal/modules/production/chickins/route.go index 103a3655..4b49969a 100644 --- a/internal/modules/production/chickins/route.go +++ b/internal/modules/production/chickins/route.go @@ -19,6 +19,6 @@ func ChickinRoutes(v1 fiber.Router, u user.UserService, s chickin.ChickinService route.Post("/",m.RequirePermissions(m.P_ChickinsCreateOne), ctrl.CreateOne) route.Get("/:id",m.RequirePermissions(m.P_ChickinsGetOne), ctrl.GetOne) // route.Patch("/:id", ctrl.UpdateOne) - // route.Delete("/:id", ctrl.DeleteOne) + route.Delete("/:id", ctrl.DeleteOne) route.Post("/approvals",m.RequirePermissions(m.P_ChickinsApproval), ctrl.Approval) } diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index a1bfeb17..3a342646 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "math" + "sort" "strings" "time" @@ -33,7 +34,10 @@ import ( "gorm.io/gorm/clause" ) -const chickinDeletePopulationGuardMessage = "Chickin tidak dapat dihapus karena sudah memiliki population. Lakukan rollback/penyesuaian population terlebih dahulu" +const ( + chickinDeletePopulationGuardMessage = "Chickin tidak dapat dihapus karena masih memiliki population aktif" + chickinDeleteDownstreamGuardMessage = "Chickin tidak bisa dihapus karena masih dipakai oleh transaksi turunan. Hapus/unexecute Marketing, Recording, Transfer, Adjustment, dan Transfer to Laying terlebih dahulu." +) type ChickinService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error) @@ -133,16 +137,31 @@ func (s chickinService) ensureNotTransferred(ctx context.Context, projectFlockKa return nil } - transfer, err := s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, projectFlockKandangID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil + // Restriction transfer->laying untuk chickin hanya berlaku pada kandang kategori growing. + if s.ProjectflockKandangRepo != nil { + pfk, err := s.ProjectflockKandangRepo.GetByIDLight(ctx, projectFlockKandangID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to resolve project flock kandang %d: %+v", projectFlockKandangID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying") } + if err == nil && pfk != nil { + category := strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category)) + if category != strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)) { + return nil + } + } + } + + checkExecuted := func(transfer *entity.LayingTransfer) bool { + return transfer != nil && transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero() + } + + sourceTransfer, err := s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, projectFlockKandangID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { s.Log.Errorf("Failed to resolve transfer laying by source kandang %d: %+v", projectFlockKandangID, err) return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying") } - - if transfer != nil && transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero() { + if checkExecuted(sourceTransfer) { return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang sudah dipindahkan ke laying") } @@ -175,6 +194,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti newChikins := make([]*entity.ProjectChickin, 0) chickinQtyMap := make(map[uint]float64) + flockCategory := strings.ToUpper(strings.TrimSpace(projectFlockKandang.ProjectFlock.Category)) for idx, chickinReq := range req.ChickinRequests { @@ -194,8 +214,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti } if productWarehouse.Product.Id != 0 { - category := strings.ToUpper(strings.TrimSpace(projectFlockKandang.ProjectFlock.Category)) - if category != string(utils.ProjectFlockCategoryGrowing) && category != string(utils.ProjectFlockCategoryLaying) { + if flockCategory != string(utils.ProjectFlockCategoryGrowing) && flockCategory != string(utils.ProjectFlockCategoryLaying) { return nil, fmt.Errorf("invalid flock category for chickin") } @@ -244,6 +263,19 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti if availableQty < 0 { availableQty = 0 } + if flockCategory == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) { + sourceAvailable, err := s.resolveLayingSourceAvailableQty(c.Context(), nil, chickinReq.ProductWarehouseId, &chickinDate) + if err != nil { + s.Log.Errorf("Failed to resolve laying transfer availability for pfk=%d pw=%d: %+v", req.ProjectFlockKandangId, chickinReq.ProductWarehouseId, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi stok transfer laying") + } + if sourceAvailable < 0 { + sourceAvailable = 0 + } + if sourceAvailable < availableQty { + availableQty = sourceAvailable + } + } chickinQtyMap[uint(idx)] = availableQty } @@ -253,6 +285,9 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + if err := s.ensurePopulationRouteScope(c.Context(), dbTransaction); err != nil { + return err + } repositoryTx := repository.NewChickinRepository(dbTransaction) existingChikins, err := repositoryTx.GetByProjectFlockKandangIDForUpdate(c.Context(), req.ProjectFlockKandangId) @@ -414,53 +449,64 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { - chickin, err := s.Repository.GetByID(c.Context(), id, nil) - if err != nil { + if _, err := s.Repository.GetByID(c.Context(), id, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Chickin not found") } return err } - if err := s.ensureNotTransferred(c.Context(), chickin.ProjectFlockKandangId); err != nil { - return err - } - hasPopulation, err := s.ProjectflockPopulationRepo.ExistsByProjectChickinID(c.Context(), chickin.Id) - if err != nil { - s.Log.Errorf("Failed to check population by chickin %d: %+v", chickin.Id, err) - return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi population chickin") - } - if hasPopulation { - return fiber.NewError(fiber.StatusBadRequest, chickinDeletePopulationGuardMessage) - } - actorID, err := m.ActorIDFromContext(c) if err != nil { return err } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + if err := s.ensurePopulationRouteScope(c.Context(), tx); err != nil { + return err + } + chickinRepoTx := repository.NewChickinRepository(tx) - if chickin.UsageQty > 0 || chickin.PendingUsageQty > 0 { - if err := s.ReleaseChickinStocks(c.Context(), tx, chickin, actorID); err != nil { + lockedChickin, err := chickinRepoTx.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + return db.Clauses(clause.Locking{Strength: "UPDATE"}) + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Chickin not found") + } + return err + } + + consumeAllocBefore, traceAllocBefore, err := s.countActiveChickinAllocations(c.Context(), tx, lockedChickin.Id) + if err != nil { + return err + } + s.Log.Infof( + "Delete chickin start id=%d usage=%.3f pending=%.3f active_consume_alloc=%d active_trace_alloc=%d", + lockedChickin.Id, + lockedChickin.UsageQty, + lockedChickin.PendingUsageQty, + consumeAllocBefore, + traceAllocBefore, + ) + + if err := s.ensureNoDownstreamConsumptionForDelete(c.Context(), tx, lockedChickin.Id); err != nil { + return err + } + + hasActiveConsumeAlloc, err := s.hasActiveChickinConsumeAllocations(c.Context(), tx, lockedChickin.Id) + if err != nil { + return err + } + + if lockedChickin.UsageQty > 0 || lockedChickin.PendingUsageQty > 0 || hasActiveConsumeAlloc { + if err := s.ReleaseChickinStocks(c.Context(), tx, lockedChickin, actorID); err != nil { return err } } - now := time.Now().UTC() - note := "delete chickin rollback" - if err := tx.WithContext(c.Context()). - Model(&entity.StockAllocation{}). - Where("usable_type = ? AND usable_id = ? AND status = ?", - fifo.UsableKeyProjectChickin.String(), - chickin.Id, - entity.StockAllocationStatusActive, - ). - Updates(map[string]any{ - "status": entity.StockAllocationStatusReleased, - "released_at": now, - "note": note, - }).Error; err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal release alokasi FIFO chickin") + + if err := s.rollbackChickinPopulation(c.Context(), tx, lockedChickin.Id); err != nil { + return err } if err := chickinRepoTx.DeleteOne(c.Context(), id); err != nil { @@ -473,10 +519,29 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { return err } - if err := s.syncChickinTraceForProductWarehouse(c.Context(), tx, chickin.ProductWarehouseId); err != nil { + reflowAsOf := normalizeDateOnlyUTC(lockedChickin.ChickInDate) + if reflowAsOf.IsZero() { + reflowAsOf = time.Now().UTC() + } + if err := s.reflowWarehouseAfterChickinDelete(c.Context(), tx, lockedChickin.ProductWarehouseId, reflowAsOf); err != nil { return err } + if err := s.syncChickinTraceForProductWarehouse(c.Context(), tx, lockedChickin.ProductWarehouseId); err != nil { + return err + } + + consumeAllocAfter, traceAllocAfter, err := s.countActiveChickinAllocations(c.Context(), tx, lockedChickin.Id) + if err != nil { + return err + } + s.Log.Infof( + "Delete chickin complete id=%d active_consume_alloc=%d active_trace_alloc=%d", + lockedChickin.Id, + consumeAllocAfter, + traceAllocAfter, + ) + return nil }) if err != nil { @@ -489,6 +554,511 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { return nil } +func (s *chickinService) resolveLayingSourceAvailableQty(ctx context.Context, tx *gorm.DB, productWarehouseID uint, asOf *time.Time) (float64, error) { + if productWarehouseID == 0 || s.FifoStockV2Svc == nil { + return 0, nil + } + + db := s.Repository.DB() + if tx != nil { + db = tx + } + + flagGroupCode, err := resolveChickinFlagGroupByProductWarehouse(ctx, db, productWarehouseID) + if err != nil { + return 0, err + } + if strings.TrimSpace(flagGroupCode) == "" { + return 0, nil + } + + gatherRows, err := s.FifoStockV2Svc.Gather(ctx, commonSvc.FifoStockV2GatherRequest{ + FlagGroupCode: flagGroupCode, + Lane: commonSvc.FifoStockV2Lane("STOCKABLE"), + AllocationPurpose: entity.StockAllocationPurposeConsume, + ProductWarehouseID: productWarehouseID, + AsOf: asOf, + Limit: 10000, + Tx: tx, + }) + if err != nil { + return 0, err + } + + available := 0.0 + for _, row := range gatherRows { + if row.AvailableQuantity <= 0 { + continue + } + available += row.AvailableQuantity + } + return available, nil +} + +func (s chickinService) ensurePopulationRouteScope(ctx context.Context, tx *gorm.DB) error { + db := tx + if db == nil { + db = s.Repository.DB() + } + if db == nil { + return nil + } + + now := time.Now().UTC() + result := db.WithContext(ctx). + Table("fifo_stock_v2_route_rules"). + Where("is_active = TRUE"). + Where("lane = ?", "STOCKABLE"). + Where("function_code = ?", "POPULATION_IN"). + Where("source_table = ?", "project_flock_populations"). + Where("(scope_sql IS NULL OR TRIM(scope_sql) = '')"). + Updates(map[string]any{ + "scope_sql": "deleted_at IS NULL", + "updated_at": now, + }) + if result.Error != nil { + s.Log.Errorf("Failed to enforce FIFO population route scope: %+v", result.Error) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi konfigurasi FIFO chickin") + } + if result.RowsAffected > 0 { + s.Log.Warnf( + "Auto-fixed FIFO population route scope for chickin flow (rows=%d)", + result.RowsAffected, + ) + } + + return nil +} + +func (s *chickinService) ensureNoDownstreamConsumptionForDelete(ctx context.Context, tx *gorm.DB, chickinID uint) error { + if chickinID == 0 { + return nil + } + + db := s.Repository.DB().WithContext(ctx) + if tx != nil { + db = tx.WithContext(ctx) + } + + type downstreamRow struct { + UsableType string `gorm:"column:usable_type"` + UsableID uint `gorm:"column:usable_id"` + } + + var rows []downstreamRow + dependencyTypes := []string{ + fifo.UsableKeyMarketingDelivery.String(), + fifo.UsableKeyRecordingStock.String(), + fifo.UsableKeyRecordingDepletion.String(), + fifo.UsableKeyStockTransferOut.String(), + fifo.UsableKeyAdjustmentOut.String(), + fifo.UsableKeyTransferToLayingOut.String(), + } + + query := ` +WITH chickin_sources AS ( + SELECT DISTINCT sa.stockable_type, sa.stockable_id + FROM stock_allocations sa + WHERE sa.usable_type = ? + AND sa.usable_id = ? + AND sa.status = ? + AND sa.allocation_purpose = ? + AND sa.deleted_at IS NULL +), +downstream_by_population AS ( + SELECT sa.usable_type, sa.usable_id + FROM project_flock_populations pfp + JOIN stock_allocations sa + ON sa.stockable_type = ? + AND sa.stockable_id = pfp.id + WHERE pfp.project_chickin_id = ? + AND pfp.deleted_at IS NULL + AND sa.status = ? + AND sa.allocation_purpose = ? + AND sa.deleted_at IS NULL + AND sa.usable_type IN ? +), +downstream_by_source AS ( + SELECT sa.usable_type, sa.usable_id + FROM chickin_sources cs + JOIN stock_allocations sa + ON sa.stockable_type = cs.stockable_type + AND sa.stockable_id = cs.stockable_id + WHERE sa.status = ? + AND sa.allocation_purpose = ? + AND sa.deleted_at IS NULL + AND sa.usable_type IN ? +) +SELECT dep.usable_type, dep.usable_id +FROM ( + SELECT usable_type, usable_id FROM downstream_by_population + UNION + SELECT usable_type, usable_id FROM downstream_by_source +) dep +` + + if err := db.Raw( + query, + fifo.UsableKeyProjectChickin.String(), + chickinID, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + fifo.StockableKeyProjectFlockPopulation.String(), + chickinID, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + dependencyTypes, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + dependencyTypes, + ).Scan(&rows).Error; err != nil { + s.Log.Errorf("Failed to validate downstream consumption for chickin %d: %+v", chickinID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transaksi turunan chickin") + } + + if len(rows) == 0 { + return nil + } + + marketingIDs := make(map[uint]struct{}) + recordingIDs := make(map[uint]struct{}) + transferIDs := make(map[uint]struct{}) + adjustmentIDs := make(map[uint]struct{}) + transferLayingIDs := make(map[uint]struct{}) + orphanIDs := make(map[string]map[uint]struct{}) + + for _, row := range rows { + exists, existsErr := s.usableReferenceExistsForChickinDelete(ctx, db, row.UsableType, row.UsableID) + if existsErr != nil { + s.Log.Errorf("Failed to validate downstream usable reference %s:%d for chickin %d: %+v", row.UsableType, row.UsableID, chickinID, existsErr) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi referensi transaksi turunan chickin") + } + if !exists { + if _, ok := orphanIDs[row.UsableType]; !ok { + orphanIDs[row.UsableType] = make(map[uint]struct{}) + } + orphanIDs[row.UsableType][row.UsableID] = struct{}{} + continue + } + switch row.UsableType { + case fifo.UsableKeyMarketingDelivery.String(): + marketingIDs[row.UsableID] = struct{}{} + case fifo.UsableKeyRecordingStock.String(), fifo.UsableKeyRecordingDepletion.String(): + recordingIDs[row.UsableID] = struct{}{} + case fifo.UsableKeyStockTransferOut.String(): + transferIDs[row.UsableID] = struct{}{} + case fifo.UsableKeyAdjustmentOut.String(): + adjustmentIDs[row.UsableID] = struct{}{} + case fifo.UsableKeyTransferToLayingOut.String(): + transferLayingIDs[row.UsableID] = struct{}{} + } + } + if len(orphanIDs) > 0 { + orphanDetails := make([]string, 0, len(orphanIDs)) + for usableType, idsMap := range orphanIDs { + ids := sortedIDs(idsMap) + if len(ids) == 0 { + continue + } + orphanDetails = append(orphanDetails, fmt.Sprintf("%s=%s", usableType, joinUint(ids))) + } + sort.Strings(orphanDetails) + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf( + "Delete chickin diblok karena ditemukan orphan stock allocation pada transaksi turunan: %s. Bersihkan orphan terlebih dahulu.", + strings.Join(orphanDetails, ", "), + ), + ) + } + + details := make([]string, 0, 5) + if ids := sortedIDs(marketingIDs); len(ids) > 0 { + details = append(details, fmt.Sprintf("Marketing=%s", joinUint(ids))) + } + if ids := sortedIDs(recordingIDs); len(ids) > 0 { + details = append(details, fmt.Sprintf("Recording=%s", joinUint(ids))) + } + if ids := sortedIDs(transferIDs); len(ids) > 0 { + details = append(details, fmt.Sprintf("Transfer=%s", joinUint(ids))) + } + if ids := sortedIDs(adjustmentIDs); len(ids) > 0 { + details = append(details, fmt.Sprintf("Adjustment=%s", joinUint(ids))) + } + if ids := sortedIDs(transferLayingIDs); len(ids) > 0 { + details = append(details, fmt.Sprintf("TransferToLaying=%s", joinUint(ids))) + } + + message := chickinDeleteDownstreamGuardMessage + if len(details) > 0 { + message = fmt.Sprintf("%s Dependensi aktif: %s.", message, strings.Join(details, ", ")) + } + + return fiber.NewError(fiber.StatusBadRequest, message) +} + +func (s *chickinService) usableReferenceExistsForChickinDelete(ctx context.Context, db *gorm.DB, usableType string, usableID uint) (bool, error) { + if usableID == 0 { + return false, nil + } + if db == nil { + return false, fmt.Errorf("db is required") + } + + var count int64 + switch usableType { + case fifo.UsableKeyAdjustmentOut.String(): + if err := db.WithContext(ctx). + Table("adjustment_stocks"). + Where("id = ?", usableID). + Count(&count).Error; err != nil { + return false, err + } + case fifo.UsableKeyMarketingDelivery.String(): + if err := db.WithContext(ctx). + Table("marketing_delivery_products"). + Where("id = ?", usableID). + Count(&count).Error; err != nil { + return false, err + } + case fifo.UsableKeyRecordingStock.String(): + if err := db.WithContext(ctx). + Table("recording_stocks rs"). + Joins("JOIN recordings r ON r.id = rs.recording_id"). + Where("rs.id = ?", usableID). + Where("r.deleted_at IS NULL"). + Count(&count).Error; err != nil { + return false, err + } + case fifo.UsableKeyRecordingDepletion.String(): + if err := db.WithContext(ctx). + Table("recording_depletions rd"). + Joins("JOIN recordings r ON r.id = rd.recording_id"). + Where("rd.id = ?", usableID). + Where("r.deleted_at IS NULL"). + Count(&count).Error; err != nil { + return false, err + } + case fifo.UsableKeyStockTransferOut.String(): + if err := db.WithContext(ctx). + Table("stock_transfer_details std"). + Joins("JOIN stock_transfers st ON st.id = std.stock_transfer_id"). + Where("std.id = ?", usableID). + Where("std.deleted_at IS NULL"). + Where("st.deleted_at IS NULL"). + Count(&count).Error; err != nil { + return false, err + } + case fifo.UsableKeyTransferToLayingOut.String(): + if err := db.WithContext(ctx). + Table("laying_transfers"). + Where("id = ?", usableID). + Where("deleted_at IS NULL"). + Count(&count).Error; err != nil { + return false, err + } + default: + return true, nil + } + return count > 0, nil +} + +func sortedIDs(input map[uint]struct{}) []uint { + if len(input) == 0 { + return nil + } + out := make([]uint, 0, len(input)) + for id := range input { + if id == 0 { + continue + } + out = append(out, id) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +func joinUint(values []uint) string { + if len(values) == 0 { + return "-" + } + parts := make([]string, 0, len(values)) + for _, value := range values { + parts = append(parts, fmt.Sprintf("%d", value)) + } + return strings.Join(parts, "|") +} + +func (s *chickinService) hasActiveChickinConsumeAllocations(ctx context.Context, tx *gorm.DB, chickinID uint) (bool, error) { + if tx == nil || chickinID == 0 { + return false, nil + } + + var count int64 + if err := tx.WithContext(ctx). + Model(&entity.StockAllocation{}). + Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", + fifo.UsableKeyProjectChickin.String(), + chickinID, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Count(&count).Error; err != nil { + return false, err + } + + return count > 0, nil +} + +func (s *chickinService) countActiveChickinAllocations(ctx context.Context, tx *gorm.DB, chickinID uint) (consume int64, trace int64, err error) { + if tx == nil || chickinID == 0 { + return 0, 0, nil + } + + baseQuery := tx.WithContext(ctx).Model(&entity.StockAllocation{}). + Where("usable_type = ? AND usable_id = ? AND status = ?", + fifo.UsableKeyProjectChickin.String(), + chickinID, + entity.StockAllocationStatusActive, + ) + if err := baseQuery.Session(&gorm.Session{}). + Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). + Count(&consume).Error; err != nil { + return 0, 0, err + } + if err := baseQuery.Session(&gorm.Session{}). + Where("allocation_purpose = ?", entity.StockAllocationPurposeTraceChickin). + Count(&trace).Error; err != nil { + return 0, 0, err + } + + return consume, trace, nil +} + +func (s *chickinService) reflowWarehouseAfterChickinDelete(ctx context.Context, tx *gorm.DB, productWarehouseID uint, asOf time.Time) error { + if tx == nil || productWarehouseID == 0 || s.FifoStockV2Svc == nil { + return nil + } + if err := s.ensurePopulationRouteScope(ctx, tx); err != nil { + return err + } + qtyBefore, hasQtyBefore := s.tryLoadWarehouseQty(ctx, tx, productWarehouseID) + + flagGroupCode, err := s.resolveFlagGroupByProductWarehouse(ctx, tx, productWarehouseID) + if err != nil { + s.Log.Errorf("Failed to resolve flag group for delete chickin reflow pw=%d: %+v", productWarehouseID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal sinkronisasi stok setelah delete chickin") + } + if strings.TrimSpace(flagGroupCode) == "" { + return nil + } + + result, err := s.FifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: flagGroupCode, + ProductWarehouseID: productWarehouseID, + AsOf: &asOf, + Tx: tx, + }) + if err != nil { + s.Log.Errorf("Failed to reflow warehouse after delete chickin pw=%d: %+v", productWarehouseID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal sinkronisasi stok setelah delete chickin") + } + + processedUsables := 0 + rollbackQty := 0.0 + allocateQty := 0.0 + if result != nil { + processedUsables = result.ProcessedUsables + rollbackQty = result.Rollback.ReleasedQty + allocateQty = result.Allocate.AllocatedQty + } + s.Log.Infof( + "Delete chickin warehouse reflow pw=%d processed_usables=%d rollback_qty=%.3f allocate_qty=%.3f", + productWarehouseID, + processedUsables, + rollbackQty, + allocateQty, + ) + s.logWarehouseQtySnapshot( + ctx, + tx, + productWarehouseID, + "reflow_after_delete_chickin", + 0, + hasQtyBefore, + qtyBefore, + ) + + return nil +} + +func (s *chickinService) rollbackChickinPopulation(ctx context.Context, tx *gorm.DB, chickinID uint) error { + if tx == nil || chickinID == 0 { + return nil + } + + var populationIDs []uint + if err := tx.WithContext(ctx). + Model(&entity.ProjectFlockPopulation{}). + Where("project_chickin_id = ?", chickinID). + Pluck("id", &populationIDs).Error; err != nil { + s.Log.Errorf("Failed to list population ids for chickin %d: %+v", chickinID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil population chickin") + } + if len(populationIDs) == 0 { + return nil + } + + now := time.Now().UTC() + note := "delete chickin rollback population" + releaseResult := tx.WithContext(ctx). + Model(&entity.StockAllocation{}). + Where("stockable_type = ? AND stockable_id IN ? AND status = ?", + fifo.StockableKeyProjectFlockPopulation.String(), + populationIDs, + entity.StockAllocationStatusActive, + ). + Where("NOT (usable_type = ? AND usable_id = ?)", + fifo.UsableKeyProjectChickin.String(), + chickinID, + ). + Updates(map[string]any{ + "status": entity.StockAllocationStatusReleased, + "released_at": now, + "note": note, + }) + if releaseResult.Error != nil { + err := releaseResult.Error + s.Log.Errorf("Failed to release population allocation for chickin %d: %+v", chickinID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal release alokasi population chickin") + } + if releaseResult.RowsAffected > 0 { + s.Log.Infof( + "Delete chickin rollback population id=%d released_population_alloc=%d", + chickinID, + releaseResult.RowsAffected, + ) + } + + deleteResult := tx.WithContext(ctx). + Where("id IN ?", populationIDs). + Delete(&entity.ProjectFlockPopulation{}) + if deleteResult.Error != nil { + err := deleteResult.Error + s.Log.Errorf("Failed to delete populations for chickin %d: %+v", chickinID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus population chickin") + } + if deleteResult.RowsAffected > 0 { + s.Log.Infof( + "Delete chickin rollback population id=%d deleted_population=%d", + chickinID, + deleteResult.RowsAffected, + ) + } + + return nil +} + func isForeignKeyViolation(err error) bool { if err == nil { return false @@ -561,6 +1131,9 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + if err := s.ensurePopulationRouteScope(c.Context(), dbTransaction); err != nil { + return err + } approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) chickinRepoTx := repository.NewChickinRepository(dbTransaction) @@ -818,6 +1391,9 @@ func (s *chickinService) ReplenishChickinStocks(ctx context.Context, tx *gorm.DB if asOf.IsZero() { asOf = chickin.CreatedAt } + if err := s.ensurePopulationRouteScope(ctx, tx); err != nil { + return err + } return reflowChickinScope(ctx, s.FifoStockV2Svc, tx, targetPW.Id, &asOf) } @@ -828,14 +1404,408 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, if tx == nil { return errors.New("transaction is required") } + if chickin.ProductWarehouseId == 0 { + return s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, 0, 0) + } + qtyBefore, hasQtyBefore := s.tryLoadWarehouseQty(ctx, tx, chickin.ProductWarehouseId) - if err := s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, 0, 0); err != nil { + var activeConsumeCount int64 + if err := tx.WithContext(ctx). + Model(&entity.StockAllocation{}). + Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", + fifo.UsableKeyProjectChickin.String(), + chickin.Id, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Count(&activeConsumeCount).Error; err != nil { + return err + } + + if activeConsumeCount == 0 || s.FifoStockV2Svc == nil { + s.Log.Infof( + "Release chickin stock fallback id=%d active_consume_alloc=%d fifo_available=%t", + chickin.Id, + activeConsumeCount, + s.FifoStockV2Svc != nil, + ) + if err := s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, 0, 0); err != nil { + return err + } + s.logWarehouseQtySnapshot( + ctx, + tx, + chickin.ProductWarehouseId, + "release_chickin_fallback_no_active_alloc", + chickin.Id, + hasQtyBefore, + qtyBefore, + ) + return nil + } + + shouldRestoreWarehouseQty := true + if s.ProjectflockKandangRepo != nil && chickin.ProjectFlockKandangId != 0 { + pfk, err := s.ProjectflockKandangRepo.GetByIDLight(ctx, chickin.ProjectFlockKandangId) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if err == nil && pfk != nil { + category := strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category)) + if category == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) { + shouldRestoreWarehouseQty = false + } + } + } + + if !shouldRestoreWarehouseQty { + affectedStockables, err := s.listActiveConsumeStockableRefsByUsable(ctx, tx, chickin.Id) + if err != nil { + return err + } + + now := time.Now().UTC() + releaseResult := tx.WithContext(ctx). + Model(&entity.StockAllocation{}). + Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", + fifo.UsableKeyProjectChickin.String(), + chickin.Id, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Updates(map[string]any{ + "status": entity.StockAllocationStatusReleased, + "released_at": now, + "note": "chickin rollback without qty adjust", + }) + if releaseResult.Error != nil { + err := releaseResult.Error + return err + } + s.Log.Infof( + "Release chickin stock laying id=%d released_consume_alloc=%d transfer_targets=%d stock_transfer_sources=%d purchase_sources=%d adjustment_sources=%d", + chickin.Id, + releaseResult.RowsAffected, + len(affectedStockables[fifo.StockableKeyTransferToLayingIn.String()]), + len(affectedStockables[fifo.StockableKeyStockTransferIn.String()]), + len(affectedStockables[fifo.StockableKeyPurchaseItems.String()]), + len(affectedStockables[fifo.StockableKeyAdjustmentIn.String()]), + ) + if err := s.resyncStockableSourceUsageAfterRelease(ctx, tx, affectedStockables); err != nil { + return err + } + if err := s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, 0, 0); err != nil { + return err + } + s.logWarehouseQtySnapshot( + ctx, + tx, + chickin.ProductWarehouseId, + "release_chickin_laying_no_restore", + chickin.Id, + hasQtyBefore, + qtyBefore, + ) + return nil + } + + rollbackResult, err := s.FifoStockV2Svc.Rollback(ctx, commonSvc.FifoStockV2RollbackRequest{ + ProductWarehouseID: chickin.ProductWarehouseId, + Usable: commonSvc.FifoStockV2Ref{ + ID: chickin.Id, + LegacyTypeKey: fifo.UsableKeyProjectChickin.String(), + FunctionCode: "CHICKIN_OUT", + }, + Reason: "delete/reject chickin rollback", + Tx: tx, + }) + if err != nil { + s.Log.Errorf("Failed to rollback FIFO v2 for chickin %d: %+v", chickin.Id, err) + return err + } + releasedQty := 0.0 + detailCount := 0 + if rollbackResult != nil { + releasedQty = rollbackResult.ReleasedQty + detailCount = len(rollbackResult.Details) + } + s.Log.Infof( + "Release chickin stock fifo rollback id=%d released_qty=%.3f detail_count=%d", + chickin.Id, + releasedQty, + detailCount, + ) + s.logWarehouseQtySnapshot( + ctx, + tx, + chickin.ProductWarehouseId, + "release_chickin_fifo_rollback", + chickin.Id, + hasQtyBefore, + qtyBefore, + ) + + return nil +} + +func (s *chickinService) tryLoadWarehouseQty(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (float64, bool) { + if tx == nil || productWarehouseID == 0 { + return 0, false + } + + type row struct { + Qty float64 `gorm:"column:qty"` + } + out := row{} + if err := tx.WithContext(ctx). + Table("product_warehouses"). + Select("COALESCE(qty, 0) AS qty"). + Where("id = ?", productWarehouseID). + Take(&out).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, false + } + errText := strings.ToLower(strings.TrimSpace(err.Error())) + if strings.Contains(errText, "no such column") && strings.Contains(errText, "qty") { + return 0, false + } + if strings.Contains(errText, "column") && strings.Contains(errText, "qty") && strings.Contains(errText, "does not exist") { + return 0, false + } + s.Log.Warnf("Failed to load warehouse qty snapshot pw=%d: %+v", productWarehouseID, err) + return 0, false + } + + return out.Qty, true +} + +func (s *chickinService) logWarehouseQtySnapshot( + ctx context.Context, + tx *gorm.DB, + productWarehouseID uint, + stage string, + chickinID uint, + hasBefore bool, + beforeQty float64, +) { + afterQty, hasAfter := s.tryLoadWarehouseQty(ctx, tx, productWarehouseID) + if !hasBefore && !hasAfter { + return + } + + if hasBefore && hasAfter { + s.Log.Infof( + "Warehouse qty snapshot stage=%s chickin_id=%d pw=%d before=%.3f after=%.3f delta=%.3f", + stage, + chickinID, + productWarehouseID, + beforeQty, + afterQty, + afterQty-beforeQty, + ) + return + } + + if hasAfter { + s.Log.Infof( + "Warehouse qty snapshot stage=%s chickin_id=%d pw=%d after=%.3f", + stage, + chickinID, + productWarehouseID, + afterQty, + ) + return + } + + s.Log.Infof( + "Warehouse qty snapshot stage=%s chickin_id=%d pw=%d before=%.3f", + stage, + chickinID, + productWarehouseID, + beforeQty, + ) +} + +func (s *chickinService) listActiveConsumeStockableRefsByUsable(ctx context.Context, tx *gorm.DB, chickinID uint) (map[string][]uint, error) { + result := map[string][]uint{ + fifo.StockableKeyTransferToLayingIn.String(): nil, + fifo.StockableKeyStockTransferIn.String(): nil, + fifo.StockableKeyPurchaseItems.String(): nil, + fifo.StockableKeyAdjustmentIn.String(): nil, + } + if tx == nil || chickinID == 0 { + return result, nil + } + + type row struct { + StockableType string `gorm:"column:stockable_type"` + StockableID uint `gorm:"column:stockable_id"` + } + var rows []row + if err := tx.WithContext(ctx). + Table("stock_allocations"). + Select("stockable_type, stockable_id"). + Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", + fifo.UsableKeyProjectChickin.String(), + chickinID, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Where("stockable_type IN ?", []string{ + fifo.StockableKeyTransferToLayingIn.String(), + fifo.StockableKeyStockTransferIn.String(), + fifo.StockableKeyPurchaseItems.String(), + fifo.StockableKeyAdjustmentIn.String(), + }). + Group("stockable_type, stockable_id"). + Scan(&rows).Error; err != nil { + return nil, err + } + + for _, row := range rows { + if row.StockableID == 0 { + continue + } + result[row.StockableType] = append(result[row.StockableType], row.StockableID) + } + for key, ids := range result { + result[key] = uniqueUint(ids) + } + + return result, nil +} + +func (s *chickinService) resyncStockableSourceUsageAfterRelease(ctx context.Context, tx *gorm.DB, stockableRefs map[string][]uint) error { + if tx == nil || len(stockableRefs) == 0 { + return nil + } + + if err := s.resetAndResyncUsedQuantity( + ctx, + tx, + "laying_transfer_targets", + "id", + "total_used", + fifo.StockableKeyTransferToLayingIn.String(), + stockableRefs[fifo.StockableKeyTransferToLayingIn.String()], + ); err != nil { + return err + } + + if err := s.resetAndResyncUsedQuantity( + ctx, + tx, + "stock_transfer_details", + "id", + "total_used", + fifo.StockableKeyStockTransferIn.String(), + stockableRefs[fifo.StockableKeyStockTransferIn.String()], + ); err != nil { + return err + } + + if err := s.resetAndResyncUsedQuantity( + ctx, + tx, + "purchase_items", + "id", + "total_used", + fifo.StockableKeyPurchaseItems.String(), + stockableRefs[fifo.StockableKeyPurchaseItems.String()], + ); err != nil { + return err + } + + if err := s.resetAndResyncUsedQuantity( + ctx, + tx, + "adjustment_stocks", + "id", + "total_used", + fifo.StockableKeyAdjustmentIn.String(), + stockableRefs[fifo.StockableKeyAdjustmentIn.String()], + ); err != nil { return err } return nil } +func (s *chickinService) resetAndResyncUsedQuantity( + ctx context.Context, + tx *gorm.DB, + tableName string, + idColumn string, + usedColumn string, + stockableType string, + ids []uint, +) error { + ids = uniqueUint(ids) + if tx == nil || len(ids) == 0 { + return nil + } + + if err := tx.WithContext(ctx). + Table(tableName). + Where(fmt.Sprintf("%s IN ?", idColumn), ids). + Update(usedColumn, 0).Error; err != nil { + return err + } + + query := fmt.Sprintf(` + UPDATE %s AS t + SET %s = a.used + FROM ( + SELECT stockable_id, COALESCE(SUM(qty), 0) AS used + FROM stock_allocations + WHERE stockable_type = ? + AND status = ? + AND allocation_purpose = ? + AND stockable_id IN ? + GROUP BY stockable_id + ) AS a + WHERE t.%s = a.stockable_id + `, tableName, usedColumn, idColumn) + + if err := tx.WithContext(ctx).Exec( + query, + stockableType, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ids, + ).Error; err != nil { + return err + } + + return nil +} + +func uniqueUint(values []uint) []uint { + if len(values) == 0 { + return nil + } + out := make([]uint, 0, len(values)) + seen := make(map[uint]struct{}, len(values)) + for _, value := range values { + if value == 0 { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + +func normalizeDateOnlyUTC(value time.Time) time.Time { + if value.IsZero() { + return time.Time{} + } + return time.Date(value.UTC().Year(), value.UTC().Month(), value.UTC().Day(), 0, 0, 0, 0, time.UTC) +} + func (s *chickinService) syncChickinTraceForProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) error { if productWarehouseID == 0 { return nil @@ -849,14 +1819,9 @@ func (s *chickinService) syncChickinTraceForProductWarehouse(ctx context.Context return s.syncChickinTraceForProductWarehouse(ctx, innerTx, productWarehouseID) }) } - - flagGroupCode, err := s.resolveFlagGroupByProductWarehouse(ctx, tx, productWarehouseID) - if err != nil { + if err := s.ensurePopulationRouteScope(ctx, tx); err != nil { return err } - if strings.TrimSpace(flagGroupCode) == "" { - return nil - } now := time.Now() if err := tx.WithContext(ctx). @@ -874,6 +1839,14 @@ func (s *chickinService) syncChickinTraceForProductWarehouse(ctx context.Context return err } + flagGroupCode, err := s.resolveFlagGroupByProductWarehouse(ctx, tx, productWarehouseID) + if err != nil { + return err + } + if strings.TrimSpace(flagGroupCode) == "" { + return nil + } + type chickinTraceRow struct { ID uint `gorm:"column:id"` UsageQty float64 `gorm:"column:usage_qty"` diff --git a/internal/modules/production/project-flock-kandangs/module.go b/internal/modules/production/project-flock-kandangs/module.go index 00ae03ff..aa9a7863 100644 --- a/internal/modules/production/project-flock-kandangs/module.go +++ b/internal/modules/production/project-flock-kandangs/module.go @@ -7,14 +7,14 @@ import ( "github.com/gofiber/fiber/v2" "gorm.io/gorm" - sProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/services" - rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" - rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" rExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + sProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/services" + rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" @@ -33,13 +33,14 @@ func (ProjectFlockKandangModule) RegisterRoutes(router fiber.Router, db *gorm.DB approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) + fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) // register workflow steps for chickin approvals if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowProjectFlockKandang, utils.ProjectFlockKandangApprovalSteps); err != nil { panic(fmt.Sprintf("failed to register chickin approval workflow: %v", err)) } expenseRepo := rExpense.NewExpenseRepository(db) - projectFlockKandangService := sProjectFlockKandang.NewProjectFlockKandangService(projectFlockKandangRepo, approvalService, expenseRepo, warehouseRepo, productWarehouseRepo, projectFlockPopulationRepo,kandangRepo, validate) + projectFlockKandangService := sProjectFlockKandang.NewProjectFlockKandangService(projectFlockKandangRepo, approvalService, fifoStockV2Service, expenseRepo, warehouseRepo, productWarehouseRepo, projectFlockPopulationRepo, kandangRepo, validate) userService := sUser.NewUserService(userRepo, validate) ProjectFlockKandangRoutes(router, userService, projectFlockKandangService) diff --git a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go index 4374ba25..329fab80 100644 --- a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go +++ b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go @@ -1,8 +1,10 @@ package service import ( + "context" "errors" "fmt" + "math" "strings" "time" @@ -35,6 +37,7 @@ type projectFlockKandangService struct { Validate *validator.Validate Repository repository.ProjectFlockKandangRepository ApprovalSvc commonSvc.ApprovalService + FifoStockV2Svc commonSvc.FifoStockV2Service ExpenseRepo expenseRepo.ExpenseRepository WarehouseRepo rWarehouse.WarehouseRepository ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository @@ -69,12 +72,13 @@ type ExpenseSummary struct { Reference string `json:"reference_number"` } -func NewProjectFlockKandangService(repo repository.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, expenseRepo expenseRepo.ExpenseRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, populationRepo repository.ProjectFlockPopulationRepository, kandangRepo kandangRepo.KandangRepository, validate *validator.Validate) ProjectFlockKandangService { +func NewProjectFlockKandangService(repo repository.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, fifoStockV2Svc commonSvc.FifoStockV2Service, expenseRepo expenseRepo.ExpenseRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, populationRepo repository.ProjectFlockPopulationRepository, kandangRepo kandangRepo.KandangRepository, validate *validator.Validate) ProjectFlockKandangService { return &projectFlockKandangService{ Log: utils.Log, Validate: validate, Repository: repo, ApprovalSvc: approvalSvc, + FifoStockV2Svc: fifoStockV2Svc, ExpenseRepo: expenseRepo, WarehouseRepo: warehouseRepo, ProductWarehouseRepo: productWarehouseRepo, @@ -694,7 +698,91 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous if availableQty < 0 { availableQty = 0 } + + sourceAvailable, err := s.resolveLayingSourceAvailableQty(c.Context(), nil, productWarehouse.Id, nil) + if err != nil { + return 0, err + } + if sourceAvailable < availableQty { + availableQty = sourceAvailable + } } return availableQty, nil } + +func (s projectFlockKandangService) resolveLayingSourceAvailableQty(ctx context.Context, tx *gorm.DB, productWarehouseID uint, asOf *time.Time) (float64, error) { + if productWarehouseID == 0 || s.FifoStockV2Svc == nil { + return 0, nil + } + + flagGroupCode, err := s.resolveFlagGroupByProductWarehouse(ctx, tx, productWarehouseID) + if err != nil { + return 0, err + } + if strings.TrimSpace(flagGroupCode) == "" { + return 0, nil + } + + gatherRows, err := s.FifoStockV2Svc.Gather(ctx, commonSvc.FifoStockV2GatherRequest{ + FlagGroupCode: flagGroupCode, + Lane: commonSvc.FifoStockV2Lane("STOCKABLE"), + AllocationPurpose: entity.StockAllocationPurposeConsume, + ProductWarehouseID: productWarehouseID, + AsOf: asOf, + Limit: 10000, + Tx: tx, + }) + if err != nil { + return 0, err + } + + total := 0.0 + for _, row := range gatherRows { + if row.AvailableQuantity <= 0 { + continue + } + total += row.AvailableQuantity + } + return math.Max(total, 0), nil +} + +func (s projectFlockKandangService) resolveFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (string, error) { + type row struct { + FlagGroupCode string `gorm:"column:flag_group_code"` + } + selected := row{} + + db := s.Repository.DB() + if tx != nil { + db = tx + } + + err := db.WithContext(ctx). + Table("fifo_stock_v2_route_rules rr"). + Select("rr.flag_group_code"). + Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE"). + Where("rr.is_active = TRUE"). + Where("rr.lane = 'STOCKABLE'"). + Where(` + EXISTS ( + SELECT 1 + FROM product_warehouses pw + JOIN flags f ON f.flagable_id = pw.product_id + JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE + WHERE pw.id = ? + AND f.flagable_type = ? + AND fm.flag_group_code = rr.flag_group_code + ) + `, productWarehouseID, entity.FlagableTypeProduct). + Order("fg.priority ASC, rr.id ASC"). + Limit(1). + Take(&selected).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", nil + } + return "", err + } + return strings.TrimSpace(selected.FlagGroupCode), nil +} diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index 3df6ad45..6ad70ae1 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -6,6 +6,7 @@ import ( "math" "strconv" "strings" + "time" warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/dto" @@ -62,6 +63,7 @@ func (u *ProjectflockController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), SortBy: c.Query("sort_by", ""), SortOrder: c.Query("sort_order", ""), + Status: strings.TrimSpace(c.Query("status", "")), } if area := c.QueryInt("area_id", 0); area > 0 { @@ -272,10 +274,20 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { projectFlockId := c.QueryInt("project_flock_id", 0) kandangId := c.QueryInt("kandang_id", 0) withPopulation := c.QueryBool("withpopulation", false) + recordDateRaw := strings.TrimSpace(c.Query("record_date", "")) + var recordDate *time.Time if projectFlockId == 0 || kandangId == 0 { return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id or kandang_id") } + if recordDateRaw != "" { + parsed, err := time.Parse("2006-01-02", recordDateRaw) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "record_date must be in YYYY-MM-DD format") + } + utc := parsed.UTC() + recordDate = &utc + } result, availableStock, err := u.ProjectflockService.GetProjectFlockKandangByProjectAndKandang(c, uint(projectFlockId), uint(kandangId)) if err != nil { @@ -300,6 +312,12 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { mapped := warehouseDTO.ToWarehouseRelationDTO(*warehouse) dtoResult.Warehouse = &mapped } + if isTransition, isLaying, serr := u.ProjectflockService.GetProjectFlockKandangTransferStateAtDate(c, result.Id, recordDate); serr != nil { + return serr + } else { + dtoResult.IsTransition = isTransition + dtoResult.IsLaying = isLaying + } if withPopulation { population := dtoResult.AvailableQuantity dtoResult.Population = &population diff --git a/internal/modules/production/project_flocks/dto/projectflock.dto.go b/internal/modules/production/project_flocks/dto/projectflock.dto.go index 2701134c..da175b61 100644 --- a/internal/modules/production/project_flocks/dto/projectflock.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock.dto.go @@ -3,6 +3,7 @@ package dto import ( "time" + "gitlab.com/mbugroup/lti-api.git/internal/config" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" @@ -25,17 +26,17 @@ type ProjectFlockRelationDTO struct { type ProjectFlockListDTO struct { ProjectFlockRelationDTO - Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` - Category string `json:"category"` - StandardFcr *float64 `json:"standard_fcr,omitempty"` + Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` + Category string `json:"category"` + StandardFcr *float64 `json:"standard_fcr,omitempty"` ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"` - Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` - Kandangs []KandangWithProjectFlockIdDTO `json:"kandangs,omitempty"` - ProjectBudgets []ProjectBudgetDTO `json:"project_budgets,omitempty"` - CreatedUser *userDTO.UserRelationDTO `json:"created_user"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Approval approvalDTO.ApprovalRelationDTO `json:"approval"` + Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` + Kandangs []KandangWithProjectFlockIdDTO `json:"kandangs,omitempty"` + ProjectBudgets []ProjectBudgetDTO `json:"project_budgets,omitempty"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` } type KandangWithProjectFlockIdDTO struct { @@ -212,7 +213,7 @@ func resolveProjectFlockStandardFcr(e entity.ProjectFlock) *float64 { } week := 1 if e.Category == string(utils.ProjectFlockCategoryLaying) { - week = 18 + week = config.LayingWeekStart() } for _, detail := range e.ProductionStandard.ProductionStandardDetails { if detail.Week == week && detail.StandardFCR != nil { diff --git a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go index 5c055a1d..a3034307 100644 --- a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go @@ -40,6 +40,8 @@ type ProjectFlockKandangDTO struct { AvailableQuantity float64 `json:"available_quantity"` Population *float64 `json:"population,omitempty"` ChickInDate *time.Time `json:"chick_in_date,omitempty"` + IsTransition bool `json:"is_transition"` + IsLaying bool `json:"is_laying"` } func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangDTO { diff --git a/internal/modules/production/project_flocks/module.go b/internal/modules/production/project_flocks/module.go index 98e4a630..935814b7 100644 --- a/internal/modules/production/project_flocks/module.go +++ b/internal/modules/production/project_flocks/module.go @@ -17,6 +17,7 @@ import ( rProjectBudget "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" + rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories" sProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" utils "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -35,6 +36,7 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid projectflockKandangRepo := rProjectflock.NewProjectFlockKandangRepository(db) projectFlockPopulationRepo := rProjectflock.NewProjectFlockPopulationRepository(db) recordingRepo := rRecording.NewRecordingRepository(db) + transferLayingRepo := rTransferLaying.NewTransferLayingRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) projectBudgetRepo := rProjectBudget.NewProjectBudgetRepository(db) @@ -46,7 +48,7 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid panic(fmt.Sprintf("failed to register project flock approval workflow: %v", err)) } - projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, warehouseRepo, productWarehouseRepo, projectBudgetRepo, nonstockRepo, projectFlockPopulationRepo, recordingRepo, approvalService, validate) + projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, warehouseRepo, productWarehouseRepo, projectBudgetRepo, nonstockRepo, projectFlockPopulationRepo, recordingRepo, transferLayingRepo, approvalService, validate) userService := sUser.NewUserService(userRepo, validate) ProjectflockRoutes(router, userService, projectflockService) diff --git a/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go b/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go index 36fe8cbc..361bf8a3 100644 --- a/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go +++ b/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go @@ -51,6 +51,7 @@ func (r *projectFlockPopulationRepositoryImpl) GetByProjectFlockKandangID(ctx co err := r.DB().WithContext(ctx). Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID). + Where("project_chickins.deleted_at IS NULL"). Preload("ProjectChickin"). Find(&records).Error if err != nil { @@ -87,6 +88,7 @@ func (r *projectFlockPopulationRepositoryImpl) GetByProjectFlockKandangIDAndProd err := r.DB().WithContext(ctx). Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). Where("project_chickins.project_flock_kandang_id = ? AND project_flock_populations.product_warehouse_id = ?", projectFlockKandangID, productWarehouseID). + Where("project_chickins.deleted_at IS NULL"). Find(&records).Error if err != nil { return nil, err @@ -99,8 +101,10 @@ func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProjectFlockKandangI err := r.DB().WithContext(ctx). Table("project_flock_populations"). Select("COALESCE(SUM(total_qty - total_used_qty), 0) AS available_qty"). - Joins("JOIN product_warehouses pw ON project_flock_populations.product_warehouse_id = pw.id"). - Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). + Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). + Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID). + Where("project_chickins.deleted_at IS NULL"). + Where("project_flock_populations.deleted_at IS NULL"). Scan(&total).Error if err != nil { return 0, err @@ -111,9 +115,12 @@ func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProjectFlockKandangI func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProductWarehouseID(ctx context.Context, productWarehouseID uint) (float64, error) { var total float64 err := r.DB().WithContext(ctx). - Model(&entity.ProjectFlockPopulation{}). - Where("product_warehouse_id = ?", productWarehouseID). - Select("COALESCE(SUM(total_qty - total_used_qty), 0)"). + Table("project_flock_populations"). + Select("COALESCE(SUM(project_flock_populations.total_qty - project_flock_populations.total_used_qty), 0)"). + Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). + Where("project_flock_populations.product_warehouse_id = ?", productWarehouseID). + Where("project_chickins.deleted_at IS NULL"). + Where("project_flock_populations.deleted_at IS NULL"). Scan(&total).Error if err != nil { return 0, err @@ -128,6 +135,8 @@ func (r *projectFlockPopulationRepositoryImpl) GetAvailableQtyByProjectFlockKand Select("COALESCE(SUM(total_qty - total_used_qty), 0) AS total_qty"). Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID). + Where("project_chickins.deleted_at IS NULL"). + Where("project_flock_populations.deleted_at IS NULL"). Scan(&total).Error if err != nil { return 0, err @@ -145,6 +154,8 @@ func (r *projectFlockPopulationRepositoryImpl) GetTotalChickInByProjectFlockKand Select("COALESCE(SUM(project_flock_populations.total_qty - project_flock_populations.total_used_qty), 0) AS total_qty"). Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID). + Where("project_chickins.deleted_at IS NULL"). + Where("project_flock_populations.deleted_at IS NULL"). Scan(&total).Error if err != nil { return 0, err diff --git a/internal/modules/production/project_flocks/repositories/projectflock.repository.go b/internal/modules/production/project_flocks/repositories/projectflock.repository.go index cd7aaba7..6fab653f 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock.repository.go @@ -8,6 +8,7 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) @@ -110,6 +111,28 @@ func (r *ProjectflockRepositoryImpl) applyQueryFilters(db *gorm.DB, params *vali AND pfk.kandang_id IN ? )`, params.KandangIds) } + if params.Status != "" { + db = db.Where(` + EXISTS ( + SELECT 1 + FROM approvals latest_approval + WHERE latest_approval.approvable_type = ? + AND latest_approval.approvable_id = project_flocks.id + AND latest_approval.id = ( + SELECT a2.id + FROM approvals a2 + WHERE a2.approvable_type = ? + AND a2.approvable_id = project_flocks.id + ORDER BY a2.id DESC + LIMIT 1 + ) + AND LOWER(latest_approval.step_name) = LOWER(?) + )`, + utils.ApprovalWorkflowProjectFlock.String(), + utils.ApprovalWorkflowProjectFlock.String(), + params.Status, + ) + } db = r.applySearchFilters(db, params.Search) diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 4caf7540..f7812d0c 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -23,6 +23,7 @@ import ( pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" + transferLayingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories" uniformityRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories" purchaseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" utils "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -44,6 +45,8 @@ type ProjectflockService interface { GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, float64, error) GetProjectFlockKandangPopulation(ctx *fiber.Ctx, projectFlockKandangID uint) (float64, error) GetProjectFlockKandangChickinDate(ctx *fiber.Ctx, projectFlockKandangID uint) (*time.Time, error) + GetProjectFlockKandangTransferState(ctx *fiber.Ctx, projectFlockKandangID uint) (bool, bool, error) + GetProjectFlockKandangTransferStateAtDate(ctx *fiber.Ctx, projectFlockKandangID uint, referenceDate *time.Time) (bool, bool, error) GetPeriodSummary(ctx *fiber.Ctx, locationID uint) ([]KandangPeriodSummary, error) GetProjectPeriods(ctx *fiber.Ctx, projectIDs []uint) (map[uint]int, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) @@ -64,6 +67,7 @@ type projectflockService struct { PivotRepo repository.ProjectFlockKandangRepository PopulationRepo repository.ProjectFlockPopulationRepository RecordingRepo recordingRepo.RecordingRepository + TransferLayingRepo transferLayingRepo.TransferLayingRepository ApprovalSvc commonSvc.ApprovalService approvalWorkflow approvalutils.ApprovalWorkflowKey } @@ -85,6 +89,7 @@ func NewProjectflockService( nonstockRepo nonstockRepository.NonstockRepository, populationRepo repository.ProjectFlockPopulationRepository, recordingRepo recordingRepo.RecordingRepository, + transferLayingRepo transferLayingRepo.TransferLayingRepository, approvalSvc commonSvc.ApprovalService, validate *validator.Validate, @@ -102,6 +107,7 @@ func NewProjectflockService( PivotRepo: pivotRepo, PopulationRepo: populationRepo, RecordingRepo: recordingRepo, + TransferLayingRepo: transferLayingRepo, ApprovalSvc: approvalSvc, approvalWorkflow: utils.ApprovalWorkflowProjectFlock, } @@ -538,6 +544,70 @@ func (s projectflockService) GetProjectFlockKandangChickinDate(ctx *fiber.Ctx, p return earliest, nil } +func (s projectflockService) GetProjectFlockKandangTransferState(ctx *fiber.Ctx, projectFlockKandangID uint) (bool, bool, error) { + return s.GetProjectFlockKandangTransferStateAtDate(ctx, projectFlockKandangID, nil) +} + +func (s projectflockService) GetProjectFlockKandangTransferStateAtDate(ctx *fiber.Ctx, projectFlockKandangID uint, referenceDate *time.Time) (bool, bool, error) { + if projectFlockKandangID == 0 || s.TransferLayingRepo == nil || s.PivotRepo == nil { + return false, false, nil + } + + pfk, err := s.PivotRepo.GetByIDLight(ctx.Context(), projectFlockKandangID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, false, nil + } + s.Log.Errorf("Failed to resolve project flock kandang %d for transfer state: %+v", projectFlockKandangID, err) + return false, false, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve transfer state") + } + + category := strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category)) + var transfer *entity.LayingTransfer + switch category { + case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)): + transfer, err = s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx.Context(), projectFlockKandangID) + case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)): + transfer, err = s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx.Context(), projectFlockKandangID) + default: + return false, false, nil + } + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, false, nil + } + s.Log.Errorf("Failed to resolve transfer state for project flock kandang %d: %+v", projectFlockKandangID, err) + return false, false, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve transfer state") + } + if transfer == nil { + return false, false, nil + } + + physicalMoveDate := normalizeDateOnlyUTC(transfer.TransferDate) + if physicalMoveDate.IsZero() { + return false, false, nil + } + + economicCutoffDate := physicalMoveDate + if transfer.EconomicCutoffDate != nil && !transfer.EconomicCutoffDate.IsZero() { + economicCutoffDate = normalizeDateOnlyUTC(*transfer.EconomicCutoffDate) + } else if transfer.EffectiveMoveDate != nil && !transfer.EffectiveMoveDate.IsZero() { + economicCutoffDate = normalizeDateOnlyUTC(*transfer.EffectiveMoveDate) + } + if economicCutoffDate.Before(physicalMoveDate) { + economicCutoffDate = physicalMoveDate + } + + reference := normalizeDateOnlyUTC(time.Now().UTC()) + if referenceDate != nil && !referenceDate.IsZero() { + reference = normalizeDateOnlyUTC(referenceDate.UTC()) + } + isTransition := !reference.Before(physicalMoveDate) && reference.Before(economicCutoffDate) + isLaying := !reference.Before(economicCutoffDate) + + return isTransition, isLaying, nil +} + func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, float64, error) { idStr = strings.TrimSpace(idStr) projectFlockIdStr = strings.TrimSpace(projectFlockIdStr) @@ -579,6 +649,10 @@ func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idSt return s.GetProjectFlockKandangByProjectAndKandang(ctx, uint(pfid), uint(kid)) } +func normalizeDateOnlyUTC(value time.Time) time.Time { + return time.Date(value.UTC().Year(), value.UTC().Month(), value.UTC().Day(), 0, 0, 0, 0, time.UTC) +} + func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) { if s.PopulationRepo == nil { return 0, fiber.NewError(fiber.StatusInternalServerError, "Project flock population repository is not configured") diff --git a/internal/modules/production/project_flocks/validations/projectflock.validation.go b/internal/modules/production/project_flocks/validations/projectflock.validation.go index ca347d47..c6370133 100644 --- a/internal/modules/production/project_flocks/validations/projectflock.validation.go +++ b/internal/modules/production/project_flocks/validations/projectflock.validation.go @@ -20,6 +20,7 @@ type Query struct { LocationId uint `query:"location_id" validate:"omitempty,number,gt=0"` Period int `query:"period" validate:"omitempty,number,gt=0"` Category string `query:"category" validate:"omitempty"` + Status string `query:"status" validate:"omitempty,oneof=Pengajuan Aktif Selesai"` KandangIds []uint `query:"kandang_id" validate:"omitempty,dive,gt=0"` TransferContext string `query:"transfer_context" validate:"omitempty,oneof=transfer_to_laying"` } diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index e71bc0c5..b92d5a3c 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "gitlab.com/mbugroup/lti-api.git/internal/config" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" productWarehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/dto" @@ -86,6 +87,8 @@ type RecordingRelationDTO struct { EggWeight float64 `json:"egg_weight"` PopulationCanChange bool `json:"population_can_change"` TransferExecuted *bool `json:"transfer_executed,omitempty"` + IsTransition bool `json:"is_transition"` + IsLaying bool `json:"is_laying"` Approval approvalDTO.ApprovalRelationDTO `json:"approval"` } @@ -247,6 +250,8 @@ func toRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { EggWeight: floatValue(e.EggWeight), PopulationCanChange: boolValueDefault(e.PopulationCanChange, true), TransferExecuted: e.TransferExecuted, + IsTransition: boolValueDefault(e.IsTransition, false), + IsLaying: boolValueDefault(e.IsLaying, false), Approval: latestApproval, } } @@ -304,7 +309,7 @@ func recordingWeekValue(e entity.Recording) int { } weekBase := 1 if isLayingRecording(e) { - weekBase = 18 + weekBase = config.LayingWeekStart() } return ((day - 1) / 7) + weekBase } diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go index 5cdb6c1c..adbf6a40 100644 --- a/internal/modules/production/recordings/module.go +++ b/internal/modules/production/recordings/module.go @@ -125,6 +125,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate nonstockRepo, projectFlockPopulationRepo, recordingRepo, + transferLayingRepo, approvalService, validate, ) @@ -154,7 +155,6 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate productWarehouseRepo, warehouseRepo, approvalService, - fifoService, fifoStockV2Service, validate, ) diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 3010eca1..fae5740d 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -71,6 +71,7 @@ type RecordingRepository interface { GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (avgWeight float64, err error) GetTotalEggProductionWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeightKg float64, err error) GetAverageTargetMetricsByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, includeTargets bool) (RecordingTargetAverages, error) + GetProjectFlockKandangIDsByPopulationWarehouseIDs(ctx context.Context, tx *gorm.DB, productWarehouseIDs []uint) ([]uint, error) ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error) } @@ -874,6 +875,34 @@ func (r *RecordingRepositoryImpl) GetAverageTargetMetricsByProjectFlockKandangID return result, nil } +func (r *RecordingRepositoryImpl) GetProjectFlockKandangIDsByPopulationWarehouseIDs( + ctx context.Context, + tx *gorm.DB, + productWarehouseIDs []uint, +) ([]uint, error) { + if len(productWarehouseIDs) == 0 { + return nil, nil + } + + db := r.DB().WithContext(ctx) + if tx != nil { + db = tx.WithContext(ctx) + } + + var kandangIDs []uint + if err := db.Table("project_flock_populations pfp"). + Select("DISTINCT pc.project_flock_kandang_id"). + Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). + Where("pfp.product_warehouse_id IN ?", productWarehouseIDs). + Where("pfp.deleted_at IS NULL"). + Where("pc.deleted_at IS NULL"). + Pluck("pc.project_flock_kandang_id", &kandangIDs).Error; err != nil { + return nil, err + } + + return kandangIDs, nil +} + func (r *RecordingRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error { if projectFlockKandangID == 0 { return nil diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 8d2cc4be..7c4c9341 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -185,12 +185,14 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti rate := recordingutil.ComputeDepletionRate(prev, current, totalChick) recordings[i].DepletionRate = &rate - populationCanChange, transferExecuted, _, _, stateErr := s.evaluatePopulationMutationState(c.Context(), &recordings[i]) + populationCanChange, transferExecuted, isTransition, isLaying, _, _, stateErr := s.evaluatePopulationMutationState(c.Context(), &recordings[i]) if stateErr != nil { return nil, 0, stateErr } recordings[i].PopulationCanChange = boolPtr(populationCanChange) recordings[i].TransferExecuted = boolPtr(transferExecuted) + recordings[i].IsTransition = boolPtr(isTransition) + recordings[i].IsLaying = boolPtr(isLaying) } return recordings, total, nil } @@ -251,12 +253,14 @@ func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, erro recording.DepletionRate = &rate } - populationCanChange, transferExecuted, _, _, stateErr := s.evaluatePopulationMutationState(c.Context(), recording) + populationCanChange, transferExecuted, isTransition, isLaying, _, _, stateErr := s.evaluatePopulationMutationState(c.Context(), recording) if stateErr != nil { return nil, stateErr } recording.PopulationCanChange = boolPtr(populationCanChange) recording.TransferExecuted = boolPtr(transferExecuted) + recording.IsTransition = boolPtr(isTransition) + recording.IsLaying = boolPtr(isLaying) return recording, nil } @@ -320,6 +324,15 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent if err := s.enforceTransferRecordingRoute(ctx, pfk, recordTime, routePayload); err != nil { return nil, err } + if routePayload.DepletionCount > 0 { + if err := s.ensureDepletionMutationAllowed(ctx, &entity.Recording{ + ProjectFlockKandangId: req.ProjectFlockKandangId, + RecordDatetime: recordTime, + ProjectFlockKandang: pfk, + }, "buat"); err != nil { + return nil, err + } + } if err := s.ProjectFlockSvc.EnsureProjectFlockApproved(ctx, pfk.ProjectFlockId); err != nil { return nil, err @@ -518,9 +531,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) @@ -533,7 +543,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 } @@ -590,6 +600,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 } @@ -931,15 +944,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 } @@ -990,46 +1003,122 @@ func (s *recordingService) resolveRecordingCategory(ctx context.Context, recordi return strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category)), nil } -func (s *recordingService) evaluatePopulationMutationState(ctx context.Context, recording *entity.Recording) (bool, bool, *entity.LayingTransfer, time.Time, error) { +func (s *recordingService) evaluatePopulationMutationState(ctx context.Context, recording *entity.Recording) (bool, bool, bool, bool, *entity.LayingTransfer, time.Time, error) { if recording == nil || recording.ProjectFlockKandangId == 0 || s.TransferLayingRepo == nil { - return true, false, nil, time.Time{}, nil + return true, false, false, false, nil, time.Time{}, nil } category, err := s.resolveRecordingCategory(ctx, recording) if err != nil { s.Log.Errorf("Failed to resolve recording category for population mutation check (recording=%d): %+v", recording.Id, err) - return true, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording") - } - if category != strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)) { - return true, false, nil, time.Time{}, nil + return true, false, false, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording") } - transfer, err := s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, recording.ProjectFlockKandangId) + var transfer *entity.LayingTransfer + switch category { + case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)): + transfer, err = s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, recording.ProjectFlockKandangId) + case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)): + transfer, err = s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx, recording.ProjectFlockKandangId) + default: + return true, false, false, false, nil, time.Time{}, nil + } if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return true, false, nil, time.Time{}, nil + return true, false, false, false, nil, time.Time{}, nil } - s.Log.Errorf("Failed to resolve approved transfer by source kandang for recording %d: %+v", recording.Id, err) - return true, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording") + s.Log.Errorf("Failed to resolve approved transfer for recording %d: %+v", recording.Id, err) + return true, false, false, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording") } if transfer == nil { - return true, false, nil, time.Time{}, nil + return true, false, false, false, nil, time.Time{}, nil } transferDate := transferPhysicalMoveDate(transfer) if transferDate.IsZero() { - return true, false, transfer, transferDate, nil + return true, false, false, false, transfer, transferDate, nil } transferExecuted := transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero() recordDate := normalizeDateOnlyUTC(recording.RecordDatetime) - populationCanChange := !(transferExecuted && !recordDate.Before(transferDate)) + _, economicCutoffDate := transferRecordingWindow(transfer) + isTransition := !recordDate.Before(transferDate) && recordDate.Before(economicCutoffDate) + isLaying := !recordDate.Before(economicCutoffDate) - return populationCanChange, transferExecuted, transfer, transferDate, nil + populationCanChange := true + if category == strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)) { + populationCanChange = !(transferExecuted && !recordDate.Before(transferDate)) + + if transferExecuted && !recordDate.Before(transferDate) { + hasTargetLayingRecording, checkErr := s.hasAnyRecordingOnTransferTargets(ctx, transfer) + if checkErr != nil { + s.Log.Errorf("Failed to resolve target laying recording state for transfer %d: %+v", transfer.Id, checkErr) + return true, false, false, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi status transisi recording") + } + if hasTargetLayingRecording { + isTransition = false + isLaying = true + } else { + today := normalizeDateOnlyUTC(time.Now().UTC()) + if !today.Before(economicCutoffDate) { + isTransition = true + isLaying = false + } + } + } + } + + return populationCanChange, transferExecuted, isTransition, isLaying, transfer, transferDate, nil +} + +func (s *recordingService) hasAnyRecordingOnTransferTargets(ctx context.Context, transfer *entity.LayingTransfer) (bool, error) { + if transfer == nil || transfer.Id == 0 { + return false, nil + } + + targetIDs, err := s.transferTargetProjectFlockKandangIDs(ctx, transfer.Id) + if err != nil { + return false, err + } + if len(targetIDs) == 0 { + // Keep existing behavior for legacy or incomplete target mapping. + return true, nil + } + + var count int64 + err = s.Repository.DB(). + WithContext(ctx). + Table("recordings"). + Where("deleted_at IS NULL"). + Where("project_flock_kandangs_id IN ?", targetIDs). + Count(&count).Error + if err != nil { + return false, err + } + + return count > 0, nil +} + +func (s *recordingService) transferTargetProjectFlockKandangIDs(ctx context.Context, transferID uint) ([]uint, error) { + if transferID == 0 { + return nil, nil + } + + var targetIDs []uint + err := s.Repository.DB(). + WithContext(ctx). + Table("laying_transfer_targets"). + Where("laying_transfer_id = ?", transferID). + Where("deleted_at IS NULL"). + Pluck("target_project_flock_kandang_id", &targetIDs).Error + if err != nil { + return nil, err + } + return targetIDs, nil } func (s *recordingService) ensurePopulationMutationAllowed(ctx context.Context, recording *entity.Recording, operation string) error { - populationCanChange, _, transfer, transferDate, err := s.evaluatePopulationMutationState(ctx, recording) + populationCanChange, _, _, _, transfer, transferDate, err := s.evaluatePopulationMutationState(ctx, recording) if err != nil { return err } @@ -1056,7 +1145,7 @@ func (s *recordingService) ensurePopulationMutationAllowed(ctx context.Context, } func (s *recordingService) ensureDepletionMutationAllowed(ctx context.Context, recording *entity.Recording, operation string) error { - if recording == nil || recording.Id == 0 || recording.ProjectFlockKandangId == 0 || s.TransferLayingRepo == nil { + if recording == nil || recording.ProjectFlockKandangId == 0 || s.TransferLayingRepo == nil { return nil } @@ -1075,19 +1164,16 @@ func (s *recordingService) ensureDepletionMutationAllowed(ctx context.Context, r category = strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category)) } + if !shouldGuardDepletionMutation(category) { + return nil + } + var ( transfer *entity.LayingTransfer err error ) - switch category { - case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)): - transfer, err = s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, recording.ProjectFlockKandangId) - case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)): - transfer, err = s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx, recording.ProjectFlockKandangId) - default: - return nil - } + transfer, err = s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, recording.ProjectFlockKandangId) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil @@ -1100,22 +1186,28 @@ func (s *recordingService) ensureDepletionMutationAllowed(ctx context.Context, r } recordDate := normalizeDateOnlyUTC(recording.RecordDatetime) - physicalMoveDate := transferPhysicalMoveDate(transfer) - if physicalMoveDate.IsZero() || recordDate.Before(physicalMoveDate) { - return nil + transferNumber := strings.TrimSpace(transfer.TransferNumber) + if transferNumber == "" { + transferNumber = "-" } + executedDate := normalizeDateOnlyUTC(*transfer.ExecutedAt) return fiber.NewError( fiber.StatusBadRequest, fmt.Sprintf( - "Deplesi recording tanggal %s tidak dapat di%s karena sudah mempengaruhi transfer laying %s yang sudah dieksekusi. Lakukan unexecute transfer terlebih dahulu bila belum ada pemakaian downstream.", + "Deplesi recording tanggal %s tidak dapat di%s karena transfer laying %s sudah dieksekusi pada %s. Setelah transfer dieksekusi, mutasi deplesi di kandang growing tidak diizinkan (termasuk backdate).", recordDate.Format("2006-01-02"), operation, - transfer.TransferNumber, + transferNumber, + executedDate.Format("2006-01-02"), ), ) } +func shouldGuardDepletionMutation(category string) bool { + return strings.EqualFold(strings.TrimSpace(category), string(utils.ProjectFlockCategoryGrowing)) +} + func (s *recordingService) tryAutoExecuteTransferForRecordingCreate(c *fiber.Ctx, pfk *entity.ProjectFlockKandang, recordTime time.Time) error { if pfk == nil || pfk.Id == 0 || s.TransferLayingRepo == nil || s.TransferLayingSvc == nil { return nil @@ -1295,60 +1387,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 @@ -1590,7 +1656,7 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm var feedIntake float64 if remainingChick > 0 && usageInGrams > 0 { - feedIntake = (usageInGrams / remainingChick) * 1000 + feedIntake = usageInGrams / remainingChick updates["feed_intake"] = feedIntake recording.FeedIntake = &feedIntake } else { @@ -2010,10 +2076,7 @@ func (s *recordingService) reflowApplyRecordingStocks( } s.logStockTrace("reflow_apply:done", *refreshed, fmt.Sprintf("desired=%.3f used=%.3f pending=%.3f", desiredTotal, actualUsage, actualPending)) - logDecrease := actualUsage - if actualPending > 0 { - logDecrease += actualPending - } + logDecrease := recordingStockRollbackQty(*refreshed) if logDecrease > 0 && shouldWriteLog { log := &entity.StockLog{ ProductWarehouseId: refreshed.ProductWarehouseId, @@ -2057,11 +2120,8 @@ func (s *recordingService) reflowResetRecordingStocks( continue } - currentUsage := 0.0 - if stock.UsageQty != nil { - currentUsage = *stock.UsageQty - } - s.logStockTrace("reflow_reset:start", stock, "") + rollbackQty := recordingStockRollbackQty(stock) + s.logStockTrace("reflow_reset:start", stock, fmt.Sprintf("rollback_qty=%.3f", rollbackQty)) if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil { return err @@ -2078,13 +2138,13 @@ func (s *recordingService) reflowResetRecordingStocks( s.Log.Errorf("Failed to reflow FIFO v2 rollback for recording stock %d: %+v", stock.Id, err) return err } - s.logStockTrace("reflow_reset:done", stock, "") + s.logStockTrace("reflow_reset:done", stock, fmt.Sprintf("rollback_qty=%.3f", rollbackQty)) - if currentUsage > 0 && shouldWriteLog { + if rollbackQty > 0 && shouldWriteLog { log := &entity.StockLog{ ProductWarehouseId: stock.ProductWarehouseId, CreatedBy: actorID, - Increase: currentUsage, + Increase: rollbackQty, LoggableType: string(utils.StockLogTypeRecording), LoggableId: stock.RecordingId, Notes: note, @@ -2098,6 +2158,24 @@ func (s *recordingService) reflowResetRecordingStocks( return nil } +func recordingStockRollbackQty(stock entity.RecordingStock) float64 { + usage := 0.0 + if stock.UsageQty != nil { + usage = *stock.UsageQty + } + pending := 0.0 + if stock.PendingQty != nil { + pending = *stock.PendingQty + } + if usage < 0 { + usage = 0 + } + if pending < 0 { + pending = 0 + } + return usage + pending +} + type desiredStock struct { Usage float64 Pending float64 @@ -2316,15 +2394,10 @@ func (s *recordingService) reflowResetRecordingDepletionsOut( return errors.New("stock log repository is not available") } logState := newRecordingStockLogState() - stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx) - for _, depletion := range depletions { if depletion.Id == 0 { continue } - if err := stockAllocationRepo.ReleaseByUsable(ctx, fifo.UsableKeyRecordingDepletion.String(), depletion.Id, nil, nil); err != nil { - return err - } s.logDepletionTrace("reflow_reset:start", depletion, "") sourceWarehouseID := uint(0) @@ -2611,19 +2684,8 @@ func (s *recordingService) resyncPopulationUsageForDepletions( } if len(sourceWarehouseIDs) > 0 { - db := s.Repository.DB().WithContext(ctx) - if tx != nil { - db = tx.WithContext(ctx) - } - - var sourceKandangIDs []uint - if err := db.Table("project_flock_populations pfp"). - Select("DISTINCT pc.project_flock_kandang_id"). - Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). - Where("pfp.product_warehouse_id IN ?", sourceWarehouseIDs). - Where("pfp.deleted_at IS NULL"). - Where("pc.deleted_at IS NULL"). - Pluck("pc.project_flock_kandang_id", &sourceKandangIDs).Error; err != nil { + sourceKandangIDs, err := s.Repository.GetProjectFlockKandangIDsByPopulationWarehouseIDs(ctx, tx, sourceWarehouseIDs) + if err != nil { return err } @@ -2635,62 +2697,7 @@ func (s *recordingService) resyncPopulationUsageForDepletions( } for kandangID := range kandangIDs { - if err := s.resyncPopulationUsageByProjectFlockKandang(ctx, tx, kandangID); err != nil { - return err - } - } - - return nil -} - -func (s *recordingService) resyncPopulationUsageByProjectFlockKandang(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error { - if projectFlockKandangID == 0 { - return nil - } - - db := s.Repository.DB().WithContext(ctx) - if tx != nil { - db = tx.WithContext(ctx) - } - - var populationIDs []uint - if err := db.Table("project_flock_populations pfp"). - Select("pfp.id"). - Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). - Where("pc.project_flock_kandang_id = ?", projectFlockKandangID). - Pluck("pfp.id", &populationIDs).Error; err != nil { - return err - } - if len(populationIDs) == 0 { - return nil - } - - type usageRow struct { - StockableID uint `gorm:"column:stockable_id"` - Used float64 `gorm:"column:used"` - } - var usageRows []usageRow - if err := db.Table("stock_allocations"). - Select("stockable_id, COALESCE(SUM(qty), 0) AS used"). - Where("stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()). - Where("status = ?", entity.StockAllocationStatusActive). - Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). - Where("stockable_id IN ?", populationIDs). - Group("stockable_id"). - Scan(&usageRows).Error; err != nil { - return err - } - - if err := db.Model(&entity.ProjectFlockPopulation{}). - Where("id IN ?", populationIDs). - Update("total_used_qty", 0).Error; err != nil { - return err - } - - for _, row := range usageRows { - if err := db.Model(&entity.ProjectFlockPopulation{}). - Where("id = ?", row.StockableID). - Update("total_used_qty", row.Used).Error; err != nil { + if err := s.Repository.ResyncProjectFlockPopulationUsage(ctx, tx, kandangID); err != nil { return err } } diff --git a/internal/modules/production/transfer_layings/module.go b/internal/modules/production/transfer_layings/module.go index d7dbaa50..e297e0c7 100644 --- a/internal/modules/production/transfer_layings/module.go +++ b/internal/modules/production/transfer_layings/module.go @@ -91,7 +91,6 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val productWarehouseRepo, warehouseRepo, approvalService, - fifoService, fifoStockV2Service, validate, ) diff --git a/internal/modules/production/transfer_layings/repositories/laying_transfer_target.repository.go b/internal/modules/production/transfer_layings/repositories/laying_transfer_target.repository.go index 486008cc..9e7e62f8 100644 --- a/internal/modules/production/transfer_layings/repositories/laying_transfer_target.repository.go +++ b/internal/modules/production/transfer_layings/repositories/laying_transfer_target.repository.go @@ -2,15 +2,22 @@ package repository import ( "context" + "errors" + "time" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gorm.io/gorm" ) type LayingTransferTargetRepository interface { repository.BaseRepository[entity.LayingTransferTarget] GetByLayingTransferId(ctx context.Context, layingTransferId uint) ([]entity.LayingTransferTarget, error) + GetActiveDownstreamConsumptions(ctx context.Context, targetIDs []uint) ([]TargetDownstreamConsumption, error) + GetEarliestRecordingDateByTarget(ctx context.Context, targetProjectFlockKandangID uint, sinceDate time.Time) (*time.Time, error) + CountActiveTransferSourceConsumeAllocations(ctx context.Context, transferID uint, productWarehouseID uint) (int64, error) + SyncPopulationUsageByProjectFlockKandang(ctx context.Context, projectFlockKandangID uint) error } type LayingTransferTargetRepositoryImpl struct { @@ -18,6 +25,11 @@ type LayingTransferTargetRepositoryImpl struct { db *gorm.DB } +type TargetDownstreamConsumption struct { + UsableType string `gorm:"column:usable_type"` + UsableID uint `gorm:"column:usable_id"` +} + func NewLayingTransferTargetRepository(db *gorm.DB) LayingTransferTargetRepository { return &LayingTransferTargetRepositoryImpl{ BaseRepositoryImpl: repository.NewBaseRepository[entity.LayingTransferTarget](db), @@ -36,3 +48,123 @@ func (r *LayingTransferTargetRepositoryImpl) GetByLayingTransferId(ctx context.C } return targets, nil } + +func (r *LayingTransferTargetRepositoryImpl) GetActiveDownstreamConsumptions(ctx context.Context, targetIDs []uint) ([]TargetDownstreamConsumption, error) { + if len(targetIDs) == 0 { + return nil, nil + } + + var rows []TargetDownstreamConsumption + err := r.db.WithContext(ctx). + Table("stock_allocations"). + Select("usable_type, usable_id"). + Where("stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()). + Where("stockable_id IN ?", targetIDs). + Where("status = ?", entity.StockAllocationStatusActive). + Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). + Where("deleted_at IS NULL"). + Group("usable_type, usable_id"). + Scan(&rows).Error + if err != nil { + return nil, err + } + return rows, nil +} + +func (r *LayingTransferTargetRepositoryImpl) GetEarliestRecordingDateByTarget(ctx context.Context, targetProjectFlockKandangID uint, sinceDate time.Time) (*time.Time, error) { + if targetProjectFlockKandangID == 0 { + return nil, nil + } + + var earliest entity.Recording + query := r.db.WithContext(ctx). + Model(&entity.Recording{}). + Where("project_flock_kandangs_id = ?", targetProjectFlockKandangID). + Where("deleted_at IS NULL") + if !sinceDate.IsZero() { + query = query.Where("record_datetime >= ?", sinceDate) + } + if err := query.Order("record_datetime ASC").Limit(1).Take(&earliest).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + + d := earliest.RecordDatetime.UTC() + return &d, nil +} + +func (r *LayingTransferTargetRepositoryImpl) CountActiveTransferSourceConsumeAllocations(ctx context.Context, transferID uint, productWarehouseID uint) (int64, error) { + if transferID == 0 || productWarehouseID == 0 { + return 0, nil + } + + var count int64 + err := r.db.WithContext(ctx). + Model(&entity.StockAllocation{}). + Where("product_warehouse_id = ?", productWarehouseID). + Where("usable_type = ?", fifo.UsableKeyTransferToLayingOut.String()). + Where("usable_id = ?", transferID). + Where("status = ?", entity.StockAllocationStatusActive). + Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). + Count(&count).Error + if err != nil { + return 0, err + } + return count, nil +} + +func (r *LayingTransferTargetRepositoryImpl) SyncPopulationUsageByProjectFlockKandang(ctx context.Context, projectFlockKandangID uint) error { + if projectFlockKandangID == 0 { + return nil + } + + var populationIDs []uint + if err := r.db.WithContext(ctx). + Table("project_flock_populations pfp"). + Select("pfp.id"). + Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). + Where("pc.project_flock_kandang_id = ?", projectFlockKandangID). + Pluck("pfp.id", &populationIDs).Error; err != nil { + return err + } + if len(populationIDs) == 0 { + return nil + } + + type usageRow struct { + StockableID uint `gorm:"column:stockable_id"` + Used float64 `gorm:"column:used"` + } + var usageRows []usageRow + if err := r.db.WithContext(ctx). + Table("stock_allocations"). + Select("stockable_id, COALESCE(SUM(qty), 0) AS used"). + Where("stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()). + Where("status = ?", entity.StockAllocationStatusActive). + Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). + Where("stockable_id IN ?", populationIDs). + Group("stockable_id"). + Scan(&usageRows).Error; err != nil { + return err + } + + if err := r.db.WithContext(ctx). + Model(&entity.ProjectFlockPopulation{}). + Where("id IN ?", populationIDs). + Update("total_used_qty", 0).Error; err != nil { + return err + } + + for _, row := range usageRows { + if err := r.db.WithContext(ctx). + Model(&entity.ProjectFlockPopulation{}). + Where("id = ?", row.StockableID). + Update("total_used_qty", row.Used).Error; err != nil { + return err + } + } + + return nil +} diff --git a/internal/modules/production/transfer_layings/services/fifo_stock_v2_helper.go b/internal/modules/production/transfer_layings/services/fifo_stock_v2_helper.go index 3df37dbf..31a89ee8 100644 --- a/internal/modules/production/transfer_layings/services/fifo_stock_v2_helper.go +++ b/internal/modules/production/transfer_layings/services/fifo_stock_v2_helper.go @@ -15,6 +15,11 @@ const ( transferLayingInFunctionCode = "TRANSFER_TO_LAYING_IN" transferLayingStockableLane = "STOCKABLE" transferLayingSourceTable = "laying_transfer_targets" + + transferLayingOutFunctionCode = "TRANSFER_TO_LAYING_OUT" + transferLayingUsableLane = "USABLE" + transferLayingUsableSourceTable = "laying_transfers" + transferLayingLegacyUsableSourceTable = "laying_transfer_sources" ) func reflowTransferLayingScope( @@ -85,3 +90,90 @@ func resolveTransferLayingFlagGroupByProductWarehouse(ctx context.Context, tx *g return strings.TrimSpace(selected.FlagGroupCode), nil } + +type transferLayingUsableRouteRule struct { + FlagGroupCode string `gorm:"column:flag_group_code"` + SourceTable string `gorm:"column:source_table"` +} + +func resolveTransferLayingUsableFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (string, error) { + rows := make([]transferLayingUsableRouteRule, 0) + err := tx.WithContext(ctx). + Table("fifo_stock_v2_route_rules rr"). + Select("rr.flag_group_code, rr.source_table"). + Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE"). + Where("rr.is_active = TRUE"). + Where("rr.lane = ?", transferLayingUsableLane). + Where("rr.function_code = ?", transferLayingOutFunctionCode). + Where(` + EXISTS ( + SELECT 1 + FROM product_warehouses pw + JOIN flags f ON f.flagable_id = pw.product_id + JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE + WHERE pw.id = ? + AND f.flagable_type = ? + AND fm.flag_group_code = rr.flag_group_code + ) + `, productWarehouseID, entity.FlagableTypeProduct). + Order("rr.id ASC"). + Find(&rows).Error + if err != nil { + return "", err + } + + return validateTransferLayingUsableRouteRules(rows, productWarehouseID) +} + +func validateTransferLayingUsableRouteRules(rows []transferLayingUsableRouteRule, productWarehouseID uint) (string, error) { + if len(rows) == 0 { + return "", fmt.Errorf( + "konfigurasi FIFO v2 TRANSFER_TO_LAYING_OUT tidak ditemukan untuk source warehouse %d", + productWarehouseID, + ) + } + + var selectedFlagGroup string + hasHeaderRule := false + hasLegacyRule := false + + for _, row := range rows { + sourceTable := strings.ToLower(strings.TrimSpace(row.SourceTable)) + flagGroupCode := strings.TrimSpace(row.FlagGroupCode) + + switch sourceTable { + case transferLayingUsableSourceTable: + if flagGroupCode == "" { + return "", fmt.Errorf("konfigurasi FIFO v2 TRANSFER_TO_LAYING_OUT memiliki flag_group_code kosong") + } + hasHeaderRule = true + if selectedFlagGroup == "" { + selectedFlagGroup = flagGroupCode + continue + } + if selectedFlagGroup != flagGroupCode { + return "", fmt.Errorf( + "konfigurasi FIFO v2 TRANSFER_TO_LAYING_OUT ambigu untuk source warehouse %d", + productWarehouseID, + ) + } + case transferLayingLegacyUsableSourceTable: + hasLegacyRule = true + } + } + + if hasLegacyRule { + return "", fmt.Errorf( + "konfigurasi FIFO v2 legacy untuk TRANSFER_TO_LAYING_OUT masih aktif (source_table=%s)", + transferLayingLegacyUsableSourceTable, + ) + } + if !hasHeaderRule { + return "", fmt.Errorf( + "konfigurasi FIFO v2 TRANSFER_TO_LAYING_OUT aktif untuk source_table=%s tidak ditemukan", + transferLayingUsableSourceTable, + ) + } + + return selectedFlagGroup, nil +} diff --git a/internal/modules/production/transfer_layings/services/fifo_stock_v2_helper_test.go b/internal/modules/production/transfer_layings/services/fifo_stock_v2_helper_test.go new file mode 100644 index 00000000..7ad3d8ae --- /dev/null +++ b/internal/modules/production/transfer_layings/services/fifo_stock_v2_helper_test.go @@ -0,0 +1,56 @@ +package service + +import ( + "strings" + "testing" +) + +func TestValidateTransferLayingUsableRouteRules(t *testing.T) { + t.Run("valid header rule", func(t *testing.T) { + flagGroup, err := validateTransferLayingUsableRouteRules([]transferLayingUsableRouteRule{ + {FlagGroupCode: "AYAM", SourceTable: transferLayingUsableSourceTable}, + }, 10) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if flagGroup != "AYAM" { + t.Fatalf("unexpected flag group: %s", flagGroup) + } + }) + + t.Run("missing usable header rule", func(t *testing.T) { + _, err := validateTransferLayingUsableRouteRules(nil, 10) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(strings.ToLower(err.Error()), "tidak ditemukan") { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("legacy rule still active", func(t *testing.T) { + _, err := validateTransferLayingUsableRouteRules([]transferLayingUsableRouteRule{ + {FlagGroupCode: "AYAM", SourceTable: transferLayingUsableSourceTable}, + {FlagGroupCode: "AYAM", SourceTable: transferLayingLegacyUsableSourceTable}, + }, 10) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(strings.ToLower(err.Error()), "legacy") { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("ambiguous active header rules", func(t *testing.T) { + _, err := validateTransferLayingUsableRouteRules([]transferLayingUsableRouteRule{ + {FlagGroupCode: "AYAM", SourceTable: transferLayingUsableSourceTable}, + {FlagGroupCode: "PAKAN", SourceTable: transferLayingUsableSourceTable}, + }, 10) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(strings.ToLower(err.Error()), "ambigu") { + t.Fatalf("unexpected error: %v", err) + } + }) +} diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index 5a7ef3c8..ce267544 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "sort" "strings" "time" @@ -56,12 +57,12 @@ type transferLayingService struct { WarehouseRepo rWarehouse.WarehouseRepository StockLogRepo rStockLogs.StockLogRepository ApprovalService commonSvc.ApprovalService - FifoSvc commonSvc.FifoService FifoStockV2Svc commonSvc.FifoStockV2Service } const ( - transferToLayingFlagGroupCode = "AYAM" + transferToLayingFlagGroupCode = "AYAM" + transferLayingDeleteDownstreamGuardMessage = "Transfer laying tidak dapat dihapus karena stok target transfer sudah dipakai transaksi turunan. Hapus dependensi terkait secara manual terlebih dahulu." ) func NewTransferLayingService( @@ -74,7 +75,6 @@ func NewTransferLayingService( productWarehouseRepo rInventory.ProductWarehouseRepository, warehouseRepo rWarehouse.WarehouseRepository, approvalService commonSvc.ApprovalService, - fifoSvc commonSvc.FifoService, fifoStockV2Svc commonSvc.FifoStockV2Service, validate *validator.Validate, ) TransferLayingService { @@ -91,7 +91,6 @@ func NewTransferLayingService( WarehouseRepo: warehouseRepo, StockLogRepo: rStockLogs.NewStockLogRepository(repo.DB()), ApprovalService: approvalService, - FifoSvc: fifoSvc, FifoStockV2Svc: fifoStockV2Svc, } } @@ -610,6 +609,9 @@ func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error { if isLegacyTransfer(transfer) { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Transfer laying legacy %s tidak dapat dihapus", transfer.TransferNumber)) } + if err := s.ensureNoDownstreamConsumptionForDelete(c.Context(), nil, transfer.TransferNumber, transfer.Targets); err != nil { + return err + } approvalRepo := commonRepo.NewApprovalRepository(s.Repository.DB()) @@ -635,6 +637,16 @@ func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error { err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { repoTx := s.Repository.WithTx(dbTransaction) + // Lock header row to keep delete deterministic after single downstream guard check. + if _, err := repoTx.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + return db.Clauses(clause.Locking{Strength: "UPDATE"}) + }); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "TransferLaying not found") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer laying") + } + if err := repoTx.DeleteOne(c.Context(), id); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete transfer laying") } @@ -1026,6 +1038,38 @@ func (s transferLayingService) Unexecute(c *fiber.Ctx, id uint) (*entity.LayingT } } + flagGroupCode, err := resolveTransferLayingUsableFlagGroupByProductWarehouse( + c.Context(), + dbTransaction, + *transfer.SourceProductWarehouseId, + ) + if err != nil { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Konfigurasi FIFO v2 transfer laying tidak valid: %v", err), + ) + } + activeConsumeAllocCount, err := s.countActiveTransferSourceConsumeAllocations( + c.Context(), + dbTransaction, + transfer.Id, + *transfer.SourceProductWarehouseId, + ) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi alokasi FIFO source transfer laying") + } + if transfer.SourceUsageQty > 1e-6 && activeConsumeAllocCount == 0 { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Unexecute transfer laying %s gagal: alokasi FIFO source tidak ditemukan", transfer.TransferNumber), + ) + } + + type targetReflowKey struct { + productWarehouseID uint + } + targetReflow := make(map[targetReflowKey]struct{}) + for _, target := range targets { if target.ProductWarehouseId == nil || *target.ProductWarehouseId == 0 { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", transfer.Id)) @@ -1033,15 +1077,6 @@ func (s transferLayingService) Unexecute(c *fiber.Ctx, id uint) (*entity.LayingT if target.TotalQty <= 0 { continue } - if err := s.FifoSvc.AdjustStockableQuantity(c.Context(), commonSvc.StockAdjustRequest{ - StockableKey: fifo.StockableKeyTransferToLayingIn, - StockableID: target.Id, - ProductWarehouseID: *target.ProductWarehouseId, - Quantity: -target.TotalQty, - Tx: dbTransaction, - }); err != nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback stok target transfer laying: %v", err)) - } stockLogDecrease := &entity.StockLog{ ProductWarehouseId: *target.ProductWarehouseId, @@ -1065,23 +1100,56 @@ func (s transferLayingService) Unexecute(c *fiber.Ctx, id uint) (*entity.LayingT if err := stockLogRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar target saat unexecute") } + + if err := targetRepoTx.PatchOne(c.Context(), target.Id, map[string]any{ + "total_qty": 0, + "total_used": 0, + }, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal rollback kuantitas target transfer laying") + } + targetReflow[targetReflowKey{productWarehouseID: *target.ProductWarehouseId}] = struct{}{} + } + asOf := normalizeDateOnlyUTC(transfer.TransferDate) + for key := range targetReflow { + if err := reflowTransferLayingScope(c.Context(), s.FifoStockV2Svc, dbTransaction, key.productWarehouseID, &asOf); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback FIFO v2 target transfer laying: %v", err)) + } + } + + rollbackResult, err := s.FifoStockV2Svc.Rollback(c.Context(), commonSvc.FifoStockV2RollbackRequest{ + ProductWarehouseID: *transfer.SourceProductWarehouseId, + Usable: commonSvc.FifoStockV2Ref{ + ID: transfer.Id, + LegacyTypeKey: fifo.UsableKeyTransferToLayingOut.String(), + FunctionCode: transferLayingOutFunctionCode, + }, + Reason: fmt.Sprintf("transfer laying unexecute #%s [%s]", transfer.TransferNumber, flagGroupCode), + Tx: dbTransaction, + }) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback FIFO v2 source transfer laying: %v", err)) + } + releasedQty := 0.0 + if rollbackResult != nil { + releasedQty = rollbackResult.ReleasedQty + } + if transfer.SourceUsageQty > 1e-6 && releasedQty < transfer.SourceUsageQty-1e-6 { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf( + "Rollback FIFO v2 source transfer laying tidak lengkap. Dibutuhkan %.3f, terlepas %.3f", + transfer.SourceUsageQty, + releasedQty, + ), + ) } - asOf := normalizeDateOnlyUTC(transfer.TransferDate) if err := repoTx.PatchOne(c.Context(), transfer.Id, map[string]any{ "source_usage_qty": 0, "source_pending_usage_qty": 0, }, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal reset kuantitas source transfer laying") } - if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{ - FlagGroupCode: transferToLayingFlagGroupCode, - ProductWarehouseID: *transfer.SourceProductWarehouseId, - AsOf: &asOf, - Tx: dbTransaction, - }); err != nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback FIFO v2 source transfer laying: %v", err)) - } if err := fifoV2.ReleasePopulationConsumptionByUsable( c.Context(), dbTransaction, @@ -1183,9 +1251,6 @@ func (s *transferLayingService) executeApprovedTransferMovement( if s.FifoStockV2Svc == nil { return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available") } - if s.FifoSvc == nil { - return fiber.NewError(fiber.StatusInternalServerError, "FIFO service is not available") - } stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx) targetRepoTx := repository.NewLayingTransferTargetRepository(tx) @@ -1281,29 +1346,22 @@ func (s *transferLayingService) executeApprovedTransferMovement( return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar") } + type targetReflowKey struct { + productWarehouseID uint + } + targetReflow := make(map[targetReflowKey]struct{}) + for _, target := range targets { if target.ProductWarehouseId == nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", transfer.Id)) } - note := fmt.Sprintf("Transfer to Laying #%s", transfer.TransferNumber) - _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ - StockableKey: fifo.StockableKeyTransferToLayingIn, - StockableID: target.Id, - ProductWarehouseID: *target.ProductWarehouseId, - Quantity: target.TotalQty, - Note: ¬e, - Tx: tx, - }) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal replenish stock ke target warehouse: %v", err)) - } - if err := targetRepoTx.PatchOne(ctx, target.Id, map[string]any{ "total_qty": target.TotalQty, }, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal update target total qty") } + targetReflow[targetReflowKey{productWarehouseID: *target.ProductWarehouseId}] = struct{}{} stockLogIncrease := &entity.StockLog{ ProductWarehouseId: *target.ProductWarehouseId, @@ -1330,6 +1388,11 @@ func (s *transferLayingService) executeApprovedTransferMovement( return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk") } } + for key := range targetReflow { + if err := reflowTransferLayingScope(ctx, s.FifoStockV2Svc, tx, key.productWarehouseID, &asOf); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow FIFO v2 target transfer laying: %v", err)) + } + } return nil } @@ -1549,86 +1612,66 @@ func (s *transferLayingService) hasDownstreamRecordingOnTarget( targetProjectFlockKandangID uint, sinceDate time.Time, ) (bool, time.Time, error) { - if targetProjectFlockKandangID == 0 { - return false, time.Time{}, nil - } - - db := s.Repository.DB().WithContext(ctx) + targetRepo := s.LayingTransferTargetRepo if tx != nil { - db = tx.WithContext(ctx) + targetRepo = repository.NewLayingTransferTargetRepository(tx) } - var earliest entity.Recording - query := db.Model(&entity.Recording{}). - Where("project_flock_kandangs_id = ?", targetProjectFlockKandangID). - Where("deleted_at IS NULL") - if !sinceDate.IsZero() { - query = query.Where("record_datetime >= ?", sinceDate) - } - - if err := query.Order("record_datetime ASC").Limit(1).Take(&earliest).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return false, time.Time{}, nil - } + recordDate, err := targetRepo.GetEarliestRecordingDateByTarget(ctx, targetProjectFlockKandangID, sinceDate) + if err != nil { return false, time.Time{}, err } + if recordDate == nil { + return false, time.Time{}, nil + } + return true, normalizeDateOnlyUTC(*recordDate), nil +} - return true, normalizeDateOnlyUTC(earliest.RecordDatetime), nil +func (s *transferLayingService) countActiveTransferSourceConsumeAllocations( + ctx context.Context, + tx *gorm.DB, + transferID uint, + productWarehouseID uint, +) (int64, error) { + targetRepo := s.LayingTransferTargetRepo + if tx != nil { + targetRepo = repository.NewLayingTransferTargetRepository(tx) + } + return targetRepo.CountActiveTransferSourceConsumeAllocations(ctx, transferID, productWarehouseID) } func (s *transferLayingService) resyncPopulationUsageByProjectFlockKandang(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error { - if projectFlockKandangID == 0 { - return nil - } - - db := s.Repository.DB().WithContext(ctx) + targetRepo := s.LayingTransferTargetRepo if tx != nil { - db = tx.WithContext(ctx) + targetRepo = repository.NewLayingTransferTargetRepository(tx) } + return targetRepo.SyncPopulationUsageByProjectFlockKandang(ctx, projectFlockKandangID) +} - var populationIDs []uint - if err := db.Table("project_flock_populations pfp"). - Select("pfp.id"). - Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). - Where("pc.project_flock_kandang_id = ?", projectFlockKandangID). - Pluck("pfp.id", &populationIDs).Error; err != nil { - return err - } - if len(populationIDs) == 0 { +func sortedIDs(input map[uint]struct{}) []uint { + if len(input) == 0 { return nil } - - type usageRow struct { - StockableID uint `gorm:"column:stockable_id"` - Used float64 `gorm:"column:used"` - } - var usageRows []usageRow - if err := db.Table("stock_allocations"). - Select("stockable_id, COALESCE(SUM(qty), 0) AS used"). - Where("stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()). - Where("status = ?", entity.StockAllocationStatusActive). - Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). - Where("stockable_id IN ?", populationIDs). - Group("stockable_id"). - Scan(&usageRows).Error; err != nil { - return err - } - - if err := db.Model(&entity.ProjectFlockPopulation{}). - Where("id IN ?", populationIDs). - Update("total_used_qty", 0).Error; err != nil { - return err - } - - for _, row := range usageRows { - if err := db.Model(&entity.ProjectFlockPopulation{}). - Where("id = ?", row.StockableID). - Update("total_used_qty", row.Used).Error; err != nil { - return err + out := make([]uint, 0, len(input)) + for id := range input { + if id == 0 { + continue } + out = append(out, id) } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} - return nil +func joinUint(values []uint) string { + if len(values) == 0 { + return "-" + } + parts := make([]string, 0, len(values)) + for _, value := range values { + parts = append(parts, fmt.Sprintf("%d", value)) + } + return strings.Join(parts, "|") } func normalizeDateOnlyUTC(value time.Time) time.Time { @@ -1647,3 +1690,84 @@ func isLegacyTransfer(transfer *entity.LayingTransfer) bool { } return false } + +func (s *transferLayingService) ensureNoDownstreamConsumptionForDelete( + ctx context.Context, + tx *gorm.DB, + transferNumber string, + targets []entity.LayingTransferTarget, +) error { + targetIDs := make([]uint, 0, len(targets)) + for _, target := range targets { + if target.Id == 0 { + continue + } + targetIDs = append(targetIDs, target.Id) + } + if len(targetIDs) == 0 { + return nil + } + + targetRepo := s.LayingTransferTargetRepo + if tx != nil { + targetRepo = repository.NewLayingTransferTargetRepository(tx) + } + + rows, err := targetRepo.GetActiveDownstreamConsumptions(ctx, targetIDs) + if err != nil { + s.Log.Errorf("Failed to validate downstream consumption for transfer laying %s: %+v", transferNumber, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transaksi turunan transfer laying") + } + if len(rows) == 0 { + return nil + } + + dependencyMap := make(map[string]map[uint]struct{}) + for _, row := range rows { + label := mapTransferLayingDownstreamUsableLabel(row.UsableType) + if _, ok := dependencyMap[label]; !ok { + dependencyMap[label] = make(map[uint]struct{}) + } + dependencyMap[label][row.UsableID] = struct{}{} + } + + labels := make([]string, 0, len(dependencyMap)) + for label := range dependencyMap { + labels = append(labels, label) + } + sort.Strings(labels) + + details := make([]string, 0, len(labels)) + for _, label := range labels { + details = append(details, fmt.Sprintf("%s=%s", label, joinUint(sortedIDs(dependencyMap[label])))) + } + + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf( + "%s Transfer %s. Dependensi aktif: %s.", + transferLayingDeleteDownstreamGuardMessage, + transferNumber, + strings.Join(details, ", "), + ), + ) +} + +func mapTransferLayingDownstreamUsableLabel(usableType string) string { + switch strings.ToUpper(strings.TrimSpace(usableType)) { + case fifo.UsableKeyRecordingStock.String(), fifo.UsableKeyRecordingDepletion.String(): + return "Recording" + case fifo.UsableKeyProjectChickin.String(): + return "Chickin" + case fifo.UsableKeyMarketingDelivery.String(): + return "Marketing" + case fifo.UsableKeyTransferToLayingOut.String(): + return "TransferToLaying" + case fifo.UsableKeyStockTransferOut.String(): + return "TransferStock" + case fifo.UsableKeyAdjustmentOut.String(): + return "Adjustment" + default: + return strings.ToUpper(strings.TrimSpace(usableType)) + } +} diff --git a/internal/modules/production/uniformities/services/uniformity.service.go b/internal/modules/production/uniformities/services/uniformity.service.go index 5a747fca..7de39ef8 100644 --- a/internal/modules/production/uniformities/services/uniformity.service.go +++ b/internal/modules/production/uniformities/services/uniformity.service.go @@ -13,6 +13,7 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/config" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" @@ -380,12 +381,13 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file } } weekBase := 1 - if strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying)) { - weekBase = 18 + isLayingCategory := strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying)) + if isLayingCategory { + weekBase = config.LayingWeekStart() } if req.Week < weekBase { - if weekBase == 18 { - return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 18 for laying projects") + if isLayingCategory { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase)) } return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") } @@ -399,8 +401,8 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate uniformity week sequence") } if latestWeek == 0 && req.Week != weekBase { - if weekBase == 18 { - return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 18 for laying projects") + if isLayingCategory { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase)) } return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") } @@ -474,7 +476,7 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file }); err != nil { s.Log.Errorf("Failed to create uniformity: %+v", err) return nil, err - } + } if s.DocumentSvc != nil { actorIDCopy := actorID @@ -575,12 +577,13 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui } } weekBase := 1 - if strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying)) { - weekBase = 18 + isLayingCategory := strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying)) + if isLayingCategory { + weekBase = config.LayingWeekStart() } if targetWeek < weekBase { - if weekBase == 18 { - return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 18 for laying projects") + if isLayingCategory { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase)) } return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") } 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 diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 38fdd74b..3e002e2c 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "gitlab.com/mbugroup/lti-api.git/internal/config" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories" @@ -256,11 +257,11 @@ func (s *repportService) GetProductionResult(ctx *fiber.Ctx, params *validation. const ( recordsPerWeek = 7 - defaultStartWoa = 18 defaultStdBw = 1951 defaultBw = 0 defaultUniformText = "90% up" ) + defaultStartWoa := config.LayingWeekStart() if params.Limit <= 0 { params.Limit = 10 diff --git a/internal/utils/recording/recording_helpers.go b/internal/utils/recording/recording_helpers.go index 644f6e8d..ff95a2f7 100644 --- a/internal/utils/recording/recording_helpers.go +++ b/internal/utils/recording/recording_helpers.go @@ -6,6 +6,7 @@ import ( "strings" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/config" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -13,31 +14,31 @@ import ( ) type warnLogger interface { - Warnf(format string, args ...any) + Warnf(format string, args ...any) } type productWarehouseExistsRepo interface { - ExistsByID(ctx context.Context, id uint) (bool, error) + ExistsByID(ctx context.Context, id uint) (bool, error) } type recordingValidationRepo interface { - ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error) + ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error) } func EnsureProductWarehousesExist(ctx context.Context, repo productWarehouseExistsRepo, ids []uint) error { - if repo == nil || len(ids) == 0 { - return nil - } - for _, id := range ids { - ok, err := repo.ExistsByID(ctx, id) - if err != nil { - return err - } - if !ok { - return fmt.Errorf("product warehouse %d not found", id) - } - } - return nil + if repo == nil || len(ids) == 0 { + return nil + } + for _, id := range ids { + ok, err := repo.ExistsByID(ctx, id) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("product warehouse %d not found", id) + } + } + return nil } func EnsureProductWarehousesByFlags(ctx context.Context, repo recordingValidationRepo, ids []uint, flags []string, label string) error { @@ -82,212 +83,212 @@ func EnsureProductWarehousesByFlagsForItems[T any]( } func ComputeDepletionRate(prevRecording *entity.Recording, currentDepletion float64, totalChick int64) float64 { - base := 0.0 - if prevRecording != nil && prevRecording.TotalChickQty != nil && *prevRecording.TotalChickQty > 0 { - base = *prevRecording.TotalChickQty - } else if totalChick > 0 { - base = float64(totalChick) + currentDepletion - } - if base <= 0 { - return 0 - } - return (currentDepletion / base) * 100 + base := 0.0 + if prevRecording != nil && prevRecording.TotalChickQty != nil && *prevRecording.TotalChickQty > 0 { + base = *prevRecording.TotalChickQty + } else if totalChick > 0 { + base = float64(totalChick) + currentDepletion + } + if base <= 0 { + return 0 + } + return (currentDepletion / base) * 100 } func AttachLatestApprovals(ctx context.Context, items []entity.Recording, approvalSvc commonSvc.ApprovalService, logger warnLogger) error { - if len(items) == 0 || approvalSvc == nil { - return nil - } + if len(items) == 0 || approvalSvc == nil { + return nil + } - ids := make([]uint, 0, len(items)) - visited := make(map[uint]struct{}, len(items)) - for _, item := range items { - if item.Id == 0 { - continue - } - if _, ok := visited[item.Id]; ok { - continue - } - visited[item.Id] = struct{}{} - ids = append(ids, item.Id) - } + ids := make([]uint, 0, len(items)) + visited := make(map[uint]struct{}, len(items)) + for _, item := range items { + if item.Id == 0 { + continue + } + if _, ok := visited[item.Id]; ok { + continue + } + visited[item.Id] = struct{}{} + ids = append(ids, item.Id) + } - if len(ids) == 0 { - return nil - } + if len(ids) == 0 { + return nil + } - latestMap, err := approvalSvc.LatestByTargets(ctx, utils.ApprovalWorkflowRecording, ids, func(db *gorm.DB) *gorm.DB { - return db.Preload("ActionUser") - }) - if err != nil { - if logger != nil { - logger.Warnf("Unable to load latest approvals for recordings: %+v", err) - } - return nil - } + latestMap, err := approvalSvc.LatestByTargets(ctx, utils.ApprovalWorkflowRecording, ids, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + if logger != nil { + logger.Warnf("Unable to load latest approvals for recordings: %+v", err) + } + return nil + } - if len(latestMap) == 0 { - return nil - } + if len(latestMap) == 0 { + return nil + } - for i := range items { - if items[i].Id == 0 { - continue - } - if approval, ok := latestMap[items[i].Id]; ok { - items[i].LatestApproval = approval - } - } + for i := range items { + if items[i].Id == 0 { + continue + } + if approval, ok := latestMap[items[i].Id]; ok { + items[i].LatestApproval = approval + } + } - return nil + return nil } func AttachLatestApproval(ctx context.Context, item *entity.Recording, approvalSvc commonSvc.ApprovalService, logger warnLogger) error { - if item == nil || item.Id == 0 || approvalSvc == nil { - return nil - } + if item == nil || item.Id == 0 || approvalSvc == nil { + return nil + } - latest, err := approvalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowRecording, item.Id, func(db *gorm.DB) *gorm.DB { - return db.Preload("ActionUser") - }) - if err != nil { - if logger != nil { - logger.Warnf("Unable to load approvals for recording %d: %+v", item.Id, err) - } - return nil - } - item.LatestApproval = latest - return nil + latest, err := approvalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowRecording, item.Id, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + if logger != nil { + logger.Warnf("Unable to load approvals for recording %d: %+v", item.Id, err) + } + return nil + } + item.LatestApproval = latest + return nil } type productionStandardValues struct { - HenDay *float64 - HenHouse *float64 - FeedIntake *float64 - MaxDepletion *float64 - EggMass *float64 - EggWeight *float64 + HenDay *float64 + HenHouse *float64 + FeedIntake *float64 + MaxDepletion *float64 + EggMass *float64 + EggWeight *float64 } func AttachProductionStandards(ctx context.Context, db *gorm.DB, warnOnly bool, logger warnLogger, items ...*entity.Recording) error { - if len(items) == 0 { - return nil - } + if len(items) == 0 { + return nil + } - type standardKey struct { - standardID uint - week int - } - type standardCacheEntry struct { - values productionStandardValues - fcr *float64 - } + type standardKey struct { + standardID uint + week int + } + type standardCacheEntry struct { + values productionStandardValues + fcr *float64 + } - if db == nil { - return nil - } + if db == nil { + return nil + } - standardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) - growthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) - cache := make(map[standardKey]standardCacheEntry, len(items)) + standardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) + growthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) + cache := make(map[standardKey]standardCacheEntry, len(items)) - standardIDs := make(map[uint]struct{}, len(items)) - for _, item := range items { - if item == nil || item.ProjectFlockKandang == nil || item.ProjectFlockKandang.ProjectFlock.Id == 0 { - continue - } - if item.ProjectFlockKandang.ProjectFlock.ProductionStandardId > 0 { - standardIDs[item.ProjectFlockKandang.ProjectFlock.ProductionStandardId] = struct{}{} - } - } + standardIDs := make(map[uint]struct{}, len(items)) + for _, item := range items { + if item == nil || item.ProjectFlockKandang == nil || item.ProjectFlockKandang.ProjectFlock.Id == 0 { + continue + } + if item.ProjectFlockKandang.ProjectFlock.ProductionStandardId > 0 { + standardIDs[item.ProjectFlockKandang.ProjectFlock.ProductionStandardId] = struct{}{} + } + } - standardDetailByStd := make(map[uint]map[int]*entity.ProductionStandardDetail, len(standardIDs)) - growthDetailByStd := make(map[uint]map[int]*entity.StandardGrowthDetail, len(standardIDs)) + standardDetailByStd := make(map[uint]map[int]*entity.ProductionStandardDetail, len(standardIDs)) + growthDetailByStd := make(map[uint]map[int]*entity.StandardGrowthDetail, len(standardIDs)) - for standardID := range standardIDs { - details, err := standardDetailRepo.GetByProductionStandardID(ctx, standardID) - if err != nil { - if warnOnly { - if logger != nil { - logger.Warnf("Unable to preload production standard detail for standard %d: %+v", standardID, err) - } - } else { - return err - } - continue - } - detailMap := make(map[int]*entity.ProductionStandardDetail, len(details)) - for i := range details { - detail := details[i] - detailMap[detail.Week] = &detail - } - standardDetailByStd[standardID] = detailMap + for standardID := range standardIDs { + details, err := standardDetailRepo.GetByProductionStandardID(ctx, standardID) + if err != nil { + if warnOnly { + if logger != nil { + logger.Warnf("Unable to preload production standard detail for standard %d: %+v", standardID, err) + } + } else { + return err + } + continue + } + detailMap := make(map[int]*entity.ProductionStandardDetail, len(details)) + for i := range details { + detail := details[i] + detailMap[detail.Week] = &detail + } + standardDetailByStd[standardID] = detailMap - growths, err := growthDetailRepo.GetByProductionStandardID(ctx, standardID) - if err != nil { - if warnOnly { - if logger != nil { - logger.Warnf("Unable to preload standard growth detail for standard %d: %+v", standardID, err) - } - } else { - return err - } - continue - } - growthMap := make(map[int]*entity.StandardGrowthDetail, len(growths)) - for i := range growths { - growth := growths[i] - growthMap[growth.Week] = &growth - } - growthDetailByStd[standardID] = growthMap - } + growths, err := growthDetailRepo.GetByProductionStandardID(ctx, standardID) + if err != nil { + if warnOnly { + if logger != nil { + logger.Warnf("Unable to preload standard growth detail for standard %d: %+v", standardID, err) + } + } else { + return err + } + continue + } + growthMap := make(map[int]*entity.StandardGrowthDetail, len(growths)) + for i := range growths { + growth := growths[i] + growthMap[growth.Week] = &growth + } + growthDetailByStd[standardID] = growthMap + } - for _, item := range items { - if item == nil || item.ProjectFlockKandang == nil || item.ProjectFlockKandang.ProjectFlock.Id == 0 { - continue - } - standardID := item.ProjectFlockKandang.ProjectFlock.ProductionStandardId - if standardID == 0 { - continue - } - week := RecordingWeekValue(*item) - cacheKey := standardKey{standardID: standardID, week: week} - if cached, ok := cache[cacheKey]; ok { - applyProductionStandardValues(item, cached.values, cached.fcr) - continue - } - values := productionStandardValues{} - var fcr *float64 - if detailMap, ok := standardDetailByStd[standardID]; ok { - if detail, ok := detailMap[week]; ok { - values.HenDay = detail.TargetHenDayProduction - values.HenHouse = detail.TargetHenHouseProduction - values.EggMass = detail.TargetEggMass - values.EggWeight = detail.TargetEggWeight - fcr = detail.StandardFCR - } - } - if growthMap, ok := growthDetailByStd[standardID]; ok { - if growth, ok := growthMap[week]; ok { - values.FeedIntake = growth.FeedIntake - values.MaxDepletion = growth.MaxDepletion - } - } - cache[cacheKey] = standardCacheEntry{values: values, fcr: fcr} - applyProductionStandardValues(item, values, fcr) - } + for _, item := range items { + if item == nil || item.ProjectFlockKandang == nil || item.ProjectFlockKandang.ProjectFlock.Id == 0 { + continue + } + standardID := item.ProjectFlockKandang.ProjectFlock.ProductionStandardId + if standardID == 0 { + continue + } + week := RecordingWeekValue(*item) + cacheKey := standardKey{standardID: standardID, week: week} + if cached, ok := cache[cacheKey]; ok { + applyProductionStandardValues(item, cached.values, cached.fcr) + continue + } + values := productionStandardValues{} + var fcr *float64 + if detailMap, ok := standardDetailByStd[standardID]; ok { + if detail, ok := detailMap[week]; ok { + values.HenDay = detail.TargetHenDayProduction + values.HenHouse = detail.TargetHenHouseProduction + values.EggMass = detail.TargetEggMass + values.EggWeight = detail.TargetEggWeight + fcr = detail.StandardFCR + } + } + if growthMap, ok := growthDetailByStd[standardID]; ok { + if growth, ok := growthMap[week]; ok { + values.FeedIntake = growth.FeedIntake + values.MaxDepletion = growth.MaxDepletion + } + } + cache[cacheKey] = standardCacheEntry{values: values, fcr: fcr} + applyProductionStandardValues(item, values, fcr) + } - return nil + return nil } func applyProductionStandardValues(item *entity.Recording, values productionStandardValues, fcr *float64) { - item.StandardHenDay = values.HenDay - item.StandardHenHouse = values.HenHouse - item.StandardFeedIntake = values.FeedIntake - item.StandardMaxDepletion = values.MaxDepletion - item.StandardEggMass = values.EggMass - item.StandardEggWeight = values.EggWeight - item.StandardFcr = fcr + item.StandardHenDay = values.HenDay + item.StandardHenHouse = values.HenHouse + item.StandardFeedIntake = values.FeedIntake + item.StandardMaxDepletion = values.MaxDepletion + item.StandardEggMass = values.EggMass + item.StandardEggWeight = values.EggWeight + item.StandardFcr = fcr } func RecordingWeekValue(e entity.Recording) int { @@ -297,7 +298,7 @@ func RecordingWeekValue(e entity.Recording) int { } weekBase := 1 if IsLayingRecording(e) { - weekBase = 18 + weekBase = config.LayingWeekStart() } return ((day - 1) / 7) + weekBase } 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;