diff --git a/internal/common/service/common.fifo_stock_v2.service.go b/internal/common/service/common.fifo_stock_v2.service.go index a1b51e9e..9f59f562 100644 --- a/internal/common/service/common.fifo_stock_v2.service.go +++ b/internal/common/service/common.fifo_stock_v2.service.go @@ -10,6 +10,11 @@ type FifoStockV2Service = fifoStockV2.Service type FifoStockV2Lane = fifoStockV2.Lane +const ( + FifoStockV2LaneStockable FifoStockV2Lane = fifoStockV2.LaneStockable + FifoStockV2LaneUsable FifoStockV2Lane = fifoStockV2.LaneUsable +) + type FifoStockV2Ref = fifoStockV2.Ref type FifoStockV2GatherRequest = fifoStockV2.GatherRequest diff --git a/internal/common/service/common.fifo_stock_v2_route_rules.go b/internal/common/service/common.fifo_stock_v2_route_rules.go new file mode 100644 index 00000000..db2e049e --- /dev/null +++ b/internal/common/service/common.fifo_stock_v2_route_rules.go @@ -0,0 +1,144 @@ +package service + +import ( + "context" + "strings" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type FifoStockV2RouteRule struct { + FlagGroupCode string `gorm:"column:flag_group_code"` + Lane string `gorm:"column:lane"` + FunctionCode string `gorm:"column:function_code"` + SourceTable string `gorm:"column:source_table"` + LegacyTypeKey string `gorm:"column:legacy_type_key"` + AllowPendingDefault bool `gorm:"column:allow_pending_default"` +} + +func ResolveFifoStockV2RouteByProductIDAndLane( + ctx context.Context, + db *gorm.DB, + productID uint, + functionCode string, + lane FifoStockV2Lane, +) (*FifoStockV2RouteRule, error) { + rows, err := resolveFifoStockV2RoutesByProductID(ctx, db, productID, functionCode, lane) + if err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, nil + } + selected := rows[0] + return &selected, nil +} + +func ResolveFifoStockV2RouteByProductWarehouseIDAndLane( + ctx context.Context, + db *gorm.DB, + productWarehouseID uint, + functionCode string, + lane FifoStockV2Lane, +) (*FifoStockV2RouteRule, error) { + rows, err := resolveFifoStockV2RoutesByProductWarehouseID(ctx, db, productWarehouseID, functionCode, lane) + if err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, nil + } + selected := rows[0] + return &selected, nil +} + +func resolveFifoStockV2RoutesByProductID( + ctx context.Context, + db *gorm.DB, + productID uint, + functionCode string, + lane FifoStockV2Lane, +) ([]FifoStockV2RouteRule, error) { + normalizedCode := strings.ToUpper(strings.TrimSpace(functionCode)) + if db == nil || productID == 0 || normalizedCode == "" { + return []FifoStockV2RouteRule{}, nil + } + + query := db.WithContext(ctx). + Table("fifo_stock_v2_route_rules rr"). + Select("rr.flag_group_code, rr.lane, rr.function_code, rr.source_table, rr.legacy_type_key, rr.allow_pending_default"). + 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.function_code = ?", normalizedCode). + 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 = ? + AND fm.flag_group_code = rr.flag_group_code + ) + `, entity.FlagableTypeProduct, productID) + + if lane == FifoStockV2LaneStockable || lane == FifoStockV2LaneUsable { + query = query.Where("rr.lane = ?", string(lane)) + } + + var rows []FifoStockV2RouteRule + err := query. + Order("CASE WHEN rr.source_table = 'adjustment_stocks' THEN 0 ELSE 1 END ASC"). + Order("rr.id ASC"). + Find(&rows).Error + if err != nil { + return nil, err + } + + return rows, nil +} + +func resolveFifoStockV2RoutesByProductWarehouseID( + ctx context.Context, + db *gorm.DB, + productWarehouseID uint, + functionCode string, + lane FifoStockV2Lane, +) ([]FifoStockV2RouteRule, error) { + normalizedCode := strings.ToUpper(strings.TrimSpace(functionCode)) + if db == nil || productWarehouseID == 0 || normalizedCode == "" { + return []FifoStockV2RouteRule{}, nil + } + + query := db.WithContext(ctx). + Table("fifo_stock_v2_route_rules rr"). + Select("rr.flag_group_code, rr.lane, rr.function_code, rr.source_table, rr.legacy_type_key, rr.allow_pending_default"). + 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.function_code = ?", normalizedCode). + Where(` + EXISTS ( + SELECT 1 + FROM product_warehouses pw + JOIN flags f ON f.flagable_type = ? AND 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 fm.flag_group_code = rr.flag_group_code + ) + `, entity.FlagableTypeProduct, productWarehouseID) + + if lane == FifoStockV2LaneStockable || lane == FifoStockV2LaneUsable { + query = query.Where("rr.lane = ?", string(lane)) + } + + var rows []FifoStockV2RouteRule + err := query. + Order("CASE WHEN rr.source_table = 'adjustment_stocks' THEN 0 ELSE 1 END ASC"). + Order("rr.id ASC"). + Find(&rows).Error + if err != nil { + return nil, err + } + + return rows, nil +} diff --git a/internal/database/migrations/20260218090010_seed_fifo_stock_v2_config.up.sql b/internal/database/migrations/20260218090010_seed_fifo_stock_v2_config.up.sql index e6914e94..5cdd0d5e 100644 --- a/internal/database/migrations/20260218090010_seed_fifo_stock_v2_config.up.sql +++ b/internal/database/migrations/20260218090010_seed_fifo_stock_v2_config.up.sql @@ -16,6 +16,7 @@ SET INSERT INTO fifo_stock_v2_flag_members(flag_name, flag_group_code, priority) VALUES + ('AYAM', 'AYAM', 5), ('DOC', 'AYAM', 10), ('PULLET', 'AYAM', 20), ('LAYER', 'AYAM', 30), diff --git a/internal/database/migrations/20260227054736_sync_fifo_stock_v2_inventory_matrix_v2.down.sql b/internal/database/migrations/20260227054736_sync_fifo_stock_v2_inventory_matrix_v2.down.sql new file mode 100644 index 00000000..05ae7e3e --- /dev/null +++ b/internal/database/migrations/20260227054736_sync_fifo_stock_v2_inventory_matrix_v2.down.sql @@ -0,0 +1,57 @@ +BEGIN; + +UPDATE fifo_stock_v2_flag_members +SET + is_active = FALSE, + updated_at = NOW() +WHERE flag_name = 'AYAM' + AND flag_group_code = 'AYAM'; + +INSERT INTO fifo_stock_v2_route_rules( + flag_group_code, + lane, + function_code, + source_table, + source_id_column, + product_warehouse_col, + quantity_col, + used_quantity_col, + pending_quantity_col, + scope_sql, + legacy_type_key, + allow_pending_default, + is_active +) +VALUES + ('AYAM', 'USABLE', 'TRANSFER_TO_LAYING_OUT', 'laying_transfer_sources', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'TRANSFERTOLAYING_OUT', TRUE, TRUE) +ON CONFLICT (flag_group_code, lane, function_code, source_table) DO UPDATE +SET + source_id_column = EXCLUDED.source_id_column, + product_warehouse_col = EXCLUDED.product_warehouse_col, + quantity_col = EXCLUDED.quantity_col, + used_quantity_col = EXCLUDED.used_quantity_col, + pending_quantity_col = EXCLUDED.pending_quantity_col, + scope_sql = EXCLUDED.scope_sql, + legacy_type_key = EXCLUDED.legacy_type_key, + allow_pending_default = EXCLUDED.allow_pending_default, + is_active = TRUE, + updated_at = NOW(); + +UPDATE fifo_stock_v2_overconsume_rules +SET + is_active = TRUE +WHERE lane = 'USABLE' + AND function_code = 'TRANSFER_TO_LAYING_OUT'; + +INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active) +SELECT NULL, 'TRANSFER_TO_LAYING_OUT', 'USABLE', FALSE, 50, 'fifo_v2_exception_transfer_laying_block', TRUE +WHERE NOT EXISTS ( + SELECT 1 + FROM fifo_stock_v2_overconsume_rules + WHERE flag_group_code IS NULL + AND function_code = 'TRANSFER_TO_LAYING_OUT' + AND lane = 'USABLE' + AND reason = 'fifo_v2_exception_transfer_laying_block' +); + +COMMIT; diff --git a/internal/database/migrations/20260227054736_sync_fifo_stock_v2_inventory_matrix_v2.up.sql b/internal/database/migrations/20260227054736_sync_fifo_stock_v2_inventory_matrix_v2.up.sql new file mode 100644 index 00000000..c82ef59a --- /dev/null +++ b/internal/database/migrations/20260227054736_sync_fifo_stock_v2_inventory_matrix_v2.up.sql @@ -0,0 +1,205 @@ +BEGIN; + +INSERT INTO fifo_stock_v2_flag_members(flag_name, flag_group_code, priority, is_active) +VALUES + ('AYAM', 'AYAM', 5, TRUE) +ON CONFLICT (flag_name) DO UPDATE +SET + flag_group_code = EXCLUDED.flag_group_code, + priority = EXCLUDED.priority, + is_active = TRUE, + updated_at = NOW(); + +WITH desired_rules AS ( + SELECT * FROM ( + VALUES + -- AYAM STOCKABLE + ('AYAM', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE), + ('AYAM', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE), + ('AYAM', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE), + ('AYAM', 'STOCKABLE', 'TRANSFER_TO_LAYING_IN', 'laying_transfer_targets', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'TRANSFERTOLAYING_IN', TRUE, TRUE), + + -- AYAM USABLE + ('AYAM', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE), + ('AYAM', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE), + ('AYAM', 'USABLE', 'CHICKIN_OUT', 'project_chickins', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'PROJECT_CHICKIN', TRUE, TRUE), + ('AYAM', 'USABLE', 'RECORDING_DEPLETION_OUT', 'recording_depletions', 'id', 'source_product_warehouse_id', 'qty', NULL, 'pending_qty', NULL, 'RECORDING_DEPLETION', TRUE, TRUE), + ('AYAM', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE), + + -- AFKIR/CULLING/MATI STOCKABLE + ('AFKIR_CULLING_MATI', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE), + ('AFKIR_CULLING_MATI', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE), + ('AFKIR_CULLING_MATI', 'STOCKABLE', 'RECORDING_DEPLETION_IN', 'recording_depletions', 'id', 'product_warehouse_id', 'qty', NULL, NULL, NULL, 'RECORDING_DEPLETION', TRUE, TRUE), + + -- AFKIR/CULLING/MATI USABLE + ('AFKIR_CULLING_MATI', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE), + ('AFKIR_CULLING_MATI', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE), + ('AFKIR_CULLING_MATI', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE), + + -- PAKAN STOCKABLE + ('PAKAN', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE), + ('PAKAN', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE), + ('PAKAN', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE), + + -- PAKAN USABLE + ('PAKAN', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE), + ('PAKAN', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE), + ('PAKAN', 'USABLE', 'RECORDING_STOCK_OUT', 'recording_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'RECORDING_STOCK', TRUE, TRUE), + ('PAKAN', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE), + + -- OVK STOCKABLE + ('OVK', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE), + ('OVK', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE), + ('OVK', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE), + + -- OVK USABLE + ('OVK', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE), + ('OVK', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE), + ('OVK', 'USABLE', 'RECORDING_STOCK_OUT', 'recording_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'RECORDING_STOCK', TRUE, TRUE), + ('OVK', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE), + + -- TELUR STOCKABLE + ('TELUR', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE), + ('TELUR', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE), + ('TELUR', 'STOCKABLE', 'RECORDING_EGG_IN', 'recording_eggs', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'RECORDING_EGG', TRUE, TRUE), + + -- TELUR USABLE + ('TELUR', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE), + ('TELUR', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE), + ('TELUR', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE), + + -- TELUR_GRADE STOCKABLE + ('TELUR_GRADE', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE), + ('TELUR_GRADE', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE), + ('TELUR_GRADE', 'STOCKABLE', 'RECORDING_EGG_IN', 'recording_eggs', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'RECORDING_EGG', TRUE, TRUE), + + -- TELUR_GRADE USABLE + ('TELUR_GRADE', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE), + ('TELUR_GRADE', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE), + ('TELUR_GRADE', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE) + ) AS v( + flag_group_code, + lane, + function_code, + source_table, + source_id_column, + product_warehouse_col, + quantity_col, + used_quantity_col, + pending_quantity_col, + scope_sql, + legacy_type_key, + allow_pending_default, + is_active + ) +) +INSERT INTO fifo_stock_v2_route_rules( + flag_group_code, + lane, + function_code, + source_table, + source_id_column, + product_warehouse_col, + quantity_col, + used_quantity_col, + pending_quantity_col, + scope_sql, + legacy_type_key, + allow_pending_default, + is_active +) +SELECT + flag_group_code, + lane, + function_code, + source_table, + source_id_column, + product_warehouse_col, + quantity_col, + used_quantity_col, + pending_quantity_col, + scope_sql, + legacy_type_key, + allow_pending_default, + is_active +FROM desired_rules +ON CONFLICT (flag_group_code, lane, function_code, source_table) DO UPDATE +SET + source_id_column = EXCLUDED.source_id_column, + product_warehouse_col = EXCLUDED.product_warehouse_col, + quantity_col = EXCLUDED.quantity_col, + used_quantity_col = EXCLUDED.used_quantity_col, + pending_quantity_col = EXCLUDED.pending_quantity_col, + scope_sql = EXCLUDED.scope_sql, + legacy_type_key = EXCLUDED.legacy_type_key, + allow_pending_default = EXCLUDED.allow_pending_default, + is_active = EXCLUDED.is_active, + updated_at = NOW(); + +WITH desired_rules AS ( + SELECT * FROM ( + VALUES + ('AYAM', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks'), + ('AYAM', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details'), + ('AYAM', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items'), + ('AYAM', 'STOCKABLE', 'TRANSFER_TO_LAYING_IN', 'laying_transfer_targets'), + ('AYAM', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks'), + ('AYAM', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details'), + ('AYAM', 'USABLE', 'CHICKIN_OUT', 'project_chickins'), + ('AYAM', 'USABLE', 'RECORDING_DEPLETION_OUT', 'recording_depletions'), + ('AYAM', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products'), + ('AFKIR_CULLING_MATI', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks'), + ('AFKIR_CULLING_MATI', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details'), + ('AFKIR_CULLING_MATI', 'STOCKABLE', 'RECORDING_DEPLETION_IN', 'recording_depletions'), + ('AFKIR_CULLING_MATI', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks'), + ('AFKIR_CULLING_MATI', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details'), + ('AFKIR_CULLING_MATI', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products'), + ('PAKAN', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks'), + ('PAKAN', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details'), + ('PAKAN', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items'), + ('PAKAN', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks'), + ('PAKAN', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details'), + ('PAKAN', 'USABLE', 'RECORDING_STOCK_OUT', 'recording_stocks'), + ('PAKAN', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products'), + ('OVK', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks'), + ('OVK', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details'), + ('OVK', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items'), + ('OVK', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks'), + ('OVK', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details'), + ('OVK', 'USABLE', 'RECORDING_STOCK_OUT', 'recording_stocks'), + ('OVK', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products'), + ('TELUR', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks'), + ('TELUR', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details'), + ('TELUR', 'STOCKABLE', 'RECORDING_EGG_IN', 'recording_eggs'), + ('TELUR', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks'), + ('TELUR', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details'), + ('TELUR', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products'), + ('TELUR_GRADE', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks'), + ('TELUR_GRADE', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details'), + ('TELUR_GRADE', 'STOCKABLE', 'RECORDING_EGG_IN', 'recording_eggs'), + ('TELUR_GRADE', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks'), + ('TELUR_GRADE', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details'), + ('TELUR_GRADE', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products') + ) AS v(flag_group_code, lane, function_code, source_table) +) +UPDATE fifo_stock_v2_route_rules rr +SET + is_active = FALSE, + updated_at = NOW() +WHERE rr.flag_group_code IN ('AYAM', 'AFKIR_CULLING_MATI', 'PAKAN', 'OVK', 'TELUR', 'TELUR_GRADE') + AND NOT EXISTS ( + SELECT 1 + FROM desired_rules d + WHERE d.flag_group_code = rr.flag_group_code + AND d.lane = rr.lane + AND d.function_code = rr.function_code + AND d.source_table = rr.source_table + ); + +UPDATE fifo_stock_v2_overconsume_rules +SET + is_active = FALSE +WHERE lane = 'USABLE' + AND function_code = 'TRANSFER_TO_LAYING_OUT'; + +COMMIT; diff --git a/internal/database/seed/seeder.go b/internal/database/seed/seeder.go index b4ccf36a..6104f833 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -198,7 +198,7 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories Category: "Day Old Chick", Price: 7500, Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, - Flags: []utils.FlagType{utils.FlagDOC, utils.FlagPullet, utils.FlagLayer}, + Flags: []utils.FlagType{utils.FlagAyam}, IsVisible: true, }, { diff --git a/internal/modules/closings/dto/closingSapronak.dto.go b/internal/modules/closings/dto/closingSapronak.dto.go index d4cb0d0d..4e5b7fb1 100644 --- a/internal/modules/closings/dto/closingSapronak.dto.go +++ b/internal/modules/closings/dto/closingSapronak.dto.go @@ -58,17 +58,17 @@ type SapronakReportDTO struct { // Simplified view for project-level sapronak response type SapronakCategoryRowDTO struct { - ID int `json:"id"` - Date string `json:"date"` - ReferenceNumber string `json:"reference_number"` - QtyIn float64 `json:"qty_in"` - QtyOut float64 `json:"qty_out"` - QtyUsed float64 `json:"qty_used"` - Description string `json:"description"` + ID int `json:"id"` + Date string `json:"date"` + ReferenceNumber string `json:"reference_number"` + QtyIn float64 `json:"qty_in"` + QtyOut float64 `json:"qty_out"` + QtyUsed float64 `json:"qty_used"` + Description string `json:"description"` ProductCategory []string `json:"product_category"` - UnitPrice float64 `json:"unit_price"` - TotalAmount float64 `json:"total_amount"` - Notes string `json:"notes"` + UnitPrice float64 `json:"unit_price"` + TotalAmount float64 `json:"total_amount"` + Notes string `json:"notes"` } type SapronakCategoryTotalDTO struct { @@ -148,7 +148,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin normalizeFlag := func(raw string) string { normalized := strings.ToUpper(strings.TrimSpace(raw)) - if normalized == "PULLET" { + if normalized == "AYAM" || normalized == "PULLET" { return "DOC" } return normalized @@ -177,6 +177,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin } flagOrder := map[string]int{ + "AYAM": 0, "DOC": 0, "PAKAN": 0, "OVK": 0, diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index ecd96b0a..ebba5f6a 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -446,7 +446,7 @@ SELECT f.name, ' ' ORDER BY CASE - WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + WHEN UPPER(f.name) IN ('AYAM', 'DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 ELSE 1 END, f.name @@ -459,7 +459,7 @@ SELECT f.name, ' ' ORDER BY CASE - WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + WHEN UPPER(f.name) IN ('AYAM', 'DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 ELSE 1 END, f.name @@ -495,7 +495,7 @@ SELECT f.name, ' ' ORDER BY CASE - WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + WHEN UPPER(f.name) IN ('AYAM', 'DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 ELSE 1 END, f.name @@ -508,7 +508,7 @@ SELECT f.name, ' ' ORDER BY CASE - WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + WHEN UPPER(f.name) IN ('AYAM', 'DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 ELSE 1 END, f.name @@ -545,7 +545,7 @@ SELECT f.name, ' ' ORDER BY CASE - WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + WHEN UPPER(f.name) IN ('AYAM', 'DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 ELSE 1 END, f.name @@ -558,7 +558,7 @@ SELECT f.name, ' ' ORDER BY CASE - WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + WHEN UPPER(f.name) IN ('AYAM', 'DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 ELSE 1 END, f.name @@ -595,7 +595,7 @@ SELECT f.name, ' ' ORDER BY CASE - WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + WHEN UPPER(f.name) IN ('AYAM', 'DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 ELSE 1 END, f.name @@ -608,7 +608,7 @@ SELECT f.name, ' ' ORDER BY CASE - WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + WHEN UPPER(f.name) IN ('AYAM', 'DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 ELSE 1 END, f.name @@ -645,7 +645,7 @@ SELECT f.name, ' ' ORDER BY CASE - WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + WHEN UPPER(f.name) IN ('AYAM', 'DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 ELSE 1 END, f.name @@ -658,7 +658,7 @@ SELECT f.name, ' ' ORDER BY CASE - WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + WHEN UPPER(f.name) IN ('AYAM', 'DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 ELSE 1 END, f.name @@ -685,7 +685,7 @@ WHERE pw.warehouse_id IN ? FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = 'products' - AND UPPER(f.name) NOT IN ('DOC', 'LAYER', 'PULLET', 'AYAM-AFKIR', 'AYAM-MATI', 'AYAM-CULLING', 'TELUR-UTUH', 'TELUR-PECAH', 'TELUR-PUTIH', 'TELUR-RETAK') + AND UPPER(f.name) NOT IN ('AYAM', 'DOC', 'LAYER', 'PULLET', 'AYAM-AFKIR', 'AYAM-MATI', 'AYAM-CULLING', 'TELUR-UTUH', 'TELUR-PECAH', 'TELUR-PUTIH', 'TELUR-RETAK') ) ` @@ -702,7 +702,7 @@ SELECT f.name, ' ' ORDER BY CASE - WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + WHEN UPPER(f.name) IN ('AYAM', 'DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 ELSE 1 END, f.name @@ -715,7 +715,7 @@ SELECT f.name, ' ' ORDER BY CASE - WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + WHEN UPPER(f.name) IN ('AYAM', 'DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 ELSE 1 END, f.name @@ -743,7 +743,7 @@ WHERE pw.project_flock_kandang_id IN ? FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = 'products' - AND UPPER(f.name) NOT IN ('DOC', 'LAYER', 'PULLET', 'AYAM-AFKIR', 'AYAM-MATI', 'AYAM-CULLING', 'TELUR-UTUH', 'TELUR-PECAH', 'TELUR-PUTIH', 'TELUR-RETAK') + AND UPPER(f.name) NOT IN ('AYAM', 'DOC', 'LAYER', 'PULLET', 'AYAM-AFKIR', 'AYAM-MATI', 'AYAM-CULLING', 'TELUR-UTUH', 'TELUR-PECAH', 'TELUR-PUTIH', 'TELUR-RETAK') ) ` ) @@ -796,9 +796,9 @@ func sapronakFlags(flags ...utils.FlagType) []string { } var ( - sapronakFlagsAll = sapronakFlags(utils.FlagDOC, utils.FlagPakan, utils.FlagOVK, utils.FlagPullet) + sapronakFlagsAll = sapronakFlags(utils.FlagAyam, utils.FlagDOC, utils.FlagPakan, utils.FlagOVK, utils.FlagPullet) sapronakFlagsUsage = sapronakFlags(utils.FlagPakan, utils.FlagOVK) - sapronakFlagsChickin = sapronakFlags(utils.FlagDOC, utils.FlagPullet) + sapronakFlagsChickin = sapronakFlags(utils.FlagAyam, utils.FlagDOC, utils.FlagPullet) ) func (r *ClosingRepositoryImpl) joinSapronakProductFlag(db *gorm.DB, productAlias string) *gorm.DB { @@ -808,7 +808,8 @@ func (r *ClosingRepositoryImpl) joinSapronakProductFlag(db *gorm.DB, productAlia Where("flagable_type = ?", entity.FlagableTypeProduct). Where("name IN ?", sapronakFlagsAll). Order(fmt.Sprintf( - "flagable_id, CASE WHEN name = '%s' THEN 1 WHEN name = '%s' THEN 2 WHEN name = '%s' THEN 3 WHEN name = '%s' THEN 4 ELSE 5 END", + "flagable_id, CASE WHEN name = '%s' THEN 1 WHEN name = '%s' THEN 2 WHEN name = '%s' THEN 3 WHEN name = '%s' THEN 4 WHEN name = '%s' THEN 5 ELSE 6 END", + utils.FlagAyam, utils.FlagDOC, utils.FlagPullet, utils.FlagPakan, @@ -1238,7 +1239,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka Where("sa.status = ?", entity.StockAllocationStatusActive). Where("w.kandang_id = ?", kandangID). Where("f.name IN ?", sapronakFlagsAll). - Where("f.name NOT IN ?", sapronakFlags(utils.FlagDOC, utils.FlagPullet)). + Where("f.name NOT IN ?", sapronakFlags(utils.FlagAyam, utils.FlagDOC, utils.FlagPullet)). Group("pw.product_id, p.name, f.name, pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at, po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, pc.id, ast_in.id, ast.id, p.product_price") outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p") outgoing, err := scanAndGroupDetails(outgoingQuery) diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 71bfcdec..5e7a1b44 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -856,7 +856,7 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint // FeedUsedPerHead: feedUsedPerHead, } - chickenFlagNames := []string{string(utils.FlagPullet), string(utils.FlagAyamAfkir), string(utils.FlagAyamCulling), string(utils.FlagLayer)} + chickenFlagNames := []string{string(utils.FlagAyam), string(utils.FlagPullet), string(utils.FlagAyamAfkir), string(utils.FlagAyamCulling), string(utils.FlagLayer)} chickenSalesWeight, chickenSalesQty, chickenSalesPrice, err := s.Repository.SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(c.Context(), projectFlockKandangIDs, chickenFlagNames) if err != nil { s.Log.Errorf("Failed to fetch chicken sales data for project flock %d: %+v", projectFlockID, err) @@ -885,7 +885,7 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint chickenDepletion = 0 } -chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenSalesWeight, feedUsed, population, chickenDepletion, age) + chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenSalesWeight, feedUsed, population, chickenDepletion, age) if fcrActFromRecording != nil { chickenPerformance.FcrAct = *fcrActFromRecording } diff --git a/internal/modules/closings/services/sapronak.service.go b/internal/modules/closings/services/sapronak.service.go index ba79db1d..952fbb08 100644 --- a/internal/modules/closings/services/sapronak.service.go +++ b/internal/modules/closings/services/sapronak.service.go @@ -432,8 +432,8 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj return true } candidate := strings.ToUpper(f) - if filterFlag == "DOC" || filterFlag == "PULLET" { - return candidate == "DOC" || candidate == "PULLET" + if filterFlag == "AYAM" || filterFlag == "DOC" || filterFlag == "PULLET" { + return candidate == "AYAM" || candidate == "DOC" || candidate == "PULLET" } return candidate == filterFlag } @@ -474,7 +474,8 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj if !isLaying { filteredUsage := make([]repository.SapronakUsageRow, 0, len(chickinUsageRows)) for _, row := range chickinUsageRows { - if strings.ToUpper(row.Flag) == "DOC" { + flag := strings.ToUpper(row.Flag) + if flag == "AYAM" || flag == "DOC" { filteredUsage = append(filteredUsage, row) } } @@ -483,7 +484,8 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj filteredDetail := make(map[uint][]repository.SapronakDetailRow, len(chickinUsageDetailsRows)) for pid, rows := range chickinUsageDetailsRows { for _, d := range rows { - if strings.ToUpper(d.Flag) == "DOC" { + flag := strings.ToUpper(d.Flag) + if flag == "AYAM" || flag == "DOC" { filteredDetail[pid] = append(filteredDetail[pid], d) } } diff --git a/internal/modules/closings/validations/sapronak.validation.go b/internal/modules/closings/validations/sapronak.validation.go index 78f64d08..fe446e0b 100644 --- a/internal/modules/closings/validations/sapronak.validation.go +++ b/internal/modules/closings/validations/sapronak.validation.go @@ -5,5 +5,5 @@ type CountSapronakQuery struct { KandangID uint `query:"kandang_id" validate:"omitempty,gt=0"` ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"` Status string `query:"status" validate:"omitempty,oneof=active closing all"` - Flag string `query:"flag" validate:"omitempty,oneof=DOC OVK PAKAN PULLET doc ovk pakan pullet"` + Flag string `query:"flag" validate:"omitempty,oneof=AYAM DOC OVK PAKAN PULLET ayam doc ovk pakan pullet"` } diff --git a/internal/modules/constants/repositories/constant.repository.go b/internal/modules/constants/repositories/constant.repository.go index 3c4beb06..0746576b 100644 --- a/internal/modules/constants/repositories/constant.repository.go +++ b/internal/modules/constants/repositories/constant.repository.go @@ -26,12 +26,49 @@ func NewConstantRepository(db *gorm.DB) ConstantRepository { } func (r *ConstantRepositoryImpl) GetConstants() (map[string]interface{}, error) { - flagList := make([]string, 0) - for f := range utils.AllFlagTypes() { - flagList = append(flagList, string(f)) + flagSet := make(map[string]struct{}) + for _, f := range utils.AllowedFlagTypes(utils.FlagGroupProduct) { + flagSet[string(f)] = struct{}{} + } + for _, f := range utils.AllowedFlagTypes(utils.FlagGroupNonstock) { + flagSet[string(f)] = struct{}{} + } + flagSet[string(utils.FlagIsActive)] = struct{}{} + + flagList := make([]string, 0, len(flagSet)) + for f := range flagSet { + flagList = append(flagList, f) } sort.Strings(flagList) + legacyFlagAliasesRaw := utils.LegacyFlagTypeAliases() + legacyFlagAliases := make(map[string]string, len(legacyFlagAliasesRaw)) + for legacy, canonical := range legacyFlagAliasesRaw { + legacyFlagAliases[string(legacy)] = string(canonical) + } + + productFlagOptionsRaw := utils.ProductFlagOptions() + productFlagOptions := make([]map[string]interface{}, 0, len(productFlagOptionsRaw)) + productMainFlags := make([]string, 0, len(productFlagOptionsRaw)) + productSubFlagToFlag := make(map[string]string) + for _, option := range productFlagOptionsRaw { + flag := string(option.Flag) + productMainFlags = append(productMainFlags, flag) + + subFlags := make([]string, len(option.SubFlags)) + for i, subFlag := range option.SubFlags { + subFlagStr := string(subFlag) + subFlags[i] = subFlagStr + productSubFlagToFlag[subFlagStr] = flag + } + + productFlagOptions = append(productFlagOptions, map[string]interface{}{ + "flag": flag, + "sub_flags": subFlags, + "allow_without_sub_flag": option.AllowWithoutSubFlag, + }) + } + type approvalStepConstant struct { StepNumber uint16 `json:"step_number"` StepName string `json:"step_name"` @@ -78,7 +115,13 @@ func (r *ConstantRepositoryImpl) GetConstants() (map[string]interface{}, error) adjustmentSubtypesByType := utils.AdjustmentTransactionSubtypesByTypeForFrontend() return map[string]interface{}{ - "flags": flagList, + "flags": flagList, + "legacy_flag_aliases": legacyFlagAliases, + "product_flag_mapping": map[string]interface{}{ + "flags": productMainFlags, + "options": productFlagOptions, + "sub_flag_to_flag": productSubFlagToFlag, + }, "warehouse_types": []string{ "AREA", "LOKASI", diff --git a/internal/modules/dashboards/repositories/dashboard_stats.repository.go b/internal/modules/dashboards/repositories/dashboard_stats.repository.go index 363e6aa5..b60d776c 100644 --- a/internal/modules/dashboards/repositories/dashboard_stats.repository.go +++ b/internal/modules/dashboards/repositories/dashboard_stats.repository.go @@ -364,7 +364,7 @@ func (r *DashboardRepositoryImpl) SumSapronakCost(ctx context.Context, start, en Joins("LEFT JOIN product_warehouses AS pw ON pw.id = pi.product_warehouse_id"). Joins("LEFT JOIN project_flock_kandangs AS pfk ON pfk.id = COALESCE(pi.project_flock_kandang_id, pw.project_flock_kandang_id)"). Joins("LEFT JOIN kandangs AS k ON k.id = pfk.kandang_id"). - Where("f.name IN ?", []utils.FlagType{utils.FlagDOC, utils.FlagPakan, utils.FlagOVK}). + Where("f.name IN ?", []utils.FlagType{utils.FlagAyam, utils.FlagDOC, utils.FlagPullet, utils.FlagPakan, utils.FlagOVK}). Where("pi.received_date IS NOT NULL"). Where("pi.received_date >= ? AND pi.received_date < ?", start, end) 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..48db9449 100644 --- a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go +++ b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go @@ -179,6 +179,7 @@ func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, cu flag == string(utils.FlagTelurPecah) || flag == string(utils.FlagTelurPutih) || flag == string(utils.FlagTelurRetak) || + flag == string(utils.FlagAyam) || flag == string(utils.FlagAyamAfkir) || flag == string(utils.FlagAyamCulling) || flag == string(utils.FlagAyamMati) { 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..53c4d63e 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -144,6 +144,7 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) for _, t := range marketingTypes { switch t { case string(utils.MarketingTypeAyamPullet): + flagSet[string(utils.FlagAyam)] = struct{}{} flagSet[string(utils.FlagDOC)] = struct{}{} flagSet[string(utils.FlagPullet)] = struct{}{} flagSet[string(utils.FlagLayer)] = struct{}{} diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index b377958b..af668127 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -50,6 +50,16 @@ type transferService struct { ExpenseBridge TransferExpenseBridge } +const ( + transferFunctionCodeIn = "STOCK_TRANSFER_IN" + transferFunctionCodeOut = "STOCK_TRANSFER_OUT" +) + +type transferRoutePair struct { + Stockable commonSvc.FifoStockV2RouteRule + Usable commonSvc.FifoStockV2RouteRule +} + 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, documentSvc commonSvc.DocumentService, fifoSvc commonSvc.FifoService, fifoStockV2Svc commonSvc.FifoStockV2Service, expenseBridge TransferExpenseBridge) TransferService { return &transferService{ Log: utils.Log, @@ -444,9 +454,9 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } } - pakanProducts := map[uint]bool{} - if s.FifoStockV2Svc != nil && len(req.Products) > 0 { - pakanProducts, err = s.resolvePakanProducts(c.Context(), tx, req.Products) + routePairsByProductID := map[uint]transferRoutePair{} + if len(req.Products) > 0 { + routePairsByProductID, err = s.resolveTransferRoutes(c.Context(), tx, req.Products) if err != nil { return err } @@ -454,10 +464,17 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques for _, product := range req.Products { detail := detailMap[uint64(product.ProductID)] + routePair, ok := routePairsByProductID[uint(product.ProductID)] + if !ok { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Konfigurasi FIFO v2 transfer tidak ditemukan untuk produk %d", product.ProductID), + ) + } outUsageQty := 0.0 outPendingQty := 0.0 - useFifoV2 := s.FifoStockV2Svc != nil && pakanProducts[uint(product.ProductID)] + useFifoV2 := s.FifoStockV2Svc != nil if useFifoV2 { s.Log.Infof( "[fifo-v2][transfer] use reflow movement=%s detail_id=%d product_id=%d source_pw=%d qty=%.3f", @@ -468,12 +485,12 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques product.ProductQty, ) reflowResult, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{ - FlagGroupCode: "PAKAN", + FlagGroupCode: routePair.Usable.FlagGroupCode, ProductWarehouseID: uint(*detail.SourceProductWarehouseID), Usable: commonSvc.FifoStockV2Ref{ ID: uint(detail.Id), - LegacyTypeKey: fifo.UsableKeyStockTransferOut.String(), - FunctionCode: "STOCK_TRANSFER_OUT", + LegacyTypeKey: routePair.Usable.LegacyTypeKey, + FunctionCode: routePair.Usable.FunctionCode, }, DesiredQty: product.ProductQty, Tx: tx, @@ -491,8 +508,12 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques outPendingQty, ) } else { + usableKey := fifo.UsableKey(strings.TrimSpace(routePair.Usable.LegacyTypeKey)) + if usableKey == "" { + usableKey = fifo.UsableKeyStockTransferOut + } consumeResult, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{ - UsableKey: fifo.UsableKeyStockTransferOut, + UsableKey: usableKey, UsableID: uint(detail.Id), ProductWarehouseID: uint(*detail.SourceProductWarehouseID), Quantity: product.ProductQty, @@ -553,8 +574,12 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques product.ProductQty, ) } + stockableKey := fifo.StockableKey(strings.TrimSpace(routePair.Stockable.LegacyTypeKey)) + if stockableKey == "" { + stockableKey = fifo.StockableKeyStockTransferIn + } replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ - StockableKey: fifo.StockableKeyStockTransferIn, + StockableKey: stockableKey, StockableID: uint(detail.Id), ProductWarehouseID: uint(*detail.DestProductWarehouseID), Quantity: product.ProductQty, @@ -657,50 +682,72 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return result, nil } -func (s *transferService) resolvePakanProducts( +func (s *transferService) resolveTransferRoutes( ctx context.Context, tx *gorm.DB, products []validation.TransferProduct, -) (map[uint]bool, error) { - out := make(map[uint]bool, len(products)) +) (map[uint]transferRoutePair, error) { + out := make(map[uint]transferRoutePair, len(products)) if len(products) == 0 { return out, nil } - productIDs := make([]uint, 0, len(products)) - seen := make(map[uint]struct{}, len(products)) + productIDs := make(map[uint]struct{}, len(products)) for _, product := range products { if product.ProductID == 0 { continue } - if _, ok := seen[product.ProductID]; ok { - continue + productIDs[product.ProductID] = struct{}{} + } + + for productID := range productIDs { + usableRoute, err := commonSvc.ResolveFifoStockV2RouteByProductIDAndLane( + ctx, + tx, + productID, + transferFunctionCodeOut, + commonSvc.FifoStockV2LaneUsable, + ) + if err != nil { + return nil, err + } + if usableRoute == nil { + return nil, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Produk %d tidak mendukung transaksi Transfer Stock (OUT) pada matrix FIFO v2", productID), + ) + } + + stockableRoute, err := commonSvc.ResolveFifoStockV2RouteByProductIDAndLane( + ctx, + tx, + productID, + transferFunctionCodeIn, + commonSvc.FifoStockV2LaneStockable, + ) + if err != nil { + return nil, err + } + if stockableRoute == nil { + return nil, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Produk %d tidak mendukung transaksi Transfer Stock (IN) pada matrix FIFO v2", productID), + ) + } + + if strings.TrimSpace(usableRoute.FlagGroupCode) != strings.TrimSpace(stockableRoute.FlagGroupCode) { + return nil, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Konfigurasi matrix FIFO v2 transfer tidak konsisten untuk produk %d", productID), + ) + } + + out[productID] = transferRoutePair{ + Stockable: *stockableRoute, + Usable: *usableRoute, } - seen[product.ProductID] = struct{}{} - productIDs = append(productIDs, product.ProductID) - } - if len(productIDs) == 0 { - return out, nil } - type row struct { - ProductID uint `gorm:"column:product_id"` - } - var rows []row - err := tx.WithContext(ctx). - Table("flags f"). - Select("DISTINCT f.flagable_id AS product_id"). - Where("f.flagable_type = ?", entity.FlagableTypeProduct). - Where("f.name IN ?", []string{"PAKAN", "PRE-STARTER", "STARTER", "FINISHER"}). - Where("f.flagable_id IN ?", productIDs). - Scan(&rows).Error - if err != nil { - return nil, err - } - - for _, row := range rows { - out[row.ProductID] = true - } return out, nil } diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index 17394b80..d2290c89 100644 --- a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -105,6 +105,7 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualanForAgeChickD Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id"). Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). Where("flags.name IN (?)", []string{ + string(utils.FlagAyam), string(utils.FlagAyamAfkir), string(utils.FlagAyamCulling), string(utils.FlagPullet), @@ -158,9 +159,12 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualanByCategory(c string(utils.FlagTelurPecah), string(utils.FlagTelurPutih), string(utils.FlagTelurRetak), + string(utils.FlagTelurPapacal), + string(utils.FlagTelurJumbo), }) } else { db = db.Where("flags.name IN (?)", []string{ + string(utils.FlagAyam), string(utils.FlagDOC), string(utils.FlagPullet), string(utils.FlagLayer), @@ -327,12 +331,12 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C switch filters.MarketingType { case "ayam": db = db.Where("flags.name IN (?)", []string{ - string(utils.FlagDOC), string(utils.FlagPullet), string(utils.FlagLayer), + string(utils.FlagAyam), string(utils.FlagDOC), string(utils.FlagPullet), string(utils.FlagLayer), }) case "telur": db = db.Where("flags.name IN (?)", []string{ string(utils.FlagTelur), string(utils.FlagTelurUtuh), string(utils.FlagTelurPecah), - string(utils.FlagTelurPutih), string(utils.FlagTelurRetak), + string(utils.FlagTelurPutih), string(utils.FlagTelurRetak), string(utils.FlagTelurPapacal), string(utils.FlagTelurJumbo), }) case "trading": db = db.Where("flags.name IN (?)", []string{ diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index 677ef965..e348d4db 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -549,8 +549,29 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor return fiber.NewError(fiber.StatusInternalServerError, "Delivery product not found") } + route, err := commonSvc.ResolveFifoStockV2RouteByProductWarehouseIDAndLane( + ctx, + tx, + marketingProduct.ProductWarehouseId, + "MARKETING_OUT", + commonSvc.FifoStockV2LaneUsable, + ) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve FIFO v2 marketing route") + } + if route == nil { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Product warehouse %d tidak mendukung transaksi Marketing pada matrix FIFO v2", marketingProduct.ProductWarehouseId), + ) + } + usableKey := fifo.UsableKey(route.LegacyTypeKey) + if route.LegacyTypeKey == "" { + usableKey = fifo.UsableKeyMarketingDelivery + } + result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ - UsableKey: fifo.UsableKeyMarketingDelivery, + UsableKey: usableKey, UsableID: deliveryProduct.Id, ProductWarehouseID: marketingProduct.ProductWarehouseId, Quantity: requestedQty, @@ -603,6 +624,27 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found") } + route, err := commonSvc.ResolveFifoStockV2RouteByProductWarehouseIDAndLane( + ctx, + tx, + marketingProduct.ProductWarehouseId, + "MARKETING_OUT", + commonSvc.FifoStockV2LaneUsable, + ) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve FIFO v2 marketing route") + } + if route == nil { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Product warehouse %d tidak mendukung transaksi Marketing pada matrix FIFO v2", marketingProduct.ProductWarehouseId), + ) + } + usableKey := fifo.UsableKey(route.LegacyTypeKey) + if route.LegacyTypeKey == "" { + usableKey = fifo.UsableKeyMarketingDelivery + } + deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx) currentUsage, err := deliveryProductRepo.GetUsageQty(ctx, deliveryProduct.Id) if err != nil { @@ -614,7 +656,7 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor } if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ - UsableKey: fifo.UsableKeyMarketingDelivery, + UsableKey: usableKey, UsableID: deliveryProduct.Id, Tx: tx, }); err != nil { diff --git a/internal/modules/marketing/services/salesorder.service.go b/internal/modules/marketing/services/salesorder.service.go index eb2e4f5b..264bf4dc 100644 --- a/internal/modules/marketing/services/salesorder.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -48,6 +48,8 @@ type salesOrdersService struct { ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository } +const marketingFunctionCodeOut = "MARKETING_OUT" + func NewSalesOrdersService(marketingRepo repository.MarketingRepository, customerRepo customerRepo.CustomerRepository, productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository, userRepo userRepo.UserRepository, approvalSvc commonSvc.ApprovalService, fifoSvc commonSvc.FifoService, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, validate *validator.Validate) SalesOrdersService { return &salesOrdersService{ @@ -74,6 +76,36 @@ func (s salesOrdersService) withRelations(db *gorm.DB) *gorm.DB { Preload("Products.ProductWarehouse.Warehouse") } +func (s salesOrdersService) resolveMarketingUsableKey(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (fifo.UsableKey, error) { + if productWarehouseID == 0 { + return "", fiber.NewError(fiber.StatusBadRequest, "Product warehouse tidak valid untuk transaksi Marketing") + } + + route, err := commonSvc.ResolveFifoStockV2RouteByProductWarehouseIDAndLane( + ctx, + tx, + productWarehouseID, + marketingFunctionCodeOut, + commonSvc.FifoStockV2LaneUsable, + ) + if err != nil { + return "", fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve FIFO v2 marketing route") + } + if route == nil { + return "", fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Product warehouse %d tidak mendukung transaksi Marketing pada matrix FIFO v2", productWarehouseID), + ) + } + + usableKey := fifo.UsableKey(strings.TrimSpace(route.LegacyTypeKey)) + if usableKey == "" { + usableKey = fifo.UsableKeyMarketingDelivery + } + + return usableKey, nil +} + func (s salesOrdersService) getOne(c *fiber.Ctx, id uint) (*entity.Marketing, error) { if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), id); err != nil { return nil, err @@ -376,8 +408,12 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u if qtyDiff < 0 { return fiber.NewError(fiber.StatusBadRequest, "Cannot decrease quantity after stock has been allocated. Please delete and create new product.") } else if qtyDiff > 0 { - _, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{ - UsableKey: fifo.UsableKeyMarketingDelivery, + usableKey, err := s.resolveMarketingUsableKey(c.Context(), dbTransaction, rp.ProductWarehouseId) + if err != nil { + return err + } + _, err = s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{ + UsableKey: usableKey, UsableID: deliveryProduct.Id, ProductWarehouseID: rp.ProductWarehouseId, Quantity: qtyDiff, @@ -438,9 +474,13 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u if deliveryProduct.DeliveryDate != nil { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot delete marketing product %d because it has been delivered", old.Id)) } + usableKey, err := s.resolveMarketingUsableKey(c.Context(), dbTransaction, old.ProductWarehouseId) + if err != nil { + return err + } if err := s.FifoSvc.ReleaseUsage(c.Context(), commonSvc.StockReleaseRequest{ - UsableKey: fifo.UsableKeyMarketingDelivery, + UsableKey: usableKey, UsableID: deliveryProduct.Id, Tx: dbTransaction, }); err != nil { @@ -522,9 +562,22 @@ func (s salesOrdersService) DeleteOne(c *fiber.Ctx, id uint) error { if len(marketing.Products) > 0 { deliveryProducts, err := marketingDeliveryProductRepoTx.GetByMarketingId(c.Context(), marketing.Id) if err == nil && len(deliveryProducts) > 0 { + deliveryUsableKeyByProductID := make(map[uint]fifo.UsableKey, len(marketing.Products)) + for _, product := range marketing.Products { + usableKey, err := s.resolveMarketingUsableKey(c.Context(), dbTransaction, product.ProductWarehouseId) + if err != nil { + return err + } + deliveryUsableKeyByProductID[product.Id] = usableKey + } + for _, dp := range deliveryProducts { + usableKey, ok := deliveryUsableKeyByProductID[dp.MarketingProductId] + if !ok { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product untuk delivery %d tidak ditemukan", dp.Id)) + } if err := s.FifoSvc.ReleaseUsage(c.Context(), commonSvc.StockReleaseRequest{ - UsableKey: fifo.UsableKeyMarketingDelivery, + UsableKey: usableKey, UsableID: dp.Id, Tx: dbTransaction, }); err != nil { diff --git a/internal/modules/master/products/dto/product.dto.go b/internal/modules/master/products/dto/product.dto.go index d115ad23..dc09bdee 100644 --- a/internal/modules/master/products/dto/product.dto.go +++ b/internal/modules/master/products/dto/product.dto.go @@ -7,6 +7,7 @@ import ( productCategoryDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/dto" uomDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + utils "gitlab.com/mbugroup/lti-api.git/internal/utils" ) // === DTO Structs === @@ -17,6 +18,9 @@ type ProductRelationDTO struct { ProductPrice float64 `gorm:"type:numeric(15,3);not null"` SellingPrice *float64 `gorm:"type:numeric(15,3)"` Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` + Flag *string `json:"flag,omitempty"` + SubFlag *string `json:"sub_flag,omitempty"` + SubFlags *[]string `json:"sub_flags,omitempty"` Flags *[]string `json:"flags,omitempty"` ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"` Suppliers []ProductSupplierDTO `json:"suppliers"` @@ -31,6 +35,9 @@ type ProductListDTO struct { SellingPrice *float64 `json:"selling_price,omitempty"` Tax *float64 `json:"tax,omitempty"` ExpiryPeriod *int `json:"expiry_period,omitempty"` + Flag *string `json:"flag,omitempty"` + SubFlag *string `json:"sub_flag,omitempty"` + SubFlags []string `json:"sub_flags,omitempty"` Flags []string `json:"flags"` Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"` @@ -59,6 +66,13 @@ func ToProductRelationDTO(e entity.Product) ProductRelationDTO { for i, f := range e.Flags { flags[i] = f.Name } + flag, subFlag, subFlags := resolveProductFlagAndSubFlags(flags) + var subFlagsRef *[]string + if len(subFlags) > 0 { + values := make([]string, len(subFlags)) + copy(values, subFlags) + subFlagsRef = &values + } var uomRef *uomDTO.UomRelationDTO if e.Uom.Id != 0 { @@ -77,6 +91,9 @@ func ToProductRelationDTO(e entity.Product) ProductRelationDTO { Name: e.Name, ProductPrice: e.ProductPrice, SellingPrice: e.SellingPrice, + Flag: flag, + SubFlag: subFlag, + SubFlags: subFlagsRef, Flags: &flags, Uom: uomRef, ProductCategory: categoryRef, @@ -101,6 +118,7 @@ func ToProductListDTO(e entity.Product) ProductListDTO { for i, f := range e.Flags { flags[i] = f.Name } + flag, subFlag, subFlags := resolveProductFlagAndSubFlags(flags) var uomRef *uomDTO.UomRelationDTO if e.Uom.Id != 0 { @@ -111,6 +129,9 @@ func ToProductListDTO(e entity.Product) ProductListDTO { return ProductListDTO{ Id: e.Id, Name: e.Name, + Flag: flag, + SubFlag: subFlag, + SubFlags: subFlags, Flags: flags, Uom: uomRef, Brand: e.Brand, @@ -141,6 +162,58 @@ func ToProductDetailDTO(e entity.Product) ProductDetailDTO { } } +func resolveProductFlagAndSubFlags(flags []string) (*string, *string, []string) { + normalized := utils.NormalizeFlagTypes(flags) + if len(normalized) == 0 { + return nil, nil, nil + } + + available := make(map[utils.FlagType]struct{}, len(normalized)) + for _, flag := range normalized { + available[flag] = struct{}{} + } + + var selectedFlag utils.FlagType + for _, mainFlag := range utils.ProductMainFlags() { + if _, ok := available[mainFlag]; ok { + selectedFlag = mainFlag + break + } + } + + if selectedFlag == "" { + subToMain := utils.ProductSubFlagToFlag() + for _, flag := range normalized { + if parent, ok := subToMain[flag]; ok { + selectedFlag = parent + break + } + } + } + + if selectedFlag == "" { + return nil, nil, nil + } + + flag := string(selectedFlag) + + var subFlag *string + subFlagValues := make([]string, 0) + subFlagsByMain := utils.ProductSubFlagsByFlag() + for _, sub := range subFlagsByMain[selectedFlag] { + if _, ok := available[sub]; ok { + subFlagValues = append(subFlagValues, string(sub)) + } + } + + if len(subFlagValues) > 0 { + first := subFlagValues[0] + subFlag = &first + } + + return &flag, subFlag, subFlagValues +} + func toProductSupplierDTOs(relations []entity.ProductSupplier) []ProductSupplierDTO { if len(relations) == 0 { return make([]ProductSupplierDTO, 0) diff --git a/internal/modules/master/products/services/product.service.go b/internal/modules/master/products/services/product.service.go index 0aaa0952..352ec223 100644 --- a/internal/modules/master/products/services/product.service.go +++ b/internal/modules/master/products/services/product.service.go @@ -41,6 +41,151 @@ func normalizeProductFlags(raw []string) ([]string, error) { return utils.FlagTypesToStrings(normalized), nil } +func productMainFlagOptionsString() []string { + mainFlags := utils.ProductMainFlags() + result := make([]string, len(mainFlags)) + for i, flag := range mainFlags { + result[i] = string(flag) + } + return result +} + +func productSubFlagOptionsString(flag utils.FlagType) []string { + subFlagsByFlag := utils.ProductSubFlagsByFlag() + subFlags := subFlagsByFlag[flag] + result := make([]string, len(subFlags)) + for i, subFlag := range subFlags { + result[i] = string(subFlag) + } + return result +} + +func normalizeStructuredSubFlagsInput(subFlagRaw *string, subFlagsRaw []string, hasSubFlagsField bool) ([]utils.FlagType, error) { + values := make([]string, 0, len(subFlagsRaw)+1) + + if subFlagRaw != nil { + single := strings.TrimSpace(*subFlagRaw) + if single == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "sub_flag cannot be empty") + } + values = append(values, single) + } + + if hasSubFlagsField { + for _, raw := range subFlagsRaw { + item := strings.TrimSpace(raw) + if item == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "sub_flags cannot contain empty value") + } + values = append(values, item) + } + } + + if len(values) == 0 { + return nil, nil + } + + return utils.NormalizeFlagTypes(values), nil +} + +func resolveProductFlagsFromFlagInput(flagRaw *string, subFlagRaw *string, subFlagsRaw []string, hasSubFlagsField bool) ([]string, bool, error) { + if flagRaw == nil && subFlagRaw == nil && !hasSubFlagsField { + return nil, false, nil + } + + if flagRaw == nil && (subFlagRaw != nil || hasSubFlagsField) { + return nil, false, fiber.NewError(fiber.StatusBadRequest, "flag is required when sub_flag/sub_flags is provided") + } + + flagText := strings.TrimSpace(*flagRaw) + if flagText == "" { + return nil, false, fiber.NewError(fiber.StatusBadRequest, "flag cannot be empty") + } + + flag := utils.CanonicalFlagType(flagText) + if !utils.IsProductMainFlag(flag) { + return nil, false, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Invalid product flag: %s. Allowed flags: %s", flagText, strings.Join(productMainFlagOptionsString(), ", ")), + ) + } + + out := []string{string(flag)} + + normalizedSubFlags, err := normalizeStructuredSubFlagsInput(subFlagRaw, subFlagsRaw, hasSubFlagsField) + if err != nil { + return nil, false, err + } + + if len(normalizedSubFlags) == 0 { + if !utils.ProductFlagAllowWithoutSubFlag(flag) { + return nil, false, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("sub_flag/sub_flags is required for flag %s", string(flag)), + ) + } + return out, true, nil + } + + invalidSubFlags := make([]string, 0) + for _, subFlag := range normalizedSubFlags { + if !utils.IsValidProductSubFlag(flag, subFlag) { + invalidSubFlags = append(invalidSubFlags, string(subFlag)) + } + } + if len(invalidSubFlags) > 0 { + return nil, false, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Invalid sub_flags %s for flag %s. Allowed sub_flags: %s", strings.Join(invalidSubFlags, ", "), string(flag), strings.Join(productSubFlagOptionsString(flag), ", ")), + ) + } + + out = append(out, utils.FlagTypesToStrings(normalizedSubFlags)...) + return out, true, nil +} + +func resolveCreateProductFlags(req *validation.Create) ([]string, error) { + hasStructuredInput := req.Flag != nil || req.SubFlag != nil || req.SubFlags != nil + if len(req.Flags) > 0 && hasStructuredInput { + return nil, fiber.NewError(fiber.StatusBadRequest, "Use either flags or flag/sub_flag/sub_flags, not both") + } + + if len(req.Flags) > 0 { + return normalizeProductFlags(req.Flags) + } + + flags, _, err := resolveProductFlagsFromFlagInput(req.Flag, req.SubFlag, req.SubFlags, req.SubFlags != nil) + return flags, err +} + +func resolveUpdateProductFlags(req *validation.Update) (bool, []string, error) { + hasStructuredInput := req.Flag != nil || req.SubFlag != nil || req.SubFlags != nil + + if req.Flags != nil { + if hasStructuredInput { + if len(*req.Flags) > 0 { + return false, nil, fiber.NewError(fiber.StatusBadRequest, "Use either flags or flag/sub_flag/sub_flags, not both") + } + } else { + flags, err := normalizeProductFlags(*req.Flags) + if err != nil { + return false, nil, err + } + return true, flags, nil + } + } + + subFlagsRaw := make([]string, 0) + if req.SubFlags != nil { + subFlagsRaw = *req.SubFlags + } + flags, provided, err := resolveProductFlagsFromFlagInput(req.Flag, req.SubFlag, subFlagsRaw, req.SubFlags != nil) + if err != nil { + return false, nil, err + } + return provided, flags, nil +} + func NewProductService(repo repository.ProductRepository, validate *validator.Validate) ProductService { return &productService{ Log: utils.Log, @@ -177,7 +322,7 @@ func (s *productService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit } } - productFlags, flagErr := normalizeProductFlags(req.Flags) + productFlags, flagErr := resolveCreateProductFlags(req) if flagErr != nil { return nil, flagErr } @@ -337,13 +482,10 @@ func (s productService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) flagUpdate bool flagValues []string ) - if req.Flags != nil { - flagUpdate = true - var flagErr error - flagValues, flagErr = normalizeProductFlags(*req.Flags) - if flagErr != nil { - return nil, flagErr - } + var flagErr error + flagUpdate, flagValues, flagErr = resolveUpdateProductFlags(req) + if flagErr != nil { + return nil, flagErr } if len(updateBody) == 0 && !supplierUpdate && !flagUpdate { diff --git a/internal/modules/master/products/validations/product.validation.go b/internal/modules/master/products/validations/product.validation.go index 77e8e1bf..0a383192 100644 --- a/internal/modules/master/products/validations/product.validation.go +++ b/internal/modules/master/products/validations/product.validation.go @@ -6,31 +6,37 @@ type SupplierPrice struct { } type Create struct { - Name string `json:"name" validate:"required_strict,min=3,max=50"` - Brand string `json:"brand" validate:"required_strict,min=2,max=50"` - Sku *string `json:"sku,omitempty" validate:"omitempty,max=100"` - UomID uint `json:"uom_id" validate:"required,gt=0"` - ProductCategoryID uint `json:"product_category_id" validate:"required,gt=0"` - ProductPrice float64 `json:"product_price" validate:"required"` - SellingPrice *float64 `json:"selling_price,omitempty" validate:"omitempty"` - Tax *float64 `json:"tax,omitempty" validate:"omitempty"` - ExpiryPeriod *int `json:"expiry_period,omitempty" validate:"omitempty,gt=0"` + Name string `json:"name" validate:"required_strict,min=3,max=50"` + Brand string `json:"brand" validate:"required_strict,min=2,max=50"` + Sku *string `json:"sku,omitempty" validate:"omitempty,max=100"` + UomID uint `json:"uom_id" validate:"required,gt=0"` + ProductCategoryID uint `json:"product_category_id" validate:"required,gt=0"` + ProductPrice float64 `json:"product_price" validate:"required"` + SellingPrice *float64 `json:"selling_price,omitempty" validate:"omitempty"` + Tax *float64 `json:"tax,omitempty" validate:"omitempty"` + ExpiryPeriod *int `json:"expiry_period,omitempty" validate:"omitempty,gt=0"` Suppliers []SupplierPrice `json:"suppliers,omitempty" validate:"omitempty,dive"` - Flags []string `json:"flags,omitempty" validate:"omitempty,dive"` + Flag *string `json:"flag,omitempty" validate:"omitempty,max=50"` + SubFlag *string `json:"sub_flag,omitempty" validate:"omitempty,max=50"` + SubFlags []string `json:"sub_flags,omitempty" validate:"omitempty,dive,max=50"` + Flags []string `json:"flags,omitempty" validate:"omitempty,dive"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty,min=3"` - Brand *string `json:"brand,omitempty" validate:"omitempty,min=2"` - Sku *string `json:"sku,omitempty" validate:"omitempty"` - UomID *uint `json:"uom_id,omitempty" validate:"omitempty,gt=0"` - ProductCategoryID *uint `json:"product_category_id,omitempty" validate:"omitempty,gt=0"` - ProductPrice *float64 `json:"product_price,omitempty" validate:"omitempty"` - SellingPrice *float64 `json:"selling_price,omitempty" validate:"omitempty"` - Tax *float64 `json:"tax,omitempty" validate:"omitempty"` - ExpiryPeriod *int `json:"expiry_period,omitempty" validate:"omitempty,gt=0"` + Name *string `json:"name,omitempty" validate:"omitempty,min=3"` + Brand *string `json:"brand,omitempty" validate:"omitempty,min=2"` + Sku *string `json:"sku,omitempty" validate:"omitempty"` + UomID *uint `json:"uom_id,omitempty" validate:"omitempty,gt=0"` + ProductCategoryID *uint `json:"product_category_id,omitempty" validate:"omitempty,gt=0"` + ProductPrice *float64 `json:"product_price,omitempty" validate:"omitempty"` + SellingPrice *float64 `json:"selling_price,omitempty" validate:"omitempty"` + Tax *float64 `json:"tax,omitempty" validate:"omitempty"` + ExpiryPeriod *int `json:"expiry_period,omitempty" validate:"omitempty,gt=0"` Suppliers *[]SupplierPrice `json:"suppliers,omitempty" validate:"omitempty,dive"` - Flags *[]string `json:"flags,omitempty" validate:"omitempty,dive"` + Flag *string `json:"flag,omitempty" validate:"omitempty,max=50"` + SubFlag *string `json:"sub_flag,omitempty" validate:"omitempty,max=50"` + SubFlags *[]string `json:"sub_flags,omitempty" validate:"omitempty,dive,max=50"` + Flags *[]string `json:"flags,omitempty" validate:"omitempty,dive"` } type Query struct { diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 7d2e7a7f..ac9103ab 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -29,6 +29,8 @@ import ( var chickinUsableKey = fifo.UsableKeyProjectChickin +const chickinFunctionCodeOut = "CHICKIN_OUT" + type ChickinService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectChickin, error) @@ -162,25 +164,32 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti if productWarehouse.Product.Id != 0 { - var requiredFlag utils.FlagType + requiredFlags := make([]utils.FlagType, 0, 2) if projectFlockKandang.ProjectFlock.Category == string(utils.ProjectFlockCategoryGrowing) { - requiredFlag = utils.FlagDOC + requiredFlags = append(requiredFlags, utils.FlagAyam, utils.FlagDOC) } else if projectFlockKandang.ProjectFlock.Category == string(utils.ProjectFlockCategoryLaying) { - requiredFlag = utils.FlagPullet + requiredFlags = append(requiredFlags, utils.FlagAyam, utils.FlagPullet) } else { return nil, fmt.Errorf("invalid flock category for chickin") } hasRequiredFlag := false for _, flag := range productWarehouse.Product.Flags { - if utils.FlagType(flag.Name) == requiredFlag { - hasRequiredFlag = true + currentFlag := utils.FlagType(flag.Name) + for _, requiredFlag := range requiredFlags { + if currentFlag == requiredFlag { + hasRequiredFlag = true + break + } + } + if hasRequiredFlag { break } } if !hasRequiredFlag { - return nil, fmt.Errorf("product warehouse %d cannot be used for %s chickin. Product must have %s flag (product ID: %d, warehouse ID: %d)", chickinReq.ProductWarehouseId, projectFlockKandang.ProjectFlock.Category, requiredFlag, productWarehouse.Product.Id, productWarehouse.Id) + requiredText := strings.Join(utils.FlagTypesToStrings(requiredFlags), " or ") + return nil, fmt.Errorf("product warehouse %d cannot be used for %s chickin. Product must have %s flag (product ID: %d, warehouse ID: %d)", chickinReq.ProductWarehouseId, projectFlockKandang.ProjectFlock.Category, requiredText, productWarehouse.Product.Id, productWarehouse.Id) } } @@ -483,9 +492,9 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit var targetFlag utils.FlagType if category == string(utils.ProjectFlockCategoryGrowing) { - targetFlag = utils.FlagPullet + targetFlag = utils.FlagAyam } else if category == string(utils.ProjectFlockCategoryLaying) { - targetFlag = utils.FlagLayer + targetFlag = utils.FlagAyam } else { continue } @@ -621,8 +630,29 @@ func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, return nil } + route, err := commonSvc.ResolveFifoStockV2RouteByProductWarehouseIDAndLane( + ctx, + tx, + chickin.ProductWarehouseId, + chickinFunctionCodeOut, + commonSvc.FifoStockV2LaneUsable, + ) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve FIFO v2 chickin route") + } + if route == nil { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Product warehouse %d tidak mendukung transaksi Chickin pada matrix FIFO v2", chickin.ProductWarehouseId), + ) + } + usableKey := fifo.UsableKey(strings.TrimSpace(route.LegacyTypeKey)) + if usableKey == "" { + usableKey = chickinUsableKey + } + result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ - UsableKey: chickinUsableKey, + UsableKey: usableKey, UsableID: chickin.Id, ProductWarehouseID: chickin.ProductWarehouseId, Quantity: desiredQty, @@ -688,13 +718,34 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, return nil } + route, err := commonSvc.ResolveFifoStockV2RouteByProductWarehouseIDAndLane( + ctx, + tx, + chickin.ProductWarehouseId, + chickinFunctionCodeOut, + commonSvc.FifoStockV2LaneUsable, + ) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve FIFO v2 chickin route") + } + if route == nil { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Product warehouse %d tidak mendukung transaksi Chickin pada matrix FIFO v2", chickin.ProductWarehouseId), + ) + } + usableKey := fifo.UsableKey(strings.TrimSpace(route.LegacyTypeKey)) + if usableKey == "" { + usableKey = chickinUsableKey + } + var currentUsage float64 if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Select("usage_qty").Scan(¤tUsage).Error; err != nil { } if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ - UsableKey: chickinUsableKey, + UsableKey: usableKey, UsableID: chickin.Id, Tx: tx, }); err != nil { diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 5fd387bf..50d0f5fb 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -312,7 +312,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return nil, err } feedIDs := recordingutil.CollectWarehouseIDs(req.Stocks, func(st validation.Stock) uint { return st.ProductWarehouseId }) - if err := s.ensureProductWarehousesByFlags(ctx, feedIDs, []string{"PAKAN", "OVK"}, "feed"); err != nil { + if err := s.ensureProductWarehousesByFlags(ctx, feedIDs, []string{"PAKAN", "PRE-STARTER", "STARTER", "FINISHER", "OVK", "OBAT", "VITAMIN", "KIMIA"}, "feed"); err != nil { return nil, err } depletionIDs := recordingutil.CollectWarehouseIDs(req.Depletions, func(d validation.Depletion) uint { return d.ProductWarehouseId }) @@ -320,7 +320,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return nil, err } eggIDs := recordingutil.CollectWarehouseIDs(req.Eggs, func(e validation.Egg) uint { return e.ProductWarehouseId }) - if err := s.ensureProductWarehousesByFlags(ctx, eggIDs, []string{"TELUR-UTUH", "TELUR-PECAH", "TELUR-PUTIH", "TELUR-RETAK", "TELUR"}, "egg"); err != nil { + if err := s.ensureProductWarehousesByFlags(ctx, eggIDs, []string{"TELUR", "TELUR-UTUH", "TELUR-PUTIH", "TELUR-RETAK", "TELUR-PECAH", "TELUR-PAPACAL", "TELUR-JUMBO"}, "egg"); err != nil { return nil, err } actorID, err := m.ActorIDFromContext(c) @@ -512,7 +512,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return err } feedIDs := recordingutil.CollectWarehouseIDs(req.Stocks, func(st validation.Stock) uint { return st.ProductWarehouseId }) - if err := s.ensureProductWarehousesByFlags(ctx, feedIDs, []string{"PAKAN", "OVK"}, "feed"); err != nil { + if err := s.ensureProductWarehousesByFlags(ctx, feedIDs, []string{"PAKAN", "PRE-STARTER", "STARTER", "FINISHER", "OVK", "OBAT", "VITAMIN", "KIMIA"}, "feed"); err != nil { return err } if err := s.syncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, note, actorID); err != nil { @@ -613,7 +613,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return err } eggIDs := recordingutil.CollectWarehouseIDs(req.Eggs, func(e validation.Egg) uint { return e.ProductWarehouseId }) - if err := s.ensureProductWarehousesByFlags(ctx, eggIDs, []string{"TELUR-UTUH", "TELUR-PECAH", "TELUR-PUTIH", "TELUR-RETAK", "TELUR"}, "egg"); err != nil { + if err := s.ensureProductWarehousesByFlags(ctx, eggIDs, []string{"TELUR", "TELUR-UTUH", "TELUR-PUTIH", "TELUR-RETAK", "TELUR-PECAH", "TELUR-PAPACAL", "TELUR-JUMBO"}, "egg"); err != nil { return err } if err := ensureRecordingEggsUnused(existingEggs); err != nil { diff --git a/internal/modules/production/recordings/services/recording_fifo.service.go b/internal/modules/production/recordings/services/recording_fifo.service.go index eb9e5094..faa2f00c 100644 --- a/internal/modules/production/recordings/services/recording_fifo.service.go +++ b/internal/modules/production/recordings/services/recording_fifo.service.go @@ -21,7 +21,93 @@ import ( var recordingStockUsableKey = fifo.UsableKeyRecordingStock var recordingDepletionUsableKey = fifo.UsableKeyRecordingDepletion -const depletionUsageTolerance = 0.000001 +const ( + depletionUsageTolerance = 0.000001 + recordingFunctionCodeStockOut = "RECORDING_STOCK_OUT" + recordingFunctionCodeDepletionOut = "RECORDING_DEPLETION_OUT" + recordingFunctionCodeDepletionIn = "RECORDING_DEPLETION_IN" + recordingFunctionCodeRecordingEggIn = "RECORDING_EGG_IN" +) + +func (s *recordingService) resolveRecordingUsableKey( + ctx context.Context, + tx *gorm.DB, + productWarehouseID uint, + functionCode string, + fallback fifo.UsableKey, + actionLabel string, +) (fifo.UsableKey, error) { + if productWarehouseID == 0 { + return "", fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product warehouse tidak valid untuk transaksi %s", actionLabel)) + } + + route, err := commonSvc.ResolveFifoStockV2RouteByProductWarehouseIDAndLane( + ctx, + tx, + productWarehouseID, + functionCode, + commonSvc.FifoStockV2LaneUsable, + ) + if err != nil { + return "", fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve FIFO v2 recording route") + } + if route == nil { + return "", fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Product warehouse %d tidak mendukung transaksi %s pada matrix FIFO v2", productWarehouseID, actionLabel), + ) + } + + usableKey := fifo.UsableKey(strings.TrimSpace(route.LegacyTypeKey)) + if usableKey == "" { + usableKey = fallback + } + if usableKey == "" { + return "", fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 recording route misconfiguration") + } + + return usableKey, nil +} + +func (s *recordingService) resolveRecordingStockableKey( + ctx context.Context, + tx *gorm.DB, + productWarehouseID uint, + functionCode string, + fallback fifo.StockableKey, + actionLabel string, +) (fifo.StockableKey, error) { + if productWarehouseID == 0 { + return "", fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product warehouse tidak valid untuk transaksi %s", actionLabel)) + } + + route, err := commonSvc.ResolveFifoStockV2RouteByProductWarehouseIDAndLane( + ctx, + tx, + productWarehouseID, + functionCode, + commonSvc.FifoStockV2LaneStockable, + ) + if err != nil { + return "", fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve FIFO v2 recording route") + } + if route == nil { + return "", fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Product warehouse %d tidak mendukung transaksi %s pada matrix FIFO v2", productWarehouseID, actionLabel), + ) + } + + stockableKey := fifo.StockableKey(strings.TrimSpace(route.LegacyTypeKey)) + if stockableKey == "" { + stockableKey = fallback + } + if stockableKey == "" { + return "", fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 recording route misconfiguration") + } + + return stockableKey, nil +} func (s *recordingService) logStockTrace(action string, stock entity.RecordingStock, extra string) { if s == nil || s.Log == nil { @@ -125,8 +211,20 @@ func (s *recordingService) consumeRecordingStocks( } desiredTotal := desired + pending + usableKey, err := s.resolveRecordingUsableKey( + ctx, + tx, + stock.ProductWarehouseId, + recordingFunctionCodeStockOut, + recordingStockUsableKey, + "Recording (Stock)", + ) + if err != nil { + return err + } + result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ - UsableKey: recordingStockUsableKey, + UsableKey: usableKey, UsableID: stock.Id, ProductWarehouseID: stock.ProductWarehouseId, Quantity: desiredTotal, @@ -209,9 +307,21 @@ func (s *recordingService) consumeRecordingDepletions( return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion") } + usableKey, err := s.resolveRecordingUsableKey( + ctx, + tx, + sourceWarehouseID, + recordingFunctionCodeDepletionOut, + recordingDepletionUsableKey, + "Recording Depletion (Source)", + ) + if err != nil { + return err + } + desired := depletion.Qty + depletion.PendingQty result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ - UsableKey: recordingDepletionUsableKey, + UsableKey: usableKey, UsableID: depletion.Id, ProductWarehouseID: sourceWarehouseID, Quantity: desired, @@ -314,8 +424,20 @@ func (s *recordingService) releaseRecordingStocks( if stock.Id == 0 { continue } + usableKey, err := s.resolveRecordingUsableKey( + ctx, + tx, + stock.ProductWarehouseId, + recordingFunctionCodeStockOut, + recordingStockUsableKey, + "Recording (Stock)", + ) + if err != nil { + return err + } + if stock.UsageQty != nil && *stock.UsageQty > 0 { - activeCount, err := s.countActiveAllocations(ctx, tx, fifo.UsableKeyRecordingStock, stock.Id) + activeCount, err := s.countActiveAllocations(ctx, tx, usableKey, stock.Id) if err != nil { return err } @@ -326,16 +448,16 @@ func (s *recordingService) releaseRecordingStocks( } continue } - if err := s.resyncStockableUsageFromAllocations(ctx, tx, fifo.UsableKeyRecordingStock, stock.Id); err != nil { + if err := s.resyncStockableUsageFromAllocations(ctx, tx, usableKey, stock.Id); err != nil { return err } - if err := s.ensureActiveAllocations(ctx, tx, fifo.UsableKeyRecordingStock, stock.Id); err != nil { + if err := s.ensureActiveAllocations(ctx, tx, usableKey, stock.Id); err != nil { return err } } s.logStockTrace("release:start", stock, "") if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ - UsableKey: recordingStockUsableKey, + UsableKey: usableKey, UsableID: stock.Id, Tx: tx, }); err != nil { @@ -400,8 +522,28 @@ func (s *recordingService) releaseRecordingDepletions( if depletion.Id == 0 { continue } + + sourceWarehouseID := uint(0) + if depletion.SourceProductWarehouseId != nil { + sourceWarehouseID = *depletion.SourceProductWarehouseId + } + if sourceWarehouseID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion") + } + usableKey, err := s.resolveRecordingUsableKey( + ctx, + tx, + sourceWarehouseID, + recordingFunctionCodeDepletionOut, + recordingDepletionUsableKey, + "Recording Depletion (Source)", + ) + if err != nil { + return err + } + if depletion.UsageQty > 0 { - activeCount, err := s.countActiveAllocations(ctx, tx, fifo.UsableKeyRecordingDepletion, depletion.Id) + activeCount, err := s.countActiveAllocations(ctx, tx, usableKey, depletion.Id) if err != nil { return err } @@ -418,10 +560,10 @@ func (s *recordingService) releaseRecordingDepletions( } continue } - if err := s.resyncStockableUsageFromAllocations(ctx, tx, fifo.UsableKeyRecordingDepletion, depletion.Id); err != nil { + if err := s.resyncStockableUsageFromAllocations(ctx, tx, usableKey, depletion.Id); err != nil { return err } - if err := s.ensureActiveAllocations(ctx, tx, fifo.UsableKeyRecordingDepletion, depletion.Id); err != nil { + if err := s.ensureActiveAllocations(ctx, tx, usableKey, depletion.Id); err != nil { return err } } @@ -431,15 +573,8 @@ func (s *recordingService) releaseRecordingDepletions( return err } - sourceWarehouseID := uint(0) - if depletion.SourceProductWarehouseId != nil { - sourceWarehouseID = *depletion.SourceProductWarehouseId - } - if sourceWarehouseID == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion") - } if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ - UsableKey: recordingDepletionUsableKey, + UsableKey: usableKey, UsableID: depletion.Id, Tx: tx, }); err != nil { @@ -630,9 +765,20 @@ func (s *recordingService) replenishRecordingEggs( if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 { continue } + stockableKey, err := s.resolveRecordingStockableKey( + ctx, + tx, + egg.ProductWarehouseId, + recordingFunctionCodeRecordingEggIn, + fifo.StockableKeyRecordingEgg, + "Recording Egg", + ) + if err != nil { + return err + } s.logEggTrace("replenish:start", egg, "") if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ - StockableKey: fifo.StockableKeyRecordingEgg, + StockableKey: stockableKey, StockableID: egg.Id, ProductWarehouseID: egg.ProductWarehouseId, Quantity: float64(egg.Qty), @@ -690,9 +836,20 @@ func (s *recordingService) replenishRecordingDepletions( if depletion.Id == 0 || depletion.ProductWarehouseId == 0 || depletion.Qty <= 0 { continue } + stockableKey, err := s.resolveRecordingStockableKey( + ctx, + tx, + depletion.ProductWarehouseId, + recordingFunctionCodeDepletionIn, + fifo.StockableKeyRecordingDepletion, + "Recording Depletion (Destination)", + ) + if err != nil { + return err + } s.logDepletionTrace("replenish:start", depletion, "") if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ - StockableKey: fifo.StockableKeyRecordingDepletion, + StockableKey: stockableKey, StockableID: depletion.Id, ProductWarehouseID: depletion.ProductWarehouseId, Quantity: depletion.Qty, @@ -724,9 +881,20 @@ func (s *recordingService) reduceRecordingDepletions( if depletion.Id == 0 || depletion.ProductWarehouseId == 0 || depletion.Qty <= 0 { continue } + stockableKey, err := s.resolveRecordingStockableKey( + ctx, + tx, + depletion.ProductWarehouseId, + recordingFunctionCodeDepletionIn, + fifo.StockableKeyRecordingDepletion, + "Recording Depletion (Destination)", + ) + if err != nil { + return err + } s.logDepletionTrace("reduce:start", depletion, "") if err := s.FifoSvc.AdjustStockableQuantity(ctx, commonSvc.StockAdjustRequest{ - StockableKey: fifo.StockableKeyRecordingDepletion, + StockableKey: stockableKey, StockableID: depletion.Id, ProductWarehouseID: depletion.ProductWarehouseId, Quantity: -depletion.Qty, @@ -758,9 +926,20 @@ func (s *recordingService) reduceRecordingEggs( if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 { continue } + stockableKey, err := s.resolveRecordingStockableKey( + ctx, + tx, + egg.ProductWarehouseId, + recordingFunctionCodeRecordingEggIn, + fifo.StockableKeyRecordingEgg, + "Recording Egg", + ) + if err != nil { + return err + } s.logEggTrace("reduce:start", egg, "") if err := s.FifoSvc.AdjustStockableQuantity(ctx, commonSvc.StockAdjustRequest{ - StockableKey: fifo.StockableKeyRecordingEgg, + StockableKey: stockableKey, StockableID: egg.Id, ProductWarehouseID: egg.ProductWarehouseId, Quantity: -float64(egg.Qty), diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 50e891f5..fec5423c 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -43,7 +43,8 @@ type PurchaseService interface { } const ( - priceTolerance = 0.0001 + priceTolerance = 0.0001 + purchaseFunctionCodeIn = "PURCHASE_IN" ) type purchaseService struct { @@ -405,6 +406,14 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase indexMap[key] = len(aggregated) - 1 } + routeValidationProducts := make([]uint, 0, len(aggregated)) + for _, item := range aggregated { + routeValidationProducts = append(routeValidationProducts, item.productId) + } + if err := s.ensurePurchaseRouteSupport(c.Context(), s.PurchaseRepo.DB(), routeValidationProducts); err != nil { + return nil, err + } + var dueDate *time.Time now := time.Now().UTC() d := now.AddDate(0, 0, req.CreditTerm) @@ -987,6 +996,17 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation return nil, utils.BadRequest("Receiving data must be provided for all purchase items") } + receivingProductIDs := make([]uint, 0, len(prepared)) + for _, prep := range prepared { + if prep.item == nil || prep.item.ProductId == 0 { + continue + } + receivingProductIDs = append(receivingProductIDs, prep.item.ProductId) + } + if err := s.ensurePurchaseRouteSupport(c.Context(), s.PurchaseRepo.DB(), receivingProductIDs); err != nil { + return nil, err + } + receivingAction := action completedAction := entity.ApprovalActionApproved approvalSvc := commonSvc.NewApprovalService( @@ -1834,6 +1854,15 @@ func (s *purchaseService) buildStaffAdjustmentPayload( productSupplierCache := make(map[uint]bool) newItems := make([]*entity.PurchaseItem, 0, len(newPayloads)) emptyVehicle := "" + newProductIDs := make([]uint, 0, len(newPayloads)) + for _, payload := range newPayloads { + if payload.ProductID > 0 { + newProductIDs = append(newProductIDs, payload.ProductID) + } + } + if err := s.ensurePurchaseRouteSupport(ctx, s.PurchaseRepo.DB(), newProductIDs); err != nil { + return nil, err + } for _, payload := range newPayloads { if payload.ProductID == 0 || payload.WarehouseID == 0 { @@ -1918,6 +1947,48 @@ func calculateTotalPrice(quantity float64, price float64, provided *float64, ref return *provided, nil } +func (s *purchaseService) ensurePurchaseRouteSupport(ctx context.Context, db *gorm.DB, productIDs []uint) error { + if len(productIDs) == 0 { + return nil + } + + seen := make(map[uint]struct{}, len(productIDs)) + for _, productID := range productIDs { + if productID == 0 { + continue + } + if _, ok := seen[productID]; ok { + continue + } + seen[productID] = struct{}{} + + route, err := commonSvc.ResolveFifoStockV2RouteByProductIDAndLane( + ctx, + db, + productID, + purchaseFunctionCodeIn, + commonSvc.FifoStockV2LaneStockable, + ) + if err != nil { + s.Log.Errorf("Failed to resolve FIFO v2 PURCHASE_IN route for product %d: %+v", productID, err) + return utils.Internal("Failed to resolve FIFO v2 purchase route") + } + if route == nil { + return utils.BadRequest(fmt.Sprintf("Product %d tidak mendukung transaksi Purchase pada matrix FIFO v2", productID)) + } + if !strings.EqualFold(strings.TrimSpace(route.SourceTable), "purchase_items") { + s.Log.Errorf( + "Invalid FIFO v2 PURCHASE_IN route source table for product %d: expected purchase_items got %s", + productID, + route.SourceTable, + ) + return utils.Internal("FIFO v2 purchase route misconfiguration") + } + } + + return nil +} + func (s *purchaseService) attachLatestApproval(ctx context.Context, item *entity.Purchase) error { if item == nil || item.Id == 0 || s.ApprovalSvc == nil { return nil diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index b12fdfeb..38b2afc4 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -92,7 +92,7 @@ func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[u for _, flag := range mdp.MarketingProduct.ProductWarehouse.Product.Flags { ft := utils.FlagType(flag.Name) - if ft == utils.FlagAyamAfkir || ft == utils.FlagAyamCulling || ft == utils.FlagAyamMati || + if ft == utils.FlagAyam || ft == utils.FlagAyamAfkir || ft == utils.FlagAyamCulling || ft == utils.FlagAyamMati || ft == utils.FlagDOC || ft == utils.FlagPullet || ft == utils.FlagLayer { hasAyam = true } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 1829b941..d0b32164 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -14,9 +14,18 @@ type FlagType string type FlagGroup string +type ProductFlagOption struct { + Flag FlagType `json:"flag"` + SubFlags []FlagType `json:"sub_flags"` + AllowWithoutSubFlag bool `json:"allow_without_sub_flag"` +} + const ( FlagIsActive FlagType = "IS_ACTIVE" + FlagAyam FlagType = "AYAM" + + // Legacy AYAM flags kept for backward compatibility with existing production data. FlagDOC FlagType = "DOC" FlagPullet FlagType = "PULLET" FlagLayer FlagType = "LAYER" @@ -36,11 +45,13 @@ const ( FlagAyamMati FlagType = "AYAM-MATI" //flag telur - FlagTelur FlagType = "TELUR" - FlagTelurUtuh FlagType = "TELUR-UTUH" - FlagTelurPecah FlagType = "TELUR-PECAH" - FlagTelurPutih FlagType = "TELUR-PUTIH" - FlagTelurRetak FlagType = "TELUR-RETAK" + FlagTelur FlagType = "TELUR" + FlagTelurUtuh FlagType = "TELUR-UTUH" + FlagTelurPecah FlagType = "TELUR-PECAH" + FlagTelurPutih FlagType = "TELUR-PUTIH" + FlagTelurRetak FlagType = "TELUR-RETAK" + FlagTelurPapacal FlagType = "TELUR-PAPACAL" + FlagTelurJumbo FlagType = "TELUR-JUMBO" ) const ( @@ -50,9 +61,10 @@ const ( var flagGroupOptions = map[FlagGroup][]FlagType{ FlagGroupProduct: { - FlagDOC, - FlagPullet, - FlagLayer, + FlagAyam, + FlagAyamAfkir, + FlagAyamCulling, + FlagAyamMati, FlagPakan, FlagPreStarter, FlagStarter, @@ -61,12 +73,82 @@ var flagGroupOptions = map[FlagGroup][]FlagType{ FlagObat, FlagVitamin, FlagKimia, + FlagTelur, + FlagTelurUtuh, + FlagTelurPutih, + FlagTelurRetak, + FlagTelurPecah, + FlagTelurPapacal, + FlagTelurJumbo, }, FlagGroupNonstock: { FlagEkspedisi, }, } +var legacyFlagTypeAliases = map[FlagType]FlagType{ + FlagDOC: FlagAyam, + FlagPullet: FlagAyam, + FlagLayer: FlagAyam, +} + +var productMainFlags = []FlagType{ + FlagAyam, + FlagPakan, + FlagOVK, + FlagTelur, +} + +var productSubFlagsByFlag = map[FlagType][]FlagType{ + FlagAyam: { + FlagAyamAfkir, + FlagAyamCulling, + FlagAyamMati, + }, + FlagPakan: { + FlagPreStarter, + FlagStarter, + FlagFinisher, + }, + FlagOVK: { + FlagObat, + FlagVitamin, + FlagKimia, + }, + FlagTelur: { + FlagTelurUtuh, + FlagTelurPutih, + FlagTelurRetak, + FlagTelurPecah, + FlagTelurPapacal, + FlagTelurJumbo, + }, +} + +var productSubFlagToFlag = func() map[FlagType]FlagType { + out := make(map[FlagType]FlagType) + for flag, subFlags := range productSubFlagsByFlag { + for _, subFlag := range subFlags { + out[subFlag] = flag + } + } + return out +}() + +var productAllowWithoutSubFlagByFlag = map[FlagType]bool{ + FlagAyam: true, + FlagPakan: false, + FlagOVK: false, + FlagTelur: false, +} + +func canonicalizeFlagType(flag FlagType) FlagType { + if canonical, ok := legacyFlagTypeAliases[flag]; ok { + return canonical + } + return flag +} + var allFlagTypes = func() map[FlagType]struct{} { m := map[FlagType]struct{}{ FlagIsActive: {}, @@ -83,6 +165,95 @@ func AllFlagTypes() map[FlagType]struct{} { return allFlagTypes } +func CanonicalFlagType(v string) FlagType { + normalized := FlagType(strings.ToUpper(strings.TrimSpace(v))) + if normalized == "" { + return "" + } + return canonicalizeFlagType(normalized) +} + +func LegacyFlagTypeAliases() map[FlagType]FlagType { + out := make(map[FlagType]FlagType, len(legacyFlagTypeAliases)) + for legacy, canonical := range legacyFlagTypeAliases { + out[legacy] = canonical + } + return out +} + +func ProductMainFlags() []FlagType { + out := make([]FlagType, len(productMainFlags)) + copy(out, productMainFlags) + return out +} + +func ProductSubFlagsByFlag() map[FlagType][]FlagType { + out := make(map[FlagType][]FlagType, len(productSubFlagsByFlag)) + for flag, subFlags := range productSubFlagsByFlag { + dup := make([]FlagType, len(subFlags)) + copy(dup, subFlags) + out[flag] = dup + } + return out +} + +func ProductSubFlagToFlag() map[FlagType]FlagType { + out := make(map[FlagType]FlagType, len(productSubFlagToFlag)) + for subFlag, flag := range productSubFlagToFlag { + out[subFlag] = flag + } + return out +} + +func ProductFlagOptions() []ProductFlagOption { + result := make([]ProductFlagOption, 0, len(productMainFlags)) + for _, flag := range productMainFlags { + subFlags := productSubFlagsByFlag[flag] + dup := make([]FlagType, len(subFlags)) + copy(dup, subFlags) + result = append(result, ProductFlagOption{ + Flag: flag, + SubFlags: dup, + AllowWithoutSubFlag: productAllowWithoutSubFlagByFlag[flag], + }) + } + return result +} + +func ProductFlagAllowWithoutSubFlag(flag FlagType) bool { + canonical := canonicalizeFlagType(flag) + allow, ok := productAllowWithoutSubFlagByFlag[canonical] + if !ok { + return false + } + return allow +} + +func IsProductMainFlag(flag FlagType) bool { + canonical := canonicalizeFlagType(flag) + for _, f := range productMainFlags { + if f == canonical { + return true + } + } + return false +} + +func IsValidProductSubFlag(flag FlagType, subFlag FlagType) bool { + canonicalFlag := canonicalizeFlagType(flag) + canonicalSubFlag := canonicalizeFlagType(subFlag) + allowedSubFlags, ok := productSubFlagsByFlag[canonicalFlag] + if !ok { + return false + } + for _, allowed := range allowedSubFlags { + if allowed == canonicalSubFlag { + return true + } + } + return false +} + // ------------------------------------------------------------------- // WarehouseType // ------------------------------------------------------------------- @@ -621,7 +792,11 @@ const ( // ------------------------------------------------------------------- func IsValidFlagType(v string) bool { - _, ok := allFlagTypes[FlagType(strings.ToUpper(strings.TrimSpace(v)))] + flag := FlagType(strings.ToUpper(strings.TrimSpace(v))) + if _, ok := allFlagTypes[flag]; ok { + return true + } + _, ok := legacyFlagTypeAliases[flag] return ok } @@ -667,6 +842,7 @@ func NormalizeFlagTypes(flags []string) []FlagType { if normalized == "" { continue } + normalized = canonicalizeFlagType(normalized) if _, exists := seen[normalized]; exists { continue }