diff --git a/internal/common/service/fifo_stock_v2/allocate.go b/internal/common/service/fifo_stock_v2/allocate.go index 475b2172..33c3887b 100644 --- a/internal/common/service/fifo_stock_v2/allocate.go +++ b/internal/common/service/fifo_stock_v2/allocate.go @@ -141,6 +141,9 @@ func (s *fifoStockV2Service) allocateInternal(ctx context.Context, tx *gorm.DB, if remaining <= 0 { break } + if shouldSkipStockableForUsable(req, lot.Ref.LegacyTypeKey) { + continue + } if lot.AvailableQuantity <= 0 { continue } @@ -207,6 +210,20 @@ func (s *fifoStockV2Service) allocateInternal(ctx context.Context, tx *gorm.DB, return result, nil } +func shouldSkipStockableForUsable(req AllocateRequest, stockableType string) bool { + usableType := strings.ToUpper(strings.TrimSpace(req.Usable.LegacyTypeKey)) + functionCode := strings.ToUpper(strings.TrimSpace(req.Usable.FunctionCode)) + stockable := strings.ToUpper(strings.TrimSpace(stockableType)) + + // CHICKIN_OUT must consume physical stock sources, not population lots, + // otherwise approved chickin can consume its own just-created population. + if (usableType == "PROJECT_CHICKIN" || functionCode == "CHICKIN_OUT") && stockable == "PROJECT_FLOCK_POPULATION" { + return true + } + + return false +} + func (s *fifoStockV2Service) Rollback(ctx context.Context, req RollbackRequest) (*RollbackResult, error) { if err := s.validateRollbackRequest(req); err != nil { return nil, err diff --git a/internal/database/migrations/20260304101500_add_economic_cutoff_date_to_laying_transfers.down.sql b/internal/database/migrations/20260304101500_add_economic_cutoff_date_to_laying_transfers.down.sql new file mode 100644 index 00000000..528e18f6 --- /dev/null +++ b/internal/database/migrations/20260304101500_add_economic_cutoff_date_to_laying_transfers.down.sql @@ -0,0 +1,8 @@ +BEGIN; + +DROP INDEX IF EXISTS idx_laying_transfers_economic_cutoff_date; + +ALTER TABLE laying_transfers + DROP COLUMN IF EXISTS economic_cutoff_date; + +COMMIT; diff --git a/internal/database/migrations/20260304101500_add_economic_cutoff_date_to_laying_transfers.up.sql b/internal/database/migrations/20260304101500_add_economic_cutoff_date_to_laying_transfers.up.sql new file mode 100644 index 00000000..d6793f2c --- /dev/null +++ b/internal/database/migrations/20260304101500_add_economic_cutoff_date_to_laying_transfers.up.sql @@ -0,0 +1,13 @@ +BEGIN; + +ALTER TABLE laying_transfers + ADD COLUMN IF NOT EXISTS economic_cutoff_date DATE; + +CREATE INDEX IF NOT EXISTS idx_laying_transfers_economic_cutoff_date + ON laying_transfers(economic_cutoff_date); + +UPDATE laying_transfers +SET economic_cutoff_date = COALESCE(economic_cutoff_date, effective_move_date, transfer_date) +WHERE economic_cutoff_date IS NULL; + +COMMIT; diff --git a/internal/database/migrations/20260307130342_single_source_transfer_laying_header.down.sql b/internal/database/migrations/20260307130342_single_source_transfer_laying_header.down.sql new file mode 100644 index 00000000..f453b3df --- /dev/null +++ b/internal/database/migrations/20260307130342_single_source_transfer_laying_header.down.sql @@ -0,0 +1,60 @@ +BEGIN; + +UPDATE fifo_stock_v2_route_rules +SET + is_active = TRUE, + updated_at = NOW() +WHERE flag_group_code = 'AYAM' + AND lane = 'USABLE' + AND function_code = 'TRANSFER_TO_LAYING_OUT' + AND source_table = 'laying_transfer_sources'; + +UPDATE fifo_stock_v2_route_rules +SET + is_active = FALSE, + updated_at = NOW() +WHERE flag_group_code = 'AYAM' + AND lane = 'USABLE' + AND function_code = 'TRANSFER_TO_LAYING_OUT' + AND source_table = 'laying_transfers'; + +UPDATE fifo_stock_v2_traits +SET is_active = TRUE +WHERE source_table = 'laying_transfer_sources' + AND lane = 'USABLE'; + +UPDATE fifo_stock_v2_traits +SET is_active = FALSE +WHERE source_table = 'laying_transfers' + AND lane = 'USABLE'; + +DROP INDEX IF EXISTS idx_laying_transfers_source_project_flock_kandang_id; +DROP INDEX IF EXISTS idx_laying_transfers_source_product_warehouse_id; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_laying_transfers_source_project_flock_kandang_id' + ) THEN + ALTER TABLE laying_transfers + DROP CONSTRAINT fk_laying_transfers_source_project_flock_kandang_id; + END IF; + + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_laying_transfers_source_product_warehouse_id' + ) THEN + ALTER TABLE laying_transfers + DROP CONSTRAINT fk_laying_transfers_source_product_warehouse_id; + END IF; +END $$; + +ALTER TABLE laying_transfers + DROP COLUMN IF EXISTS source_project_flock_kandang_id, + DROP COLUMN IF EXISTS source_product_warehouse_id, + DROP COLUMN IF EXISTS source_requested_qty, + DROP COLUMN IF EXISTS source_usage_qty, + DROP COLUMN IF EXISTS source_pending_usage_qty; + +COMMIT; diff --git a/internal/database/migrations/20260307130342_single_source_transfer_laying_header.up.sql b/internal/database/migrations/20260307130342_single_source_transfer_laying_header.up.sql new file mode 100644 index 00000000..dc3a4d67 --- /dev/null +++ b/internal/database/migrations/20260307130342_single_source_transfer_laying_header.up.sql @@ -0,0 +1,170 @@ +BEGIN; + +ALTER TABLE laying_transfers + ADD COLUMN IF NOT EXISTS source_project_flock_kandang_id BIGINT, + ADD COLUMN IF NOT EXISTS source_product_warehouse_id BIGINT, + ADD COLUMN IF NOT EXISTS source_requested_qty NUMERIC(15,3) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS source_usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS source_pending_usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flock_kandangs') + AND NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_laying_transfers_source_project_flock_kandang_id' + ) THEN + ALTER TABLE laying_transfers + ADD CONSTRAINT fk_laying_transfers_source_project_flock_kandang_id + FOREIGN KEY (source_project_flock_kandang_id) + REFERENCES project_flock_kandangs(id) + ON DELETE RESTRICT + ON UPDATE CASCADE; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'product_warehouses') + AND NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_laying_transfers_source_product_warehouse_id' + ) THEN + ALTER TABLE laying_transfers + ADD CONSTRAINT fk_laying_transfers_source_product_warehouse_id + FOREIGN KEY (source_product_warehouse_id) + REFERENCES product_warehouses(id) + ON DELETE SET NULL + ON UPDATE CASCADE; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_laying_transfers_source_project_flock_kandang_id + ON laying_transfers(source_project_flock_kandang_id); + +CREATE INDEX IF NOT EXISTS idx_laying_transfers_source_product_warehouse_id + ON laying_transfers(source_product_warehouse_id); + +WITH single_source AS ( + SELECT + lts.laying_transfer_id, + MIN(lts.source_project_flock_kandang_id) AS source_project_flock_kandang_id, + MIN(lts.product_warehouse_id) AS source_product_warehouse_id + FROM laying_transfer_sources lts + WHERE lts.deleted_at IS NULL + GROUP BY lts.laying_transfer_id + HAVING COUNT(*) = 1 +) +UPDATE laying_transfers lt +SET + source_project_flock_kandang_id = ss.source_project_flock_kandang_id, + source_product_warehouse_id = ss.source_product_warehouse_id +FROM single_source ss +WHERE lt.id = ss.laying_transfer_id + AND (lt.source_project_flock_kandang_id IS NULL OR lt.source_project_flock_kandang_id = 0); + +WITH source_totals AS ( + SELECT + laying_transfer_id, + COALESCE(SUM(requested_qty), 0) AS requested_qty, + COALESCE(SUM(usage_qty), 0) AS usage_qty, + COALESCE(SUM(pending_usage_qty), 0) AS pending_qty + FROM laying_transfer_sources + WHERE deleted_at IS NULL + GROUP BY laying_transfer_id +) +UPDATE laying_transfers lt +SET + source_requested_qty = CASE + WHEN lt.source_requested_qty = 0 THEN st.requested_qty + ELSE lt.source_requested_qty + END, + source_usage_qty = CASE + WHEN lt.source_usage_qty = 0 THEN st.usage_qty + ELSE lt.source_usage_qty + END, + source_pending_usage_qty = CASE + WHEN lt.source_pending_usage_qty = 0 THEN st.pending_qty + ELSE lt.source_pending_usage_qty + END +FROM source_totals st +WHERE lt.id = st.laying_transfer_id; + +WITH target_totals AS ( + SELECT + laying_transfer_id, + COALESCE(SUM(total_qty), 0) AS total_qty + FROM laying_transfer_targets + WHERE deleted_at IS NULL + GROUP BY laying_transfer_id +) +UPDATE laying_transfers lt +SET source_requested_qty = tt.total_qty +FROM target_totals tt +WHERE lt.id = tt.laying_transfer_id + AND (lt.source_requested_qty IS NULL OR lt.source_requested_qty = 0); + +INSERT INTO fifo_stock_v2_traits( + source_table, + lane, + date_table, + date_join_left_col, + date_join_right_col, + date_column, + fallback_date_column, + sort_priority, + id_column +) +VALUES + ('laying_transfers', 'USABLE', NULL, NULL, NULL, 'transfer_date', NULL, 25, 'id') +ON CONFLICT (source_table, lane) DO UPDATE +SET + date_table = EXCLUDED.date_table, + date_join_left_col = EXCLUDED.date_join_left_col, + date_join_right_col = EXCLUDED.date_join_right_col, + date_column = EXCLUDED.date_column, + fallback_date_column = EXCLUDED.fallback_date_column, + sort_priority = EXCLUDED.sort_priority, + id_column = EXCLUDED.id_column, + is_active = TRUE; + +UPDATE fifo_stock_v2_traits +SET is_active = FALSE +WHERE source_table = 'laying_transfer_sources' + AND lane = 'USABLE'; + +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_transfers', 'id', 'source_product_warehouse_id', 'source_usage_qty', NULL, 'source_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_route_rules +SET + is_active = FALSE, + updated_at = NOW() +WHERE flag_group_code = 'AYAM' + AND lane = 'USABLE' + AND function_code = 'TRANSFER_TO_LAYING_OUT' + AND source_table = 'laying_transfer_sources'; + +COMMIT; diff --git a/internal/database/migrations/20260308094032_fix_fifo_v2_population_route_rule.down.sql b/internal/database/migrations/20260308094032_fix_fifo_v2_population_route_rule.down.sql new file mode 100644 index 00000000..23b322ec --- /dev/null +++ b/internal/database/migrations/20260308094032_fix_fifo_v2_population_route_rule.down.sql @@ -0,0 +1,18 @@ +BEGIN; + +UPDATE fifo_stock_v2_route_rules +SET + is_active = FALSE, + updated_at = NOW() +WHERE flag_group_code = 'AYAM' + AND lane = 'STOCKABLE' + AND function_code = 'POPULATION_IN' + AND source_table = 'project_flock_populations' + AND legacy_type_key = 'PROJECT_FLOCK_POPULATION'; + +UPDATE fifo_stock_v2_traits +SET is_active = FALSE +WHERE source_table = 'project_flock_populations' + AND lane = 'STOCKABLE'; + +COMMIT; diff --git a/internal/database/migrations/20260308094032_fix_fifo_v2_population_route_rule.up.sql b/internal/database/migrations/20260308094032_fix_fifo_v2_population_route_rule.up.sql new file mode 100644 index 00000000..ea087674 --- /dev/null +++ b/internal/database/migrations/20260308094032_fix_fifo_v2_population_route_rule.up.sql @@ -0,0 +1,81 @@ +BEGIN; + +INSERT INTO fifo_stock_v2_traits( + source_table, + lane, + date_table, + date_join_left_col, + date_join_right_col, + date_column, + fallback_date_column, + sort_priority, + id_column, + is_active +) +VALUES + ('project_flock_populations', 'STOCKABLE', 'project_chickins', 'project_chickin_id', 'id', 'chick_in_date', 'created_at', 55, 'id', TRUE) +ON CONFLICT (source_table, lane) DO UPDATE +SET + date_table = EXCLUDED.date_table, + date_join_left_col = EXCLUDED.date_join_left_col, + date_join_right_col = EXCLUDED.date_join_right_col, + date_column = EXCLUDED.date_column, + fallback_date_column = EXCLUDED.fallback_date_column, + sort_priority = EXCLUDED.sort_priority, + id_column = EXCLUDED.id_column, + is_active = TRUE; + +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', 'STOCKABLE', 'POPULATION_IN', 'project_flock_populations', 'id', 'product_warehouse_id', 'total_qty', 'total_used_qty', NULL, NULL, 'PROJECT_FLOCK_POPULATION', 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 project_flock_populations p +SET total_used_qty = COALESCE(a.used, 0) +FROM ( + SELECT stockable_id, SUM(qty) AS used + FROM stock_allocations + WHERE stockable_type = 'PROJECT_FLOCK_POPULATION' + AND status = 'ACTIVE' + AND allocation_purpose = 'CONSUME' + GROUP BY stockable_id +) a +WHERE p.id = a.stockable_id; + +UPDATE project_flock_populations p +SET total_used_qty = 0 +WHERE NOT EXISTS ( + SELECT 1 + FROM stock_allocations sa + WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION' + AND sa.status = 'ACTIVE' + AND sa.allocation_purpose = 'CONSUME' + AND sa.stockable_id = p.id +); + +COMMIT; diff --git a/internal/database/migrations/20260308113033_enable_fifo_v2_chickin_out.down.sql b/internal/database/migrations/20260308113033_enable_fifo_v2_chickin_out.down.sql new file mode 100644 index 00000000..b5f25173 --- /dev/null +++ b/internal/database/migrations/20260308113033_enable_fifo_v2_chickin_out.down.sql @@ -0,0 +1,12 @@ +BEGIN; + +UPDATE fifo_stock_v2_route_rules +SET + is_active = FALSE, + updated_at = NOW() +WHERE flag_group_code = 'AYAM' + AND lane = 'USABLE' + AND function_code = 'CHICKIN_OUT' + AND source_table = 'project_chickins'; + +COMMIT; diff --git a/internal/database/migrations/20260308113033_enable_fifo_v2_chickin_out.up.sql b/internal/database/migrations/20260308113033_enable_fifo_v2_chickin_out.up.sql new file mode 100644 index 00000000..274aec5c --- /dev/null +++ b/internal/database/migrations/20260308113033_enable_fifo_v2_chickin_out.up.sql @@ -0,0 +1,33 @@ +BEGIN; + +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', 'CHICKIN_OUT', 'project_chickins', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'PROJECT_CHICKIN', 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(); + +COMMIT; diff --git a/internal/entities/laying_transfer.go b/internal/entities/laying_transfer.go index db5ca775..e24e8563 100644 --- a/internal/entities/laying_transfer.go +++ b/internal/entities/laying_transfer.go @@ -7,25 +7,33 @@ import ( ) type LayingTransfer struct { - Id uint `gorm:"primaryKey"` - TransferNumber string `gorm:"uniqueIndex;not null"` - FromProjectFlockId uint `gorm:"not null"` - ToProjectFlockId uint `gorm:"not null"` - TransferDate time.Time `gorm:"type:date;not null"` - EffectiveMoveDate *time.Time `gorm:"type:date"` - ExecutedAt *time.Time `gorm:"type:timestamptz"` - ExecutedBy *uint `gorm:"index"` - Notes string `gorm:"type:text"` - CreatedBy uint `gorm:"not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - DeletedAt gorm.DeletedAt `gorm:"index"` + Id uint `gorm:"primaryKey"` + TransferNumber string `gorm:"uniqueIndex;not null"` + FromProjectFlockId uint `gorm:"not null"` + ToProjectFlockId uint `gorm:"not null"` + SourceProjectFlockKandangId *uint `gorm:"index"` + SourceProductWarehouseId *uint `gorm:"index"` + SourceRequestedQty float64 `gorm:"type:numeric(15,3);default:0;not null"` + SourceUsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"` + SourcePendingUsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"` + TransferDate time.Time `gorm:"type:date;not null"` + EconomicCutoffDate *time.Time `gorm:"type:date"` + EffectiveMoveDate *time.Time `gorm:"type:date"` + ExecutedAt *time.Time `gorm:"type:timestamptz"` + ExecutedBy *uint `gorm:"index"` + Notes string `gorm:"type:text"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index"` - FromProjectFlock *ProjectFlock `gorm:"foreignKey:FromProjectFlockId;references:Id"` - ToProjectFlock *ProjectFlock `gorm:"foreignKey:ToProjectFlockId;references:Id"` - CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` - ExecutedUser *User `gorm:"foreignKey:ExecutedBy;references:Id"` - Sources []LayingTransferSource `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"` - Targets []LayingTransferTarget `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"` - LatestApproval *Approval `gorm:"-" json:"-"` + FromProjectFlock *ProjectFlock `gorm:"foreignKey:FromProjectFlockId;references:Id"` + ToProjectFlock *ProjectFlock `gorm:"foreignKey:ToProjectFlockId;references:Id"` + SourceProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:SourceProjectFlockKandangId;references:Id"` + SourceProductWarehouse *ProductWarehouse `gorm:"foreignKey:SourceProductWarehouseId;references:Id"` + CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` + ExecutedUser *User `gorm:"foreignKey:ExecutedBy;references:Id"` + Sources []LayingTransferSource `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"` + Targets []LayingTransferTarget `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"` + LatestApproval *Approval `gorm:"-" json:"-"` } diff --git a/internal/entities/recording.go b/internal/entities/recording.go index 1ca3deb2..fa20907f 100644 --- a/internal/entities/recording.go +++ b/internal/entities/recording.go @@ -43,4 +43,6 @@ type Recording struct { StandardEggMass *float64 `gorm:"-"` StandardEggWeight *float64 `gorm:"-"` StandardFcr *float64 `gorm:"-"` + PopulationCanChange *bool `gorm:"-"` + TransferExecuted *bool `gorm:"-"` } diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index b475dab0..61ee47d4 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -1291,8 +1291,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand COALESCE(p.product_price, 0) AS price `). Joins("JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id"). - Joins("LEFT JOIN laying_transfer_sources lts ON lts.laying_transfer_id = lt.id"). - Joins("LEFT JOIN product_warehouses pw_source ON pw_source.id = lts.product_warehouse_id"). + Joins("LEFT JOIN product_warehouses pw_source ON pw_source.id = lt.source_product_warehouse_id"). Joins("LEFT JOIN warehouses w_source ON w_source.id = pw_source.warehouse_id"). Joins("JOIN product_warehouses pw ON pw.id = ltt.product_warehouse_id"). Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). @@ -1352,8 +1351,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand COALESCE(SUM(sa.qty), 0) AS qty_out, COALESCE(p.product_price, 0) AS price `). - Joins("JOIN laying_transfer_sources lts ON lts.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyTransferToLayingOut.String()). - Joins("JOIN laying_transfers lt ON lt.id = lts.laying_transfer_id"). + Joins("JOIN laying_transfers lt ON lt.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyTransferToLayingOut.String()). Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.laying_transfer_id = lt.id"). Joins("LEFT JOIN product_warehouses pw_dest ON pw_dest.id = ltt.product_warehouse_id"). Joins("LEFT JOIN warehouses w_dest ON w_dest.id = pw_dest.warehouse_id"). @@ -1365,7 +1363,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand Where("w.kandang_id = ?", kandangID). Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)"). Where("f.name IN ?", sapronakFlagsAll). - Group("lts.id, pw.product_id, p.name, f.name, lt.transfer_date, lt.transfer_number, p.product_price") + Group("lt.id, pw.product_id, p.name, f.name, lt.transfer_date, lt.transfer_number, p.product_price") outgoingLayingQuery = r.joinSapronakProductFlag(outgoingLayingQuery, "p") outgoingLaying, err := scanAndGroupDetails(outgoingLayingQuery) if err != nil { diff --git a/internal/modules/inventory/adjustments/module.go b/internal/modules/inventory/adjustments/module.go index 42c8332d..72071017 100644 --- a/internal/modules/inventory/adjustments/module.go +++ b/internal/modules/inventory/adjustments/module.go @@ -26,6 +26,7 @@ func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat warehouseRepo := rWarehouse.NewWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) + projectFlockPopulationRepo := rProjectFlockKandang.NewProjectFlockPopulationRepository(db) userRepo := rUser.NewUserRepository(db) productRepo := rproduct.NewProductRepository(db) adjustmentStockRepo := rAdjustmentStock.NewAdjustmentStockRepository(db) @@ -40,6 +41,7 @@ func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat fifoStockV2Service, validate, projectFlockKandangRepo, + projectFlockPopulationRepo, ) userService := sUser.NewUserService(userRepo, validate) diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 8e92f036..72a15e3a 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -11,6 +11,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" common "gitlab.com/mbugroup/lti-api.git/internal/common/service" + fifoV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" adjustmentStockRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories" @@ -21,6 +22,7 @@ import ( projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" stockLogsRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gorm.io/gorm" ) @@ -31,15 +33,16 @@ type AdjustmentService interface { } type adjustmentService struct { - Log *logrus.Logger - Validate *validator.Validate - StockLogsRepository stockLogsRepo.StockLogRepository - WarehouseRepo warehouseRepo.WarehouseRepository - ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository - ProductRepo productRepo.ProductRepository - ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository - AdjustmentStockRepository adjustmentStockRepo.AdjustmentStockRepository - FifoStockV2Svc common.FifoStockV2Service + Log *logrus.Logger + Validate *validator.Validate + StockLogsRepository stockLogsRepo.StockLogRepository + WarehouseRepo warehouseRepo.WarehouseRepository + ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository + ProductRepo productRepo.ProductRepository + ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + ProjectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository + AdjustmentStockRepository adjustmentStockRepo.AdjustmentStockRepository + FifoStockV2Svc common.FifoStockV2Service } const ( @@ -57,17 +60,19 @@ func NewAdjustmentService( fifoStockV2Svc common.FifoStockV2Service, validate *validator.Validate, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, + projectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository, ) AdjustmentService { return &adjustmentService{ - Log: utils.Log, - Validate: validate, - StockLogsRepository: stockLogsRepo, - WarehouseRepo: warehouseRepo, - ProductWarehouseRepo: productWarehouseRepo, - ProductRepo: productRepo, - ProjectFlockKandangRepo: projectFlockKandangRepo, - AdjustmentStockRepository: adjustmentStockRepo, - FifoStockV2Svc: fifoStockV2Svc, + Log: utils.Log, + Validate: validate, + StockLogsRepository: stockLogsRepo, + WarehouseRepo: warehouseRepo, + ProductWarehouseRepo: productWarehouseRepo, + ProductRepo: productRepo, + ProjectFlockKandangRepo: projectFlockKandangRepo, + ProjectFlockPopulationRepo: projectFlockPopulationRepo, + AdjustmentStockRepository: adjustmentStockRepo, + FifoStockV2Svc: fifoStockV2Svc, } } @@ -309,6 +314,22 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to refresh depletion destination adjustment stock") } + consumedPopulationQty := refreshedSource.UsageQty + refreshedSource.PendingQty + if consumedPopulationQty > 0 { + if err := s.allocatePopulationForDepletionAdjustment( + ctx, + tx, + *projectFlockKandangID, + sourcePW.Id, + refreshedSource.Id, + consumedPopulationQty, + ); err != nil { + return err + } + if err := s.resyncProjectFlockPopulationUsage(ctx, tx, *projectFlockKandangID); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to resync project flock population usage") + } + } if err := s.createAdjustmentStockLog( ctx, @@ -614,6 +635,98 @@ func (s *adjustmentService) createAdjustmentStockLog( return stockLogRepo.CreateOne(ctx, newLog, nil) } +func (s *adjustmentService) allocatePopulationForDepletionAdjustment( + ctx context.Context, + tx *gorm.DB, + projectFlockKandangID uint, + sourceProductWarehouseID uint, + adjustmentID uint, + consumeQty float64, +) error { + if consumeQty <= 0 { + return nil + } + if tx == nil { + return errors.New("transaction is required") + } + if projectFlockKandangID == 0 || sourceProductWarehouseID == 0 || adjustmentID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid depletion adjustment population context") + } + if s.ProjectFlockPopulationRepo == nil { + return fiber.NewError(fiber.StatusInternalServerError, "Project flock population repository is not available") + } + + popRepoTx := s.ProjectFlockPopulationRepo.WithTx(tx) + populations, err := popRepoTx.GetByProjectFlockKandangIDAndProductWarehouseID(ctx, projectFlockKandangID, sourceProductWarehouseID) + if err != nil { + return err + } + if len(populations) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk depletion adjustment") + } + + return fifoV2.AllocatePopulationConsumption( + ctx, + tx, + populations, + sourceProductWarehouseID, + fifo.UsableKeyAdjustmentOut.String(), + adjustmentID, + consumeQty, + ) +} + +func (s *adjustmentService) resyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error { + if tx == nil || projectFlockKandangID == 0 { + return nil + } + + idsSubquery := ` + SELECT pfp.id + FROM project_flock_populations pfp + JOIN project_chickins pc ON pc.id = pfp.project_chickin_id + WHERE pc.project_flock_kandang_id = ? + ` + + updateWithAlloc := ` + UPDATE project_flock_populations p + SET total_used_qty = COALESCE(a.used, 0) + FROM ( + SELECT stockable_id, SUM(qty) AS used + FROM stock_allocations + WHERE stockable_type = 'PROJECT_FLOCK_POPULATION' + AND status = 'ACTIVE' + AND allocation_purpose = 'CONSUME' + GROUP BY stockable_id + ) a + WHERE p.id = a.stockable_id + AND p.id IN (` + idsSubquery + `) + ` + + resetMissing := ` + UPDATE project_flock_populations p + SET total_used_qty = 0 + WHERE p.id IN (` + idsSubquery + `) + AND NOT EXISTS ( + SELECT 1 + FROM stock_allocations sa + WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION' + AND sa.status = 'ACTIVE' + AND sa.allocation_purpose = 'CONSUME' + AND sa.stockable_id = p.id + ) + ` + + db := tx.WithContext(ctx) + if err := db.Exec(updateWithAlloc, projectFlockKandangID).Error; err != nil { + return err + } + if err := db.Exec(resetMissing, projectFlockKandangID).Error; err != nil { + return err + } + return nil +} + func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error) { if err := s.Validate.Struct(query); err != nil { return nil, 0, err diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 11daaf41..a1bfeb17 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -26,11 +26,15 @@ import ( "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" + "github.com/jackc/pgconn" + pgconnv5 "github.com/jackc/pgx/v5/pgconn" "github.com/sirupsen/logrus" "gorm.io/gorm" "gorm.io/gorm/clause" ) +const chickinDeletePopulationGuardMessage = "Chickin tidak dapat dihapus karena sudah memiliki population. Lakukan rollback/penyesuaian population terlebih dahulu" + type ChickinService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectChickin, error) @@ -189,31 +193,31 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product warehouse %d belongs to different flock. Only product warehouses with project_flock_kandang_id = NULL or = %d can be used", chickinReq.ProductWarehouseId, req.ProjectFlockKandangId)) } - if productWarehouse.Product.Id != 0 { - category := strings.ToUpper(strings.TrimSpace(projectFlockKandang.ProjectFlock.Category)) - if category != string(utils.ProjectFlockCategoryGrowing) && category != string(utils.ProjectFlockCategoryLaying) { - return nil, fmt.Errorf("invalid flock category for chickin") - } + if productWarehouse.Product.Id != 0 { + category := strings.ToUpper(strings.TrimSpace(projectFlockKandang.ProjectFlock.Category)) + if category != string(utils.ProjectFlockCategoryGrowing) && category != string(utils.ProjectFlockCategoryLaying) { + return nil, fmt.Errorf("invalid flock category for chickin") + } - hasAyamFlag := false - for _, flag := range productWarehouse.Product.Flags { - if utils.CanonicalFlagType(flag.Name) == utils.FlagAyam { - hasAyamFlag = true - break - } - } - - if !hasAyamFlag { - return nil, fmt.Errorf( - "product warehouse %d cannot be used for %s chickin. Product must have AYAM flag (or legacy alias DOC/PULLET/LAYER) (product ID: %d, warehouse ID: %d)", - chickinReq.ProductWarehouseId, - projectFlockKandang.ProjectFlock.Category, - productWarehouse.Product.Id, - productWarehouse.Id, - ) + hasAyamFlag := false + for _, flag := range productWarehouse.Product.Flags { + if utils.CanonicalFlagType(flag.Name) == utils.FlagAyam { + hasAyamFlag = true + break } } + if !hasAyamFlag { + return nil, fmt.Errorf( + "product warehouse %d cannot be used for %s chickin. Product must have AYAM flag (or legacy alias DOC/PULLET/LAYER) (product ID: %d, warehouse ID: %d)", + chickinReq.ProductWarehouseId, + projectFlockKandang.ProjectFlock.Category, + productWarehouse.Product.Id, + productWarehouse.Id, + ) + } + } + chickinDate, err := utils.ParseDateString(chickinReq.ChickInDate) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid ChickInDate format for product warehouse %d", chickinReq.ProductWarehouseId)) @@ -421,6 +425,14 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { if err := s.ensureNotTransferred(c.Context(), chickin.ProjectFlockKandangId); err != nil { return err } + hasPopulation, err := s.ProjectflockPopulationRepo.ExistsByProjectChickinID(c.Context(), chickin.Id) + if err != nil { + s.Log.Errorf("Failed to check population by chickin %d: %+v", chickin.Id, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi population chickin") + } + if hasPopulation { + return fiber.NewError(fiber.StatusBadRequest, chickinDeletePopulationGuardMessage) + } actorID, err := m.ActorIDFromContext(c) if err != nil { @@ -429,17 +441,35 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { chickinRepoTx := repository.NewChickinRepository(tx) - if chickin.UsageQty > 0 || chickin.PendingUsageQty > 0 { if err := s.ReleaseChickinStocks(c.Context(), tx, chickin, actorID); err != nil { return err } } + now := time.Now().UTC() + note := "delete chickin rollback" + if err := tx.WithContext(c.Context()). + Model(&entity.StockAllocation{}). + Where("usable_type = ? AND usable_id = ? AND status = ?", + fifo.UsableKeyProjectChickin.String(), + chickin.Id, + entity.StockAllocationStatusActive, + ). + Updates(map[string]any{ + "status": entity.StockAllocationStatusReleased, + "released_at": now, + "note": note, + }).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal release alokasi FIFO chickin") + } if err := chickinRepoTx.DeleteOne(c.Context(), id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Chickin not found") } + if isForeignKeyViolation(err) { + return fiber.NewError(fiber.StatusBadRequest, chickinDeletePopulationGuardMessage) + } return err } @@ -459,6 +489,24 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { return nil } +func isForeignKeyViolation(err error) bool { + if err == nil { + return false + } + + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return pgErr.Code == "23503" + } + + var pgErrV5 *pgconnv5.PgError + if errors.As(err, &pgErrV5) { + return pgErrV5.Code == "23503" + } + + return false +} + func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.ProjectChickin, error) { if err := s.Validate.Struct(req); err != nil { return nil, err diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index ec2f3657..e71bc0c5 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -69,22 +69,24 @@ type RecordingWarehouseDTO struct { } type RecordingRelationDTO struct { - Id uint `json:"id"` - ProjectFlock RecordingProjectFlockDTO `json:"project_flock"` - RecordDatetime time.Time `json:"record_datetime"` - Day int `json:"day"` - TotalDepletionQty float64 `json:"total_depletion_qty"` - TotalDepletionCumQty float64 `json:"total_depletion_cum_qty"` - CumDepletionRate float64 `json:"cum_depletion_rate"` - DepletionRate float64 `json:"depletion_rate"` - CumIntake int `json:"cum_intake"` - FcrValue float64 `json:"fcr_value"` - HenDay float64 `json:"hen_day"` - HenHouse float64 `json:"hen_house"` - FeedIntake float64 `json:"feed_intake"` - EggMass float64 `json:"egg_mass"` - EggWeight float64 `json:"egg_weight"` - Approval approvalDTO.ApprovalRelationDTO `json:"approval"` + Id uint `json:"id"` + ProjectFlock RecordingProjectFlockDTO `json:"project_flock"` + RecordDatetime time.Time `json:"record_datetime"` + Day int `json:"day"` + TotalDepletionQty float64 `json:"total_depletion_qty"` + TotalDepletionCumQty float64 `json:"total_depletion_cum_qty"` + CumDepletionRate float64 `json:"cum_depletion_rate"` + DepletionRate float64 `json:"depletion_rate"` + CumIntake int `json:"cum_intake"` + FcrValue float64 `json:"fcr_value"` + HenDay float64 `json:"hen_day"` + HenHouse float64 `json:"hen_house"` + FeedIntake float64 `json:"feed_intake"` + EggMass float64 `json:"egg_mass"` + EggWeight float64 `json:"egg_weight"` + PopulationCanChange bool `json:"population_can_change"` + TransferExecuted *bool `json:"transfer_executed,omitempty"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` } type RecordingListDTO struct { @@ -228,22 +230,24 @@ func toRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { } return RecordingRelationDTO{ - Id: e.Id, - ProjectFlock: toRecordingProjectFlockDTO(e), - RecordDatetime: e.RecordDatetime, - Day: intValue(e.Day), - TotalDepletionQty: floatValue(e.TotalDepletionQty), + Id: e.Id, + ProjectFlock: toRecordingProjectFlockDTO(e), + RecordDatetime: e.RecordDatetime, + Day: intValue(e.Day), + TotalDepletionQty: floatValue(e.TotalDepletionQty), TotalDepletionCumQty: floatValue(e.TotalDepletionCumQty), - CumDepletionRate: roundFloatValue(e.CumDepletionRate, 2), - DepletionRate: roundFloatValue(e.DepletionRate, 2), - CumIntake: intValue(e.CumIntake), - FcrValue: floatValue(e.FcrValue), - HenDay: floatValue(e.HenDay), - HenHouse: floatValue(e.HenHouse), - FeedIntake: floatValue(e.FeedIntake), - EggMass: floatValue(e.EggMass), - EggWeight: floatValue(e.EggWeight), - Approval: latestApproval, + CumDepletionRate: roundFloatValue(e.CumDepletionRate, 2), + DepletionRate: roundFloatValue(e.DepletionRate, 2), + CumIntake: intValue(e.CumIntake), + FcrValue: floatValue(e.FcrValue), + HenDay: floatValue(e.HenDay), + HenHouse: floatValue(e.HenHouse), + FeedIntake: floatValue(e.FeedIntake), + EggMass: floatValue(e.EggMass), + EggWeight: floatValue(e.EggWeight), + PopulationCanChange: boolValueDefault(e.PopulationCanChange, true), + TransferExecuted: e.TransferExecuted, + Approval: latestApproval, } } @@ -449,6 +453,13 @@ func intValue(value *int) int { return *value } +func boolValueDefault(value *bool, fallback bool) bool { + if value == nil { + return fallback + } + return *value +} + func defaultRecordingLatestApproval(e entity.Recording) approvalDTO.ApprovalRelationDTO { result := approvalDTO.ApprovalRelationDTO{} diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go index 8a6ad158..5cdb6c1c 100644 --- a/internal/modules/production/recordings/module.go +++ b/internal/modules/production/recordings/module.go @@ -2,6 +2,7 @@ package recordings import ( "fmt" + "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -24,8 +25,10 @@ import ( rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories" + sTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/services" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -48,6 +51,8 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate chickinRepo := rChickin.NewChickinRepository(db) chickinDetailRepo := rChickin.NewChickinDetailRepository(db) transferLayingRepo := rTransferLaying.NewTransferLayingRepository(db) + layingTransferSourceRepo := rTransferLaying.NewLayingTransferSourceRepository(db) + layingTransferTargetRepo := rTransferLaying.NewLayingTransferTargetRepository(db) stockLogRepo := rStockLogs.NewStockLogRepository(db) productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db) productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) @@ -61,6 +66,42 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate ) fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) + stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) + fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) + + if err := fifoService.RegisterStockable(fifo.StockableConfig{ + Key: fifo.StockableKeyTransferToLayingIn, + Table: "laying_transfer_targets", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used", + CreatedAt: "created_at", + }, + OrderBy: []string{"created_at ASC", "id ASC"}, + }); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "already registered") { + panic(fmt.Sprintf("failed to register transfer to laying stockable workflow: %v", err)) + } + } + + if err := fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKeyTransferToLayingOut, + Table: "laying_transfers", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "source_product_warehouse_id", + UsageQuantity: "source_usage_qty", + PendingQuantity: "source_pending_usage_qty", + CreatedAt: "created_at", + }, + OrderBy: []string{"created_at ASC", "id ASC"}, + }); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "already registered") { + panic(fmt.Sprintf("failed to register transfer to laying usable workflow: %v", err)) + } + } approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) @@ -103,6 +144,21 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate fifoStockV2Service, ) + transferLayingService := sTransferLaying.NewTransferLayingService( + transferLayingRepo, + layingTransferSourceRepo, + layingTransferTargetRepo, + projectFlockRepo, + projectFlockKandangRepo, + projectFlockPopulationRepo, + productWarehouseRepo, + warehouseRepo, + approvalService, + fifoService, + fifoStockV2Service, + validate, + ) + recordingService := sRecording.NewRecordingService( recordingRepo, projectFlockKandangRepo, @@ -116,6 +172,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate projectFlockService, chickinService, transferLayingRepo, + transferLayingService, validate, ) userService := sUser.NewUserService(userRepo, validate) diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 4ed3c0a5..8d2cc4be 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -21,6 +21,7 @@ import ( repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories" + sTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/services" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" @@ -56,6 +57,7 @@ type recordingService struct { ProjectFlockSvc sProjectFlock.ProjectflockService ChickinSvc sChickin.ChickinService TransferLayingRepo rTransferLaying.TransferLayingRepository + TransferLayingSvc sTransferLaying.TransferLayingService FifoStockV2Svc commonSvc.FifoStockV2Service StockLogRepo rStockLogs.StockLogRepository } @@ -73,6 +75,7 @@ func NewRecordingService( projectFlockSvc sProjectFlock.ProjectflockService, chickinSvc sChickin.ChickinService, transferLayingRepo rTransferLaying.TransferLayingRepository, + transferLayingSvc sTransferLaying.TransferLayingService, validate *validator.Validate, ) RecordingService { return &recordingService{ @@ -88,6 +91,7 @@ func NewRecordingService( ProjectFlockSvc: projectFlockSvc, ChickinSvc: chickinSvc, TransferLayingRepo: transferLayingRepo, + TransferLayingSvc: transferLayingSvc, FifoStockV2Svc: fifoStockV2Svc, StockLogRepo: stockLogRepo, } @@ -180,6 +184,13 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti totalChick := totalChickMap[recordings[i].ProjectFlockKandangId] rate := recordingutil.ComputeDepletionRate(prev, current, totalChick) recordings[i].DepletionRate = &rate + + populationCanChange, transferExecuted, _, _, stateErr := s.evaluatePopulationMutationState(c.Context(), &recordings[i]) + if stateErr != nil { + return nil, 0, stateErr + } + recordings[i].PopulationCanChange = boolPtr(populationCanChange) + recordings[i].TransferExecuted = boolPtr(transferExecuted) } return recordings, total, nil } @@ -239,6 +250,14 @@ func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, erro rate := recordingutil.ComputeDepletionRate(prev, current, totalChick) recording.DepletionRate = &rate } + + populationCanChange, transferExecuted, _, _, stateErr := s.evaluatePopulationMutationState(c.Context(), recording) + if stateErr != nil { + return nil, stateErr + } + recording.PopulationCanChange = boolPtr(populationCanChange) + recording.TransferExecuted = boolPtr(transferExecuted) + return recording, nil } @@ -293,7 +312,12 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent category := strings.ToUpper(pfk.ProjectFlock.Category) isLaying := category == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) - if err := s.enforceTransferRecordingRoute(ctx, pfk, recordTime); err != nil { + if err := s.tryAutoExecuteTransferForRecordingCreate(c, pfk, recordTime); err != nil { + return nil, err + } + + routePayload := buildRecordingRoutePayloadFromCreate(req) + if err := s.enforceTransferRecordingRoute(ctx, pfk, recordTime, routePayload); err != nil { return nil, err } @@ -418,8 +442,8 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent if err := s.reflowApplyRecordingDepletionsIn(ctx, tx, mappedDepletions); err != nil { return err } - if err := s.Repository.ResyncProjectFlockPopulationUsage(ctx, tx, createdRecording.ProjectFlockKandangId); err != nil { - s.Log.Errorf("Failed to resync project flock population usage: %+v", err) + if err := s.resyncPopulationUsageForDepletions(ctx, tx, createdRecording.ProjectFlockKandangId, mappedDepletions); err != nil { + s.Log.Errorf("Failed to resync depletion source population usage: %+v", err) return err } @@ -494,6 +518,26 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } recordingEntity = recording + if err := s.ensurePopulationMutationAllowed(ctx, recordingEntity, "ubah"); err != nil { + return err + } + pfkForRoute := recordingEntity.ProjectFlockKandang + if pfkForRoute == nil || pfkForRoute.Id == 0 { + fetchedPfk, fetchErr := s.ProjectFlockKandangRepo.GetByIDLight(ctx, recordingEntity.ProjectFlockKandangId) + if fetchErr != nil { + if errors.Is(fetchErr, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang not found") + } + s.Log.Errorf("Failed to fetch project flock kandang for route validation: %+v", fetchErr) + return fetchErr + } + pfkForRoute = fetchedPfk + } + routePayload := buildRecordingRoutePayloadFromUpdate(req, recordingEntity) + if err := s.enforceTransferRecordingRoute(ctx, pfkForRoute, recordingEntity.RecordDatetime, routePayload); err != nil { + return err + } + hasStockChanges := req.Stocks != nil hasDepletionChanges := req.Depletions != nil hasEggChanges := req.Eggs != nil @@ -501,6 +545,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin var existingStocks []entity.RecordingStock var existingDepletions []entity.RecordingDepletion var existingEggs []entity.RecordingEgg + var mappedDepletions []entity.RecordingDepletion note := recordingutil.RecordingNote("Edit", recordingEntity.Id) @@ -545,6 +590,9 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if match { hasDepletionChanges = false } else { + if err := s.ensureDepletionMutationAllowed(ctx, recordingEntity, "ubah"); err != nil { + return err + } if err := s.ensureProductWarehousesExist(c, nil, req.Depletions, nil); err != nil { return err } @@ -564,7 +612,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return err } - mappedDepletions := recordingutil.MapDepletions(recordingEntity.Id, req.Depletions) + mappedDepletions = recordingutil.MapDepletions(recordingEntity.Id, req.Depletions) if len(mappedDepletions) > 0 { if err := s.ensureDepletionWithinPopulation(ctx, tx, recordingEntity.ProjectFlockKandangId, sumDepletionQty(mappedDepletions), sumDepletionQty(existingDepletions)); err != nil { return err @@ -655,8 +703,8 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return nil } - if err := s.Repository.ResyncProjectFlockPopulationUsage(ctx, tx, recordingEntity.ProjectFlockKandangId); err != nil { - s.Log.Errorf("Failed to resync project flock population usage: %+v", err) + if err := s.resyncPopulationUsageForDepletions(ctx, tx, recordingEntity.ProjectFlockKandangId, append(existingDepletions, mappedDepletions...)); err != nil { + s.Log.Errorf("Failed to resync depletion source population usage: %+v", err) return err } @@ -808,6 +856,11 @@ func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]ent if action == entity.ApprovalActionRejected { note := recordingutil.RecordingNote("Reject", id) + existingDepletions, err := s.Repository.ListDepletions(tx, id) + if err != nil { + s.Log.Errorf("Failed to list depletions before reject rollback %d: %+v", id, err) + return err + } if err := s.reflowRollbackRecordingInventory(ctx, tx, id, note, actorID); err != nil { return err } @@ -821,8 +874,8 @@ func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]ent s.Log.Errorf("Failed to recompute recording metrics after reject %d: %+v", id, err) return err } - if err := s.Repository.ResyncProjectFlockPopulationUsage(ctx, tx, recording.ProjectFlockKandangId); err != nil { - s.Log.Errorf("Failed to resync project flock population usage after reject %d: %+v", id, err) + if err := s.resyncPopulationUsageForDepletions(ctx, tx, recording.ProjectFlockKandangId, existingDepletions); err != nil { + s.Log.Errorf("Failed to resync depletion source population usage after reject %d: %+v", id, err) return err } if err := s.recalculateFrom(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil { @@ -878,6 +931,19 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { s.Log.Errorf("Failed to find recording: %+v", err) return err } + if err := s.ensurePopulationMutationAllowed(ctx, recording, "hapus"); err != nil { + return err + } + existingDepletions, err := s.Repository.ListDepletions(tx, recording.Id) + if err != nil { + s.Log.Errorf("Failed to list existing depletions: %+v", err) + return err + } + if len(existingDepletions) > 0 { + if err := s.ensureDepletionMutationAllowed(ctx, recording, "hapus"); err != nil { + return err + } + } if err := s.reflowRollbackRecordingInventory(ctx, tx, id, note, actorID); err != nil { return err @@ -891,8 +957,8 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { return err } - if err := s.Repository.ResyncProjectFlockPopulationUsage(ctx, tx, recording.ProjectFlockKandangId); err != nil { - s.Log.Errorf("Failed to resync project flock population usage: %+v", err) + if err := s.resyncPopulationUsageForDepletions(ctx, tx, recording.ProjectFlockKandangId, existingDepletions); err != nil { + s.Log.Errorf("Failed to resync depletion source population usage after delete: %+v", err) return err } @@ -905,10 +971,201 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { }) } +func (s *recordingService) resolveRecordingCategory(ctx context.Context, recording *entity.Recording) (string, error) { + if recording == nil || recording.ProjectFlockKandangId == 0 { + return "", nil + } + if recording.ProjectFlockKandang != nil && recording.ProjectFlockKandang.ProjectFlock.Id != 0 { + return strings.ToUpper(strings.TrimSpace(recording.ProjectFlockKandang.ProjectFlock.Category)), nil + } + + pfk, err := s.ProjectFlockKandangRepo.GetByIDLight(ctx, recording.ProjectFlockKandangId) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", nil + } + return "", err + } + + return strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category)), nil +} + +func (s *recordingService) evaluatePopulationMutationState(ctx context.Context, recording *entity.Recording) (bool, bool, *entity.LayingTransfer, time.Time, error) { + if recording == nil || recording.ProjectFlockKandangId == 0 || s.TransferLayingRepo == nil { + return true, false, nil, time.Time{}, nil + } + + category, err := s.resolveRecordingCategory(ctx, recording) + if err != nil { + s.Log.Errorf("Failed to resolve recording category for population mutation check (recording=%d): %+v", recording.Id, err) + return true, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording") + } + if category != strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)) { + return true, false, nil, time.Time{}, nil + } + + transfer, err := s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, recording.ProjectFlockKandangId) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return true, false, nil, time.Time{}, nil + } + s.Log.Errorf("Failed to resolve approved transfer by source kandang for recording %d: %+v", recording.Id, err) + return true, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording") + } + if transfer == nil { + return true, false, nil, time.Time{}, nil + } + + transferDate := transferPhysicalMoveDate(transfer) + if transferDate.IsZero() { + return true, false, transfer, transferDate, nil + } + + transferExecuted := transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero() + recordDate := normalizeDateOnlyUTC(recording.RecordDatetime) + populationCanChange := !(transferExecuted && !recordDate.Before(transferDate)) + + return populationCanChange, transferExecuted, transfer, transferDate, nil +} + +func (s *recordingService) ensurePopulationMutationAllowed(ctx context.Context, recording *entity.Recording, operation string) error { + populationCanChange, _, transfer, transferDate, err := s.evaluatePopulationMutationState(ctx, recording) + if err != nil { + return err + } + if populationCanChange { + return nil + } + + transferNumber := "-" + if transfer != nil && strings.TrimSpace(transfer.TransferNumber) != "" { + transferNumber = transfer.TransferNumber + } + recordDate := normalizeDateOnlyUTC(recording.RecordDatetime) + + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf( + "Recording growing tanggal %s tidak dapat di%s karena transfer laying %s sudah dieksekusi sejak %s. Perubahan populasi tidak diizinkan.", + recordDate.Format("2006-01-02"), + operation, + transferNumber, + transferDate.Format("2006-01-02"), + ), + ) +} + +func (s *recordingService) ensureDepletionMutationAllowed(ctx context.Context, recording *entity.Recording, operation string) error { + if recording == nil || recording.Id == 0 || recording.ProjectFlockKandangId == 0 || s.TransferLayingRepo == nil { + return nil + } + + category := "" + if recording.ProjectFlockKandang != nil && recording.ProjectFlockKandang.ProjectFlock.Id != 0 { + category = strings.ToUpper(strings.TrimSpace(recording.ProjectFlockKandang.ProjectFlock.Category)) + } else { + pfk, err := s.ProjectFlockKandangRepo.GetByIDLight(ctx, recording.ProjectFlockKandangId) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + s.Log.Errorf("Failed to load project flock kandang %d for depletion guard: %+v", recording.ProjectFlockKandangId, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan deplesi") + } + category = strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category)) + } + + var ( + transfer *entity.LayingTransfer + err error + ) + + switch category { + case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)): + transfer, err = s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, recording.ProjectFlockKandangId) + case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)): + transfer, err = s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx, recording.ProjectFlockKandangId) + default: + return nil + } + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + s.Log.Errorf("Failed to resolve transfer laying for depletion guard recording %d: %+v", recording.Id, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan deplesi") + } + if transfer == nil || transfer.ExecutedAt == nil || transfer.ExecutedAt.IsZero() { + return nil + } + + recordDate := normalizeDateOnlyUTC(recording.RecordDatetime) + physicalMoveDate := transferPhysicalMoveDate(transfer) + if physicalMoveDate.IsZero() || recordDate.Before(physicalMoveDate) { + return nil + } + + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf( + "Deplesi recording tanggal %s tidak dapat di%s karena sudah mempengaruhi transfer laying %s yang sudah dieksekusi. Lakukan unexecute transfer terlebih dahulu bila belum ada pemakaian downstream.", + recordDate.Format("2006-01-02"), + operation, + transfer.TransferNumber, + ), + ) +} + +func (s *recordingService) tryAutoExecuteTransferForRecordingCreate(c *fiber.Ctx, pfk *entity.ProjectFlockKandang, recordTime time.Time) error { + if pfk == nil || pfk.Id == 0 || s.TransferLayingRepo == nil || s.TransferLayingSvc == nil { + return nil + } + + ctx := c.Context() + recordDate := normalizeDateOnlyUTC(recordTime) + category := strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category)) + + var ( + transfer *entity.LayingTransfer + err error + ) + + switch category { + case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)): + transfer, err = s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx, pfk.Id) + case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)): + transfer, err = s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, pfk.Id) + default: + return nil + } + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + s.Log.Errorf("Failed to resolve approved transfer for recording create (pfk=%d): %+v", pfk.Id, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying") + } + if transfer == nil || (transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero()) { + return nil + } + + physicalMoveDate := transferPhysicalMoveDate(transfer) + if physicalMoveDate.IsZero() || recordDate.Before(physicalMoveDate) { + return nil + } + + if _, err := s.TransferLayingSvc.ExecuteWithBusinessDate(c, transfer.Id, recordDate); err != nil { + return err + } + + return nil +} + func (s *recordingService) enforceTransferRecordingRoute( ctx context.Context, pfk *entity.ProjectFlockKandang, recordTime time.Time, + payload recordingRoutePayload, ) error { if pfk == nil || pfk.Id == 0 || s.TransferLayingRepo == nil { return nil @@ -928,22 +1185,35 @@ func (s *recordingService) enforceTransferRecordingRoute( return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying") } - effectiveDate := effectiveTransferDate(transfer) - if effectiveDate.IsZero() { + physicalMoveDate, economicCutoffDate := transferRecordingWindow(transfer) + if physicalMoveDate.IsZero() { return nil } - if recordDate.Before(effectiveDate) { + if recordDate.Before(physicalMoveDate) { return fiber.NewError( fiber.StatusBadRequest, - fmt.Sprintf("Recording kandang laying hanya bisa dimulai pada %s. Sebelumnya gunakan kandang growing", effectiveDate.Format("2006-01-02")), + fmt.Sprintf("Recording kandang laying hanya bisa dimulai pada %s (tanggal pindah fisik). Sebelumnya gunakan kandang growing", physicalMoveDate.Format("2006-01-02")), ) } if transfer.ExecutedAt == nil || transfer.ExecutedAt.IsZero() { return fiber.NewError( fiber.StatusBadRequest, - fmt.Sprintf("Transfer laying %s sudah efektif pada %s tetapi belum dieksekusi. Eksekusi transfer terlebih dahulu", transfer.TransferNumber, effectiveDate.Format("2006-01-02")), + fmt.Sprintf("Transfer laying %s dengan tanggal pindah fisik %s belum dieksekusi. Eksekusi transfer terlebih dahulu", transfer.TransferNumber, physicalMoveDate.Format("2006-01-02")), + ) + } + + if recordDate.Before(economicCutoffDate) && payload.StockCount > 0 { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf( + "Periode transisi transfer laying %s (%s s.d. %s): input PAKAN/OVK harus dicatat di kandang growing hingga %s. Recording kandang laying pada periode ini hanya untuk deplesi (dan telur bila ada).", + transfer.TransferNumber, + physicalMoveDate.Format("2006-01-02"), + economicCutoffDate.AddDate(0, 0, -1).Format("2006-01-02"), + economicCutoffDate.AddDate(0, 0, -1).Format("2006-01-02"), + ), ) } @@ -957,22 +1227,38 @@ func (s *recordingService) enforceTransferRecordingRoute( return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying") } - if transfer != nil && transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero() { - return fiber.NewError( - fiber.StatusBadRequest, - "Project flock kandang sudah dipindahkan ke laying", - ) - } - - effectiveDate := effectiveTransferDate(transfer) - if effectiveDate.IsZero() { + physicalMoveDate, economicCutoffDate := transferRecordingWindow(transfer) + if physicalMoveDate.IsZero() { return nil } - if !recordDate.Before(effectiveDate) { + if recordDate.Before(physicalMoveDate) { + return nil + } + + if transfer.ExecutedAt == nil || transfer.ExecutedAt.IsZero() { return fiber.NewError( fiber.StatusBadRequest, - fmt.Sprintf("Recording kandang growing hanya diperbolehkan sampai %s. Gunakan kandang laying mulai %s", effectiveDate.AddDate(0, 0, -1).Format("2006-01-02"), effectiveDate.Format("2006-01-02")), + fmt.Sprintf("Transfer laying %s sudah memasuki tanggal pindah fisik %s namun belum dieksekusi. Eksekusi transfer lalu lakukan recording transisi sesuai aturan", transfer.TransferNumber, physicalMoveDate.Format("2006-01-02")), + ) + } + + if !recordDate.Before(economicCutoffDate) { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Recording kandang growing hanya diperbolehkan sampai %s. Gunakan kandang laying mulai %s", economicCutoffDate.AddDate(0, 0, -1).Format("2006-01-02"), economicCutoffDate.Format("2006-01-02")), + ) + } + + if payload.DepletionCount > 0 { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf( + "Periode transisi transfer laying %s (%s s.d. %s): deplesi harus dicatat di kandang laying tujuan agar mapping tidak ambigu. Kandang growing pada periode ini hanya untuk PAKAN/OVK.", + transfer.TransferNumber, + physicalMoveDate.Format("2006-01-02"), + economicCutoffDate.AddDate(0, 0, -1).Format("2006-01-02"), + ), ) } } @@ -980,23 +1266,138 @@ func (s *recordingService) enforceTransferRecordingRoute( return nil } -func effectiveTransferDate(transfer *entity.LayingTransfer) time.Time { +type recordingRoutePayload struct { + StockCount int + DepletionCount int + EggCount int +} + +func buildRecordingRoutePayloadFromCreate(req *validation.Create) recordingRoutePayload { + payload := recordingRoutePayload{} + if req == nil { + return payload + } + for _, stock := range req.Stocks { + if stock.Qty > 0 { + payload.StockCount++ + } + } + for _, depletion := range req.Depletions { + if depletion.Qty > 0 { + payload.DepletionCount++ + } + } + for _, egg := range req.Eggs { + if egg.Qty > 0 { + payload.EggCount++ + } + } + return payload +} + +func buildRecordingRoutePayloadFromUpdate(req *validation.Update, existing *entity.Recording) recordingRoutePayload { + payload := recordingRoutePayload{} + if req == nil && existing == nil { + return payload + } + + if req != nil && req.Stocks != nil { + for _, stock := range req.Stocks { + if stock.Qty > 0 { + payload.StockCount++ + } + } + } else if existing != nil { + for _, stock := range existing.Stocks { + usageQty := 0.0 + if stock.UsageQty != nil { + usageQty = *stock.UsageQty + } + pendingQty := 0.0 + if stock.PendingQty != nil { + pendingQty = *stock.PendingQty + } + if usageQty > 0 || pendingQty > 0 { + payload.StockCount++ + } + } + } + + if req != nil && req.Depletions != nil { + for _, depletion := range req.Depletions { + if depletion.Qty > 0 { + payload.DepletionCount++ + } + } + } else if existing != nil { + for _, depletion := range existing.Depletions { + if depletion.Qty > 0 { + payload.DepletionCount++ + } + } + } + + if req != nil && req.Eggs != nil { + for _, egg := range req.Eggs { + if egg.Qty > 0 { + payload.EggCount++ + } + } + } else if existing != nil { + for _, egg := range existing.Eggs { + if egg.Qty > 0 { + payload.EggCount++ + } + } + } + + return payload +} + +func transferPhysicalMoveDate(transfer *entity.LayingTransfer) time.Time { if transfer == nil { return time.Time{} } - if transfer.EffectiveMoveDate != nil && !transfer.EffectiveMoveDate.IsZero() { - return normalizeDateOnlyUTC(*transfer.EffectiveMoveDate) - } if !transfer.TransferDate.IsZero() { return normalizeDateOnlyUTC(transfer.TransferDate) } return time.Time{} } +func transferEconomicCutoffDate(transfer *entity.LayingTransfer) time.Time { + if transfer == nil { + return time.Time{} + } + if transfer.EconomicCutoffDate != nil && !transfer.EconomicCutoffDate.IsZero() { + return normalizeDateOnlyUTC(*transfer.EconomicCutoffDate) + } + if transfer.EffectiveMoveDate != nil && !transfer.EffectiveMoveDate.IsZero() { + return normalizeDateOnlyUTC(*transfer.EffectiveMoveDate) + } + return transferPhysicalMoveDate(transfer) +} + +func transferRecordingWindow(transfer *entity.LayingTransfer) (time.Time, time.Time) { + physicalMoveDate := transferPhysicalMoveDate(transfer) + economicCutoffDate := transferEconomicCutoffDate(transfer) + if economicCutoffDate.IsZero() { + economicCutoffDate = physicalMoveDate + } + if !physicalMoveDate.IsZero() && economicCutoffDate.Before(physicalMoveDate) { + economicCutoffDate = physicalMoveDate + } + return physicalMoveDate, economicCutoffDate +} + func normalizeDateOnlyUTC(value time.Time) time.Time { return time.Date(value.UTC().Year(), value.UTC().Month(), value.UTC().Day(), 0, 0, 0, 0, time.UTC) } +func boolPtr(value bool) *bool { + v := value + return &v +} + func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []validation.Stock, depletions []validation.Depletion, eggs []validation.Egg) error { idSet := make(map[uint]struct{}) @@ -2184,6 +2585,119 @@ func sumDepletionQty(items []entity.RecordingDepletion) float64 { return total } +func (s *recordingService) resyncPopulationUsageForDepletions( + ctx context.Context, + tx *gorm.DB, + recordingProjectFlockKandangID uint, + depletions []entity.RecordingDepletion, +) error { + kandangIDs := map[uint]struct{}{} + if recordingProjectFlockKandangID != 0 { + kandangIDs[recordingProjectFlockKandangID] = struct{}{} + } + + sourceWarehouseIDs := make([]uint, 0) + sourceWarehouseSeen := map[uint]struct{}{} + for _, dep := range depletions { + if dep.SourceProductWarehouseId == nil || *dep.SourceProductWarehouseId == 0 { + continue + } + pwID := *dep.SourceProductWarehouseId + if _, exists := sourceWarehouseSeen[pwID]; exists { + continue + } + sourceWarehouseSeen[pwID] = struct{}{} + sourceWarehouseIDs = append(sourceWarehouseIDs, pwID) + } + + if len(sourceWarehouseIDs) > 0 { + db := s.Repository.DB().WithContext(ctx) + if tx != nil { + db = tx.WithContext(ctx) + } + + var sourceKandangIDs []uint + if err := db.Table("project_flock_populations pfp"). + Select("DISTINCT pc.project_flock_kandang_id"). + Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). + Where("pfp.product_warehouse_id IN ?", sourceWarehouseIDs). + Where("pfp.deleted_at IS NULL"). + Where("pc.deleted_at IS NULL"). + Pluck("pc.project_flock_kandang_id", &sourceKandangIDs).Error; err != nil { + return err + } + + for _, kandangID := range sourceKandangIDs { + if kandangID != 0 { + kandangIDs[kandangID] = struct{}{} + } + } + } + + for kandangID := range kandangIDs { + if err := s.resyncPopulationUsageByProjectFlockKandang(ctx, tx, kandangID); err != nil { + return err + } + } + + return nil +} + +func (s *recordingService) resyncPopulationUsageByProjectFlockKandang(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error { + if projectFlockKandangID == 0 { + return nil + } + + db := s.Repository.DB().WithContext(ctx) + if tx != nil { + db = tx.WithContext(ctx) + } + + var populationIDs []uint + if err := db.Table("project_flock_populations pfp"). + Select("pfp.id"). + Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). + Where("pc.project_flock_kandang_id = ?", projectFlockKandangID). + Pluck("pfp.id", &populationIDs).Error; err != nil { + return err + } + if len(populationIDs) == 0 { + return nil + } + + type usageRow struct { + StockableID uint `gorm:"column:stockable_id"` + Used float64 `gorm:"column:used"` + } + var usageRows []usageRow + if err := db.Table("stock_allocations"). + Select("stockable_id, COALESCE(SUM(qty), 0) AS used"). + Where("stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()). + Where("status = ?", entity.StockAllocationStatusActive). + Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). + Where("stockable_id IN ?", populationIDs). + Group("stockable_id"). + Scan(&usageRows).Error; err != nil { + return err + } + + if err := db.Model(&entity.ProjectFlockPopulation{}). + Where("id IN ?", populationIDs). + Update("total_used_qty", 0).Error; err != nil { + return err + } + + for _, row := range usageRows { + if err := db.Model(&entity.ProjectFlockPopulation{}). + Where("id = ?", row.StockableID). + Update("total_used_qty", row.Used).Error; err != nil { + return err + } + } + + return nil +} + func (s *recordingService) ensureDepletionWithinPopulation(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, newTotal float64, existingTotal float64) error { if projectFlockKandangId == 0 || newTotal <= 0 { return nil diff --git a/internal/modules/production/recordings/services/recording_route_helper_test.go b/internal/modules/production/recordings/services/recording_route_helper_test.go new file mode 100644 index 00000000..155bd93e --- /dev/null +++ b/internal/modules/production/recordings/services/recording_route_helper_test.go @@ -0,0 +1,87 @@ +package service + +import ( + "testing" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +func mustDate(t *testing.T, value string) time.Time { + t.Helper() + parsed, err := time.Parse("2006-01-02", value) + if err != nil { + t.Fatalf("failed parsing date %s: %v", value, err) + } + return parsed +} + +func TestTransferRecordingWindow(t *testing.T) { + t.Run("early transfer keeps transition until economic cutoff", func(t *testing.T) { + physical := mustDate(t, "2026-04-08") + cutoff := mustDate(t, "2026-05-13") + transfer := &entity.LayingTransfer{ + TransferDate: physical, + EconomicCutoffDate: &cutoff, + } + + gotPhysical, gotCutoff := transferRecordingWindow(transfer) + if gotPhysical.Format("2006-01-02") != "2026-04-08" { + t.Fatalf("unexpected physical date: %s", gotPhysical.Format("2006-01-02")) + } + if gotCutoff.Format("2006-01-02") != "2026-05-13" { + t.Fatalf("unexpected cutoff date: %s", gotCutoff.Format("2006-01-02")) + } + }) + + t.Run("standard transfer has no transition window", func(t *testing.T) { + physical := mustDate(t, "2026-05-13") + cutoff := mustDate(t, "2026-05-13") + transfer := &entity.LayingTransfer{ + TransferDate: physical, + EconomicCutoffDate: &cutoff, + } + + gotPhysical, gotCutoff := transferRecordingWindow(transfer) + if gotPhysical.Format("2006-01-02") != "2026-05-13" { + t.Fatalf("unexpected physical date: %s", gotPhysical.Format("2006-01-02")) + } + if gotCutoff.Format("2006-01-02") != "2026-05-13" { + t.Fatalf("unexpected cutoff date: %s", gotCutoff.Format("2006-01-02")) + } + }) + + t.Run("late transfer clamps economic cutoff to physical move", func(t *testing.T) { + physical := mustDate(t, "2026-06-03") + cutoff := mustDate(t, "2026-05-13") + transfer := &entity.LayingTransfer{ + TransferDate: physical, + EconomicCutoffDate: &cutoff, + } + + gotPhysical, gotCutoff := transferRecordingWindow(transfer) + if gotPhysical.Format("2006-01-02") != "2026-06-03" { + t.Fatalf("unexpected physical date: %s", gotPhysical.Format("2006-01-02")) + } + if gotCutoff.Format("2006-01-02") != "2026-06-03" { + t.Fatalf("unexpected cutoff date: %s", gotCutoff.Format("2006-01-02")) + } + }) + + t.Run("legacy data falls back to effective move date", func(t *testing.T) { + physical := mustDate(t, "2026-04-08") + legacyEffective := mustDate(t, "2026-05-13") + transfer := &entity.LayingTransfer{ + TransferDate: physical, + EffectiveMoveDate: &legacyEffective, + } + + gotPhysical, gotCutoff := transferRecordingWindow(transfer) + if gotPhysical.Format("2006-01-02") != "2026-04-08" { + t.Fatalf("unexpected physical date: %s", gotPhysical.Format("2006-01-02")) + } + if gotCutoff.Format("2006-01-02") != "2026-05-13" { + t.Fatalf("unexpected cutoff date: %s", gotCutoff.Format("2006-01-02")) + } + }) +} diff --git a/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go b/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go index 7b4b76ff..4059b9dc 100644 --- a/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go +++ b/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go @@ -208,6 +208,28 @@ func (u *TransferLayingController) Execute(c *fiber.Ctx) error { }) } +func (u *TransferLayingController) Unexecute(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.TransferLayingService.Unexecute(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Unexecute transfer laying successfully", + Data: dto.ToTransferLayingDetailDTOWithSingleApproval(*result, result.LatestApproval), + }) +} + func (u *TransferLayingController) GetAvailableQtyPerKandang(c *fiber.Ctx) error { projectFlockID, err := strconv.ParseUint(c.Params("project_flock_id"), 10, 32) if err != nil { diff --git a/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go b/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go index a23cc7df..0bc65d0c 100644 --- a/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go +++ b/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go @@ -14,12 +14,13 @@ import ( // === DTO Structs === type TransferLayingRelationDTO struct { - Id uint `json:"id"` - TransferNumber string `json:"transfer_number"` - TransferDate time.Time `json:"transfer_date"` - EffectiveMoveDate *time.Time `json:"effective_move_date,omitempty"` - ExecutedAt *time.Time `json:"executed_at,omitempty"` - Notes string `json:"notes"` + Id uint `json:"id"` + TransferNumber string `json:"transfer_number"` + TransferDate time.Time `json:"transfer_date"` + EconomicCutoffDate *time.Time `json:"economic_cutoff_date,omitempty"` + EffectiveMoveDate *time.Time `json:"effective_move_date,omitempty"` + ExecutedAt *time.Time `json:"executed_at,omitempty"` + Notes string `json:"notes"` } type ProjectFlockKandangWithKandangDTO struct { @@ -92,12 +93,13 @@ type MaxTargetQtyForTransferDTO struct { func ToTransferLayingRelationDTO(e entity.LayingTransfer) TransferLayingRelationDTO { return TransferLayingRelationDTO{ - Id: e.Id, - TransferNumber: e.TransferNumber, - TransferDate: e.TransferDate, - EffectiveMoveDate: e.EffectiveMoveDate, - ExecutedAt: e.ExecutedAt, - Notes: e.Notes, + Id: e.Id, + TransferNumber: e.TransferNumber, + TransferDate: e.TransferDate, + EconomicCutoffDate: e.EconomicCutoffDate, + EffectiveMoveDate: e.EffectiveMoveDate, + ExecutedAt: e.ExecutedAt, + Notes: e.Notes, } } @@ -150,6 +152,46 @@ func ToLayingTransferSourceDTOs(sources []entity.LayingTransferSource) []LayingT return result } +func toLayingTransferSourceDTOsFromTransfer(e entity.LayingTransfer) []LayingTransferSourceDTO { + if len(e.Sources) > 0 { + return ToLayingTransferSourceDTOs(e.Sources) + } + if e.SourceProjectFlockKandangId == nil || *e.SourceProjectFlockKandangId == 0 { + return []LayingTransferSourceDTO{} + } + + displayQty := e.SourceRequestedQty + if e.SourceUsageQty > 0 { + displayQty = e.SourceUsageQty + } + + pfkDTO := &ProjectFlockKandangWithKandangDTO{ + Id: *e.SourceProjectFlockKandangId, + } + if e.SourceProjectFlockKandang != nil && e.SourceProjectFlockKandang.Id != 0 { + pfkDTO.KandangId = e.SourceProjectFlockKandang.KandangId + pfkDTO.ProjectFlockId = e.SourceProjectFlockKandang.ProjectFlockId + if e.SourceProjectFlockKandang.Kandang.Id != 0 { + kandangMapped := kandangDTO.ToKandangRelationDTO(e.SourceProjectFlockKandang.Kandang) + pfkDTO.Kandang = &kandangMapped + } + } + + var pwDTO *productWarehouseDTO.ProductWarehouseRelationDTO + if e.SourceProductWarehouse != nil && e.SourceProductWarehouse.Id != 0 { + mapped := productWarehouseDTO.ToProductWarehouseRelationDTO(*e.SourceProductWarehouse) + pwDTO = &mapped + } + + return []LayingTransferSourceDTO{ + { + SourceProjectFlockKandang: pfkDTO, + Qty: displayQty, + ProductWarehouse: pwDTO, + }, + } +} + func ToLayingTransferTargetDTO(target entity.LayingTransferTarget) LayingTransferTargetDTO { var pfkDTO *ProjectFlockKandangWithKandangDTO if target.TargetProjectFlockKandang != nil && target.TargetProjectFlockKandang.Id != 0 { @@ -254,7 +296,7 @@ func ToTransferLayingDetailDTO(e entity.LayingTransfer, approvals []entity.Appro return TransferLayingDetailDTO{ TransferLayingListDTO: ToTransferLayingListDTO(e), - Sources: ToLayingTransferSourceDTOs(e.Sources), + Sources: toLayingTransferSourceDTOsFromTransfer(e), Targets: ToLayingTransferTargetDTOs(e.Targets), Approval: latestApproval, } @@ -276,7 +318,7 @@ func ToTransferLayingDetailDTOWithSingleApproval(e entity.LayingTransfer, approv return TransferLayingDetailDTO{ TransferLayingListDTO: ToTransferLayingListDTO(e), - Sources: ToLayingTransferSourceDTOs(e.Sources), + Sources: toLayingTransferSourceDTOsFromTransfer(e), Targets: ToLayingTransferTargetDTOs(e.Targets), Approval: mappedApproval, } diff --git a/internal/modules/production/transfer_layings/module.go b/internal/modules/production/transfer_layings/module.go index a8044f79..d7dbaa50 100644 --- a/internal/modules/production/transfer_layings/module.go +++ b/internal/modules/production/transfer_layings/module.go @@ -10,11 +10,11 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories" sTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/services" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" rInventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" @@ -60,12 +60,12 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val // daftarin jadi usable if err := fifoService.RegisterUsable(fifo.UsableConfig{ Key: fifo.UsableKeyTransferToLayingOut, - Table: "laying_transfer_sources", + Table: "laying_transfers", Columns: fifo.UsableColumns{ ID: "id", - ProductWarehouseID: "product_warehouse_id", - UsageQuantity: "usage_qty", - PendingQuantity: "pending_usage_qty", + ProductWarehouseID: "source_product_warehouse_id", + UsageQuantity: "source_usage_qty", + PendingQuantity: "source_pending_usage_qty", CreatedAt: "created_at", }, OrderBy: []string{"created_at ASC", "id ASC"}, diff --git a/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go b/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go index 1d28d9c9..8c21f176 100644 --- a/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go +++ b/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go @@ -166,6 +166,9 @@ func (r *TransferLayingRepositoryImpl) GetAllWithFilters(ctx context.Context, of q = q.Offset(offset).Limit(limit). Preload("FromProjectFlock"). Preload("ToProjectFlock"). + Preload("SourceProjectFlockKandang"). + Preload("SourceProjectFlockKandang.Kandang"). + Preload("SourceProductWarehouse"). Preload("CreatedUser"). Preload("ExecutedUser"). Preload("Sources"). @@ -193,11 +196,12 @@ func (r *TransferLayingRepositoryImpl) GetLatestApprovedBySourceKandang(ctx cont var transfer entity.LayingTransfer err := r.db.WithContext(ctx). Model(&entity.LayingTransfer{}). - Joins("JOIN laying_transfer_sources lts ON lts.laying_transfer_id = laying_transfers.id AND lts.deleted_at IS NULL"). - Where("lts.source_project_flock_kandang_id = ?", sourceProjectFlockKandangID). + Distinct("laying_transfers.*"). + Joins("LEFT JOIN laying_transfer_sources lts ON lts.laying_transfer_id = laying_transfers.id AND lts.deleted_at IS NULL"). + Where("(laying_transfers.source_project_flock_kandang_id = ? OR lts.source_project_flock_kandang_id = ?)", sourceProjectFlockKandangID, sourceProjectFlockKandangID). Where("laying_transfers.deleted_at IS NULL"). Where(`( - SELECT a.action + SELECT a.action FROM approvals a WHERE a.approvable_type = ? AND a.approvable_id = laying_transfers.id diff --git a/internal/modules/production/transfer_layings/route.go b/internal/modules/production/transfer_layings/route.go index edd1877c..28d72200 100644 --- a/internal/modules/production/transfer_layings/route.go +++ b/internal/modules/production/transfer_layings/route.go @@ -28,6 +28,7 @@ func TransferLayingRoutes(v1 fiber.Router, u user.UserService, s transferLaying. route.Delete("/:id", m.RequirePermissions(m.P_TransferToLaying_DeleteOne), ctrl.DeleteOne) route.Post("/approvals", m.RequirePermissions(m.P_TransferToLaying_Approval), ctrl.Approval) route.Post("/:id/execute", m.RequirePermissions(m.P_TransferToLaying_Approval), ctrl.Execute) + route.Post("/:id/unexecute", m.RequirePermissions(m.P_TransferToLaying_Approval), ctrl.Unexecute) route.Get("/project-flocks/:project_flock_id/available-qty", m.RequirePermissions(m.P_TransferToLaying_GetAvailableQty), ctrl.GetAvailableQtyPerKandang) route.Get("/project-flocks/:project_flock_id/max-target-qty", m.RequirePermissions(m.P_TransferToLaying_CreateOne), ctrl.GetMaxTargetQtyPerKandang) } diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index 9a1bf993..5a7ef3c8 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "math" "strings" "time" @@ -27,6 +26,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type TransferLayingService interface { @@ -37,6 +37,8 @@ type TransferLayingService interface { DeleteOne(ctx *fiber.Ctx, id uint) error Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.LayingTransfer, error) Execute(ctx *fiber.Ctx, id uint) (*entity.LayingTransfer, error) + ExecuteWithBusinessDate(ctx *fiber.Ctx, id uint, businessDate time.Time) (*entity.LayingTransfer, error) + Unexecute(ctx *fiber.Ctx, id uint) (*entity.LayingTransfer, error) GetAvailableQtyPerKandang(ctx *fiber.Ctx, projectFlockID uint) (*entity.ProjectFlock, map[uint]float64, error) GetMaxTargetQtyPerKandang(ctx *fiber.Ctx, projectFlockID uint) (map[uint]float64, error) } @@ -100,6 +102,11 @@ func (s transferLayingService) withRelations(db *gorm.DB) *gorm.DB { Preload("ExecutedUser"). Preload("FromProjectFlock"). Preload("ToProjectFlock"). + Preload("SourceProjectFlockKandang"). + Preload("SourceProjectFlockKandang.Kandang"). + Preload("SourceProductWarehouse"). + Preload("SourceProductWarehouse.Product"). + Preload("SourceProductWarehouse.Warehouse"). Preload("Sources"). Preload("Sources.SourceProjectFlockKandang"). Preload("Sources.SourceProjectFlockKandang.Kandang"). @@ -200,15 +207,15 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) return nil, err } - sourceKandangIDs := make([]uint, len(req.SourceKandangs)) - for i, detail := range req.SourceKandangs { - sourceKandangIDs[i] = detail.ProjectFlockKandangId + if len(req.SourceKandangs) != 1 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Transfer to laying baru hanya mendukung 1 kandang sumber per transaksi") } + sourceDetail := req.SourceKandangs[0] if err := s.validateKandangOwnership( c.Context(), req.SourceProjectFlockId, - sourceKandangIDs, + []uint{sourceDetail.ProjectFlockKandangId}, ); err != nil { return nil, err } @@ -231,56 +238,45 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) return nil, fiber.NewError(fiber.StatusBadRequest, "Format tanggal transfer tidak valid") } - var totalSourceQty, totalTargetQty float64 - sourceWarehouseMap := make(map[uint]uint) - - for _, sourceDetail := range req.SourceKandangs { - if sourceDetail.Quantity <= 0 { - continue - } - totalSourceQty += sourceDetail.Quantity - - populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(c.Context(), sourceDetail.ProjectFlockKandangId) - if err != nil { - return nil, err - } - - var totalPopulation float64 - var productWarehouseId uint - for _, pop := range populations { - totalPopulation += pop.TotalQty - if productWarehouseId == 0 { - productWarehouseId = pop.ProductWarehouseId - } - } - - if totalPopulation == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang sumber %d tidak memiliki populasi untuk ditransfer", sourceDetail.ProjectFlockKandangId)) - } - - if totalPopulation < sourceDetail.Quantity { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang sumber %d jumlah tidak mencukupi. Tersedia: %.0f, Diminta: %.0f", sourceDetail.ProjectFlockKandangId, totalPopulation, sourceDetail.Quantity)) - } - - sourceWarehouseMap[sourceDetail.ProjectFlockKandangId] = productWarehouseId - } - + var totalTargetQty float64 for _, targetDetail := range req.TargetKandangs { + if targetDetail.Quantity < 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Jumlah kandang tujuan tidak boleh negatif") + } if targetDetail.Quantity <= 0 { continue } totalTargetQty += targetDetail.Quantity } - if totalSourceQty == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Minimal harus ada 1 kandang sumber dengan jumlah lebih dari 0") - } if totalTargetQty == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Minimal harus ada 1 kandang tujuan dengan jumlah lebih dari 0") } - if totalSourceQty != totalTargetQty { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Jumlah total sumber (%.0f) harus sama dengan jumlah total tujuan (%.0f)", totalSourceQty, totalTargetQty)) + sourceRequestedQty := totalTargetQty + + populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(c.Context(), sourceDetail.ProjectFlockKandangId) + if err != nil { + return nil, err + } + + var totalPopulation float64 + var sourceProductWarehouseID uint + for _, pop := range populations { + totalPopulation += pop.TotalQty + if sourceProductWarehouseID == 0 { + sourceProductWarehouseID = pop.ProductWarehouseId + } + } + + if totalPopulation == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang sumber %d tidak memiliki populasi untuk ditransfer", sourceDetail.ProjectFlockKandangId)) + } + if totalPopulation < sourceRequestedQty { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang sumber %d jumlah tidak mencukupi. Tersedia: %.0f, Diminta: %.0f", sourceDetail.ProjectFlockKandangId, totalPopulation, sourceRequestedQty)) + } + if sourceProductWarehouseID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang sumber %d tidak memiliki warehouse produk ayam", sourceDetail.ProjectFlockKandangId)) } transferNumber, err := s.Repository.GenerateMovementNumber(c.Context()) @@ -290,18 +286,22 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) } createBody := &entity.LayingTransfer{ - TransferNumber: transferNumber, - Notes: req.Reason, - FromProjectFlockId: req.SourceProjectFlockId, - ToProjectFlockId: req.TargetProjectFlockId, - TransferDate: transferDate, - CreatedBy: actorID, + TransferNumber: transferNumber, + Notes: req.Reason, + FromProjectFlockId: req.SourceProjectFlockId, + ToProjectFlockId: req.TargetProjectFlockId, + SourceProjectFlockKandangId: &sourceDetail.ProjectFlockKandangId, + SourceProductWarehouseId: &sourceProductWarehouseID, + SourceRequestedQty: sourceRequestedQty, + SourceUsageQty: 0, + SourcePendingUsageQty: 0, + TransferDate: transferDate, + CreatedBy: actorID, } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { repoTx := s.Repository.WithTx(dbTransaction) - sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction) targetRepoTx := repository.NewLayingTransferTargetRepository(dbTransaction) pwRepoTx := rInventory.NewProductWarehouseRepository(dbTransaction) @@ -309,29 +309,13 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat record transfer laying") } - for _, sourceDetail := range req.SourceKandangs { - if sourceDetail.Quantity == 0 { - continue - } - - productWarehouseId := sourceWarehouseMap[sourceDetail.ProjectFlockKandangId] - - source := entity.LayingTransferSource{ - LayingTransferId: createBody.Id, - SourceProjectFlockKandangId: sourceDetail.ProjectFlockKandangId, - RequestedQty: sourceDetail.Quantity, // Quantity yang diminta user - UsageQty: 0, - PendingUsageQty: 0, // Di-set 0, biarkan FIFO Consume yang handle saat Approval - ProductWarehouseId: &productWarehouseId, - } - if err := sourceRepoTx.CreateOne(c.Context(), &source, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat sumber transfer") - } - + sourcePW, err := pwRepoTx.GetByID(c.Context(), sourceProductWarehouseID, nil) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mendapatkan warehouse produk kandang sumber") } for _, targetDetail := range req.TargetKandangs { - if targetDetail.Quantity == 0 { + if targetDetail.Quantity <= 0 { continue } @@ -348,29 +332,12 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) return fiber.NewError(fiber.StatusInternalServerError, "Gagal mendapatkan warehouse tujuan") } - // Ambil product ID dari salah satu source warehouse (harusnya semua sources product-nya sama) - var sourceProductID uint - for _, sourceDetail := range req.SourceKandangs { - if pwID, ok := sourceWarehouseMap[sourceDetail.ProjectFlockKandangId]; ok { - // Get product warehouse untuk ambil product ID - sourcePW, err := pwRepoTx.GetByID(c.Context(), pwID, nil) - if err == nil { - sourceProductID = sourcePW.ProductId - break - } - } - } - - if sourceProductID == 0 { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal mendapatkan product dari source warehouse") - } - // Cari product warehouse di target berdasarkan: warehouse + project_flock_kandang + PRODUCT - targetPW, err := pwRepoTx.FindByProductWarehouseAndPfk(c.Context(), sourceProductID, targetWarehouse.Id, &targetDetail.ProjectFlockKandangId) + targetPW, err := pwRepoTx.FindByProductWarehouseAndPfk(c.Context(), sourcePW.ProductId, targetWarehouse.Id, &targetDetail.ProjectFlockKandangId) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { newTargetPW := entity.ProductWarehouse{ - ProductId: sourceProductID, + ProductId: sourcePW.ProductId, WarehouseId: targetWarehouse.Id, ProjectFlockKandangId: &targetDetail.ProjectFlockKandangId, Quantity: 0, @@ -434,6 +401,9 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer laying") } + if isLegacyTransfer(existingTransfer) { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Transfer laying legacy %s tidak dapat diubah. Buat transfer baru dengan 1 kandang sumber", existingTransfer.TransferNumber)) + } approvalRepo := commonRepo.NewApprovalRepository(s.Repository.DB()) latestApproval, err := approvalRepo.LatestByTarget(c.Context(), string(utils.ApprovalWorkflowTransferToLaying), id, nil) @@ -456,15 +426,15 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, return nil, fiber.NewError(fiber.StatusBadRequest, "Target project flock not found") } - sourceKandangIDs := make([]uint, len(req.SourceKandangs)) - for i, detail := range req.SourceKandangs { - sourceKandangIDs[i] = detail.ProjectFlockKandangId + if len(req.SourceKandangs) != 1 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Transfer to laying baru hanya mendukung 1 kandang sumber per transaksi") } + sourceDetail := req.SourceKandangs[0] if err := s.validateKandangOwnership( c.Context(), req.SourceProjectFlockId, - sourceKandangIDs, + []uint{sourceDetail.ProjectFlockKandangId}, ); err != nil { return nil, err } @@ -487,68 +457,45 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transfer date format") } - var totalSourceQty, totalTargetQty float64 - sourceWarehouseMap := make(map[uint]uint) - - for _, sourceDetail := range req.SourceKandangs { - if sourceDetail.Quantity <= 0 { - continue - } - totalSourceQty += sourceDetail.Quantity - - populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(c.Context(), sourceDetail.ProjectFlockKandangId) - if err != nil { - return nil, err - } - - var totalPopulation float64 - var productWarehouseId uint - for _, pop := range populations { - totalPopulation += pop.TotalQty - if productWarehouseId == 0 { - productWarehouseId = pop.ProductWarehouseId - } - } - - if totalPopulation == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang sumber %d tidak memiliki populasi untuk ditransfer", sourceDetail.ProjectFlockKandangId)) - } - - if totalPopulation < sourceDetail.Quantity { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang sumber %d jumlah tidak mencukupi. Tersedia: %.0f, Diminta: %.0f", sourceDetail.ProjectFlockKandangId, totalPopulation, sourceDetail.Quantity)) - } - - sourceWarehouseMap[sourceDetail.ProjectFlockKandangId] = productWarehouseId - } - + var totalTargetQty float64 for _, targetDetail := range req.TargetKandangs { + if targetDetail.Quantity < 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Jumlah kandang tujuan tidak boleh negatif") + } if targetDetail.Quantity <= 0 { continue } totalTargetQty += targetDetail.Quantity } - if totalSourceQty == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Minimal harus ada 1 kandang sumber dengan jumlah lebih dari 0") - } if totalTargetQty == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Minimal harus ada 1 kandang tujuan dengan jumlah lebih dari 0") } - if totalSourceQty != totalTargetQty { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Jumlah total sumber (%.0f) harus sama dengan jumlah total tujuan (%.0f)", totalSourceQty, totalTargetQty)) + sourceRequestedQty := totalTargetQty + + populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(c.Context(), sourceDetail.ProjectFlockKandangId) + if err != nil { + return nil, err } - // Ambil productWarehouseId pertama dari source yang valid (quantity > 0) - var firstProductWarehouseId uint - for _, sourceDetail := range req.SourceKandangs { - if sourceDetail.Quantity > 0 { - if pwId, ok := sourceWarehouseMap[sourceDetail.ProjectFlockKandangId]; ok { - firstProductWarehouseId = pwId - break - } + var totalPopulation float64 + var sourceProductWarehouseID uint + for _, pop := range populations { + totalPopulation += pop.TotalQty + if sourceProductWarehouseID == 0 { + sourceProductWarehouseID = pop.ProductWarehouseId } } + if totalPopulation == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang sumber %d tidak memiliki populasi untuk ditransfer", sourceDetail.ProjectFlockKandangId)) + } + if totalPopulation < sourceRequestedQty { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang sumber %d jumlah tidak mencukupi. Tersedia: %.0f, Diminta: %.0f", sourceDetail.ProjectFlockKandangId, totalPopulation, sourceRequestedQty)) + } + if sourceProductWarehouseID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang sumber %d tidak memiliki warehouse produk ayam", sourceDetail.ProjectFlockKandangId)) + } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { repoTx := s.Repository.WithTx(dbTransaction) @@ -570,47 +517,26 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, } if err := repoTx.PatchOne(c.Context(), id, map[string]any{ - "transfer_date": transferDate, - "notes": req.Reason, + "transfer_date": transferDate, + "notes": req.Reason, + "source_project_flock_kandang_id": sourceDetail.ProjectFlockKandangId, + "source_product_warehouse_id": sourceProductWarehouseID, + "source_requested_qty": sourceRequestedQty, + "source_usage_qty": 0, + "source_pending_usage_qty": 0, }, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update transfer header") } - // Create new sources dengan pending quantity - for _, sourceDetail := range req.SourceKandangs { - if sourceDetail.Quantity == 0 { - continue - } - - productWarehouseId := sourceWarehouseMap[sourceDetail.ProjectFlockKandangId] - - source := entity.LayingTransferSource{ - LayingTransferId: id, - SourceProjectFlockKandangId: sourceDetail.ProjectFlockKandangId, - RequestedQty: sourceDetail.Quantity, // Quantity yang diminta user - UsageQty: 0, - PendingUsageQty: 0, // Di-set 0, biarkan FIFO Consume yang handle saat Approval - ProductWarehouseId: &productWarehouseId, - } - if err := sourceRepo.CreateOne(c.Context(), &source, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to create transfer source") - } - } - - // Ambil product ID dari source warehouse pertama yang valid - var sourceProductID uint - if firstProductWarehouseId > 0 { - sourcePW, err := pwRepo.GetByID(c.Context(), firstProductWarehouseId, nil) - if err == nil { - sourceProductID = sourcePW.ProductId - } - } - - if sourceProductID == 0 { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product from source warehouse") + sourcePW, err := pwRepo.GetByID(c.Context(), sourceProductWarehouseID, nil) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get source product warehouse") } for _, targetDetail := range req.TargetKandangs { + if targetDetail.Quantity <= 0 { + continue + } targetprojectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetDetail.ProjectFlockKandangId) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get target project flock kandang") @@ -624,12 +550,12 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, return fiber.NewError(fiber.StatusInternalServerError, "Failed to get target warehouse") } - targetPW, err := pwRepo.FindByProductWarehouseAndPfk(c.Context(), sourceProductID, targetWarehouse.Id, &targetDetail.ProjectFlockKandangId) + targetPW, err := pwRepo.FindByProductWarehouseAndPfk(c.Context(), sourcePW.ProductId, targetWarehouse.Id, &targetDetail.ProjectFlockKandangId) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { newTargetPW := entity.ProductWarehouse{ - ProductId: sourceProductID, + ProductId: sourcePW.ProductId, WarehouseId: targetWarehouse.Id, ProjectFlockKandangId: &targetDetail.ProjectFlockKandangId, Quantity: 0, @@ -672,7 +598,7 @@ func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error { return err } - _, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + transfer, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { return db.Preload("Sources.ProductWarehouse").Preload("Targets") }) if err != nil { @@ -681,6 +607,9 @@ func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error { } return fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer laying") } + if isLegacyTransfer(transfer) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Transfer laying legacy %s tidak dapat dihapus", transfer.TransferNumber)) + } approvalRepo := commonRepo.NewApprovalRepository(s.Repository.DB()) @@ -691,9 +620,17 @@ func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error { if latestApproval != nil && latestApproval.Action != nil { action := string(*latestApproval.Action) - if action == string(entity.ApprovalActionApproved) || action == string(entity.ApprovalActionRejected) { + if action == string(entity.ApprovalActionRejected) { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot delete transfer laying with status %s", action)) } + if action == string(entity.ApprovalActionApproved) && transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero() { + if _, err := s.Unexecute(c, id); err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return fiberErr + } + return fiber.NewError(fiber.StatusInternalServerError, "Gagal unexecute transfer laying sebelum delete") + } + } } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { repoTx := s.Repository.WithTx(dbTransaction) @@ -755,16 +692,21 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { repoTx := s.Repository.WithTx(dbTransaction) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) - sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction) + targetRepoTx := repository.NewLayingTransferTargetRepository(dbTransaction) for _, approvableID := range approvableIDs { - _, err := repoTx.GetByID(c.Context(), approvableID, nil) + transfer, err := repoTx.GetByID(c.Context(), approvableID, func(db *gorm.DB) *gorm.DB { + return db.Preload("Sources") + }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("TransferLaying %d not found", approvableID)) } return err } + if isLegacyTransfer(transfer) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Transfer laying legacy %s tidak dapat diproses approval", transfer.TransferNumber)) + } if _, err := approvalSvcTx.CreateApproval( c.Context(), @@ -779,22 +721,52 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( } if action == entity.ApprovalActionApproved { - sources, err := sourceRepoTx.GetByLayingTransferId(c.Context(), approvableID) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil sources transfer") + if transfer.SourceProjectFlockKandangId == nil || *transfer.SourceProjectFlockKandangId == 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Transfer laying %s tidak memiliki kandang sumber valid", transfer.TransferNumber)) } - effectiveMoveDate, err := s.calculateEffectiveMoveDate(c.Context(), sources) + targets, err := targetRepoTx.GetByLayingTransferId(c.Context(), approvableID) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil target transfer") + } + sourceRequestedQty := 0.0 + for _, target := range targets { + if target.TotalQty > 0 { + sourceRequestedQty += target.TotalQty + } + } + if sourceRequestedQty <= 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Transfer laying %s tidak memiliki total target valid", transfer.TransferNumber)) + } + economicCutoffDate, err := s.calculateEconomicCutoffDate(c.Context(), *transfer.SourceProjectFlockKandangId) if err != nil { return err } if err := repoTx.PatchOne(c.Context(), approvableID, map[string]any{ - "effective_move_date": effectiveMoveDate, - "executed_at": nil, - "executed_by": nil, + "economic_cutoff_date": economicCutoffDate, + "effective_move_date": economicCutoffDate, // Backward-compatible alias for existing clients. + "source_requested_qty": sourceRequestedQty, + "executed_at": nil, + "executed_by": nil, }, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal menyimpan tanggal efektif transfer laying") } + + today := normalizeDateOnlyUTC(time.Now().UTC()) + physicalMoveDate := normalizeDateOnlyUTC(transfer.TransferDate) + if !today.Before(physicalMoveDate) { + if err := s.executeTransferInTx( + c.Context(), + dbTransaction, + transfer.Id, + actorID, + today, + physicalMoveDate, + true, + ); err != nil { + return err + } + } } } @@ -830,74 +802,17 @@ func (s transferLayingService) Execute(c *fiber.Ctx, id uint) (*entity.LayingTra return nil, err } + today := normalizeDateOnlyUTC(time.Now().UTC()) err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { - repoTx := s.Repository.WithTx(dbTransaction) - sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction) - targetRepoTx := repository.NewLayingTransferTargetRepository(dbTransaction) - approvalRepoTx := commonRepo.NewApprovalRepository(dbTransaction) - - transfer, err := repoTx.GetByID(c.Context(), id, nil) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Transfer laying tidak ditemukan") - } - return err - } - - if transfer.ExecutedAt != nil { - return nil - } - - latestApproval, err := approvalRepoTx.LatestByTarget(c.Context(), string(utils.ApprovalWorkflowTransferToLaying), transfer.Id, nil) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - if latestApproval == nil || latestApproval.Action == nil || *latestApproval.Action != entity.ApprovalActionApproved { - return fiber.NewError(fiber.StatusBadRequest, "Transfer laying harus disetujui sebelum dieksekusi") - } - - sources, err := sourceRepoTx.GetByLayingTransferId(c.Context(), transfer.Id) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil sumber transfer laying") - } - - targets, err := targetRepoTx.GetByLayingTransferId(c.Context(), transfer.Id) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil target transfer laying") - } - - if transfer.EffectiveMoveDate == nil || transfer.EffectiveMoveDate.IsZero() { - effectiveMoveDate, calcErr := s.calculateEffectiveMoveDate(c.Context(), sources) - if calcErr != nil { - return calcErr - } - if patchErr := repoTx.PatchOne(c.Context(), transfer.Id, map[string]any{ - "effective_move_date": effectiveMoveDate, - }, nil); patchErr != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal menyimpan tanggal efektif transfer laying") - } - transfer.EffectiveMoveDate = &effectiveMoveDate - } - - effectiveMoveDate := normalizeDateOnlyUTC(*transfer.EffectiveMoveDate) - today := normalizeDateOnlyUTC(time.Now().UTC()) - if today.Before(effectiveMoveDate) { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Transfer laying baru bisa dieksekusi mulai tanggal %s", effectiveMoveDate.Format("2006-01-02"))) - } - - if err := s.executeApprovedTransferMovement(c.Context(), dbTransaction, transfer, actorID, sources, targets); err != nil { - return err - } - - executedAt := time.Now().UTC() - if err := repoTx.PatchOne(c.Context(), transfer.Id, map[string]any{ - "executed_at": executedAt, - "executed_by": actorID, - }, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal menyimpan status eksekusi transfer laying") - } - - return nil + return s.executeTransferInTx( + c.Context(), + dbTransaction, + id, + actorID, + today, + time.Now().UTC(), + false, + ) }) if err != nil { if fiberErr, ok := err.(*fiber.Error); ok { @@ -913,19 +828,354 @@ func (s transferLayingService) Execute(c *fiber.Ctx, id uint) (*entity.LayingTra return transfer, nil } +func (s transferLayingService) ExecuteWithBusinessDate(c *fiber.Ctx, id uint, businessDate time.Time) (*entity.LayingTransfer, error) { + if err := m.EnsureLayingTransferAccess(c, s.Repository.DB(), id); err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + businessDate = normalizeDateOnlyUTC(businessDate) + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + return s.executeTransferInTx( + c.Context(), + dbTransaction, + id, + actorID, + businessDate, + businessDate, + true, + ) + }) + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal auto execute transfer laying") + } + + transfer, _, err := s.GetOne(c, id) + if err != nil { + return nil, err + } + return transfer, nil +} + +func (s transferLayingService) executeTransferInTx( + ctx context.Context, + tx *gorm.DB, + id uint, + actorID uint, + eligibilityDate time.Time, + executedAt time.Time, + forceDateOnlyExecutedAt bool, +) error { + repoTx := s.Repository.WithTx(tx) + targetRepoTx := repository.NewLayingTransferTargetRepository(tx) + approvalRepoTx := commonRepo.NewApprovalRepository(tx) + + transfer, err := repoTx.GetByID(ctx, id, func(db *gorm.DB) *gorm.DB { + return db. + Clauses(clause.Locking{Strength: "UPDATE"}). + Preload("Sources") + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Transfer laying tidak ditemukan") + } + return err + } + if isLegacyTransfer(transfer) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Transfer laying legacy %s tidak dapat dieksekusi", transfer.TransferNumber)) + } + if transfer.SourceProjectFlockKandangId == nil || *transfer.SourceProjectFlockKandangId == 0 || transfer.SourceProductWarehouseId == nil || *transfer.SourceProductWarehouseId == 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Transfer laying %s belum memiliki data source yang valid", transfer.TransferNumber)) + } + + if transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero() { + return nil + } + + latestApproval, err := approvalRepoTx.LatestByTarget(ctx, string(utils.ApprovalWorkflowTransferToLaying), transfer.Id, nil) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if latestApproval == nil || latestApproval.Action == nil || *latestApproval.Action != entity.ApprovalActionApproved { + return fiber.NewError(fiber.StatusBadRequest, "Transfer laying harus disetujui sebelum dieksekusi") + } + + targets, err := targetRepoTx.GetByLayingTransferId(ctx, transfer.Id) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil target transfer laying") + } + + if transfer.EconomicCutoffDate == nil || transfer.EconomicCutoffDate.IsZero() { + economicCutoffDate, calcErr := s.calculateEconomicCutoffDate(ctx, *transfer.SourceProjectFlockKandangId) + if calcErr != nil { + return calcErr + } + if patchErr := repoTx.PatchOne(ctx, transfer.Id, map[string]any{ + "economic_cutoff_date": economicCutoffDate, + "effective_move_date": economicCutoffDate, // Keep legacy field in sync. + }, nil); patchErr != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal menyimpan tanggal efektif transfer laying") + } + transfer.EconomicCutoffDate = &economicCutoffDate + transfer.EffectiveMoveDate = &economicCutoffDate + } + + physicalMoveDate := normalizeDateOnlyUTC(transfer.TransferDate) + eligibilityDate = normalizeDateOnlyUTC(eligibilityDate) + if eligibilityDate.Before(physicalMoveDate) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Transfer laying baru bisa dieksekusi mulai tanggal pindah fisik %s", physicalMoveDate.Format("2006-01-02"))) + } + + if err := s.executeApprovedTransferMovement(ctx, tx, transfer, actorID, targets); err != nil { + return err + } + + executedAt = executedAt.UTC() + if forceDateOnlyExecutedAt { + executedAt = normalizeDateOnlyUTC(executedAt) + } + if executedAt.IsZero() { + executedAt = eligibilityDate + } + + if err := repoTx.PatchOne(ctx, transfer.Id, map[string]any{ + "executed_at": executedAt, + "executed_by": actorID, + }, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal menyimpan status eksekusi transfer laying") + } + + return nil +} + +func (s transferLayingService) Unexecute(c *fiber.Ctx, id uint) (*entity.LayingTransfer, error) { + if err := m.EnsureLayingTransferAccess(c, s.Repository.DB(), id); err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + repoTx := s.Repository.WithTx(dbTransaction) + targetRepoTx := repository.NewLayingTransferTargetRepository(dbTransaction) + approvalRepoTx := commonRepo.NewApprovalRepository(dbTransaction) + stockLogRepoTx := rStockLogs.NewStockLogRepository(dbTransaction) + + transfer, err := repoTx.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + return db.Preload("Sources") + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Transfer laying tidak ditemukan") + } + return err + } + if isLegacyTransfer(transfer) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Transfer laying legacy %s tidak dapat di-unexecute", transfer.TransferNumber)) + } + if transfer.SourceProductWarehouseId == nil || *transfer.SourceProductWarehouseId == 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Transfer laying %s belum memiliki source warehouse valid", transfer.TransferNumber)) + } + + if transfer.ExecutedAt == nil || transfer.ExecutedAt.IsZero() { + return nil + } + + latestApproval, err := approvalRepoTx.LatestByTarget(c.Context(), string(utils.ApprovalWorkflowTransferToLaying), transfer.Id, nil) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if latestApproval == nil || latestApproval.Action == nil || *latestApproval.Action != entity.ApprovalActionApproved { + return fiber.NewError(fiber.StatusBadRequest, "Transfer laying hanya bisa di-unexecute saat status approval APPROVED") + } + + targets, err := targetRepoTx.GetByLayingTransferId(c.Context(), transfer.Id) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil target transfer laying") + } + if len(targets) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Transfer laying belum memiliki target") + } + physicalMoveDate := normalizeDateOnlyUTC(transfer.TransferDate) + for _, target := range targets { + if target.TotalUsed > 1e-6 { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Transfer laying %s tidak dapat di-unexecute karena target kandang %d sudah dipakai downstream", transfer.TransferNumber, target.TargetProjectFlockKandangId), + ) + } + hasDownstreamRecording, firstRecordingDate, err := s.hasDownstreamRecordingOnTarget(c.Context(), dbTransaction, target.TargetProjectFlockKandangId, physicalMoveDate) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi recording downstream pada target transfer laying") + } + if hasDownstreamRecording { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Transfer laying %s tidak dapat di-unexecute karena target kandang %d sudah memiliki recording laying downstream sejak %s", transfer.TransferNumber, target.TargetProjectFlockKandangId, firstRecordingDate.Format("2006-01-02")), + ) + } + } + + for _, target := range targets { + if target.ProductWarehouseId == nil || *target.ProductWarehouseId == 0 { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", transfer.Id)) + } + if target.TotalQty <= 0 { + continue + } + if err := s.FifoSvc.AdjustStockableQuantity(c.Context(), commonSvc.StockAdjustRequest{ + StockableKey: fifo.StockableKeyTransferToLayingIn, + StockableID: target.Id, + ProductWarehouseID: *target.ProductWarehouseId, + Quantity: -target.TotalQty, + Tx: dbTransaction, + }); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback stok target transfer laying: %v", err)) + } + + stockLogDecrease := &entity.StockLog{ + ProductWarehouseId: *target.ProductWarehouseId, + CreatedBy: actorID, + Increase: 0, + Decrease: target.TotalQty, + LoggableType: string(utils.StockLogTypeTransferLaying), + LoggableId: transfer.Id, + Notes: fmt.Sprintf("TL UNEXEC #%s", transfer.TransferNumber), + } + stockLogs, err := stockLogRepoTx.GetByProductWarehouse(c.Context(), *target.ProductWarehouseId, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + stockLogDecrease.Stock = latestStockLog.Stock - stockLogDecrease.Decrease + } else { + stockLogDecrease.Stock -= stockLogDecrease.Decrease + } + if err := stockLogRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar target saat unexecute") + } + } + + asOf := normalizeDateOnlyUTC(transfer.TransferDate) + if err := repoTx.PatchOne(c.Context(), transfer.Id, map[string]any{ + "source_usage_qty": 0, + "source_pending_usage_qty": 0, + }, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal reset kuantitas source transfer laying") + } + if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: transferToLayingFlagGroupCode, + ProductWarehouseID: *transfer.SourceProductWarehouseId, + AsOf: &asOf, + Tx: dbTransaction, + }); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback FIFO v2 source transfer laying: %v", err)) + } + if err := fifoV2.ReleasePopulationConsumptionByUsable( + c.Context(), + dbTransaction, + fifo.UsableKeyTransferToLayingOut.String(), + transfer.Id, + ); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal rollback pemakaian population sumber transfer laying") + } + if transfer.SourceProjectFlockKandangId != nil && *transfer.SourceProjectFlockKandangId != 0 { + if err := s.resyncPopulationUsageByProjectFlockKandang(c.Context(), dbTransaction, *transfer.SourceProjectFlockKandangId); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal sinkronisasi population source setelah unexecute transfer laying") + } + } + + now := time.Now().UTC() + if err := dbTransaction.WithContext(c.Context()). + Model(&entity.StockAllocation{}). + Where("stockable_type = ? AND stockable_id = ? AND status = ? AND allocation_purpose = ?", + fifo.UsableKeyTransferToLayingOut.String(), + transfer.Id, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Updates(map[string]any{ + "status": entity.StockAllocationStatusReleased, + "released_at": now, + "note": fmt.Sprintf("TL UNEXEC #%s", transfer.TransferNumber), + }).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal release mapping source-target transfer laying") + } + + if transfer.SourceUsageQty > 0 { + stockLogIncrease := &entity.StockLog{ + ProductWarehouseId: *transfer.SourceProductWarehouseId, + CreatedBy: actorID, + Increase: transfer.SourceUsageQty, + Decrease: 0, + LoggableType: string(utils.StockLogTypeTransferLaying), + LoggableId: transfer.Id, + Notes: fmt.Sprintf("TL UNEXEC #%s", transfer.TransferNumber), + } + stockLogs, err := stockLogRepoTx.GetByProductWarehouse(c.Context(), *transfer.SourceProductWarehouseId, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + stockLogIncrease.Stock = latestStockLog.Stock + stockLogIncrease.Increase + } else { + stockLogIncrease.Stock += stockLogIncrease.Increase + } + if err := stockLogRepoTx.CreateOne(c.Context(), stockLogIncrease, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk source saat unexecute") + } + } + + if err := repoTx.PatchOne(c.Context(), transfer.Id, map[string]any{ + "executed_at": nil, + "executed_by": nil, + }, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal reset status eksekusi transfer laying") + } + + return nil + }) + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal melakukan unexecute transfer laying") + } + + transfer, _, err := s.GetOne(c, id) + if err != nil { + return nil, err + } + return transfer, nil +} + func (s *transferLayingService) executeApprovedTransferMovement( ctx context.Context, tx *gorm.DB, transfer *entity.LayingTransfer, actorID uint, - sources []entity.LayingTransferSource, targets []entity.LayingTransferTarget, ) error { if transfer == nil || transfer.Id == 0 { return fiber.NewError(fiber.StatusBadRequest, "Transfer laying tidak valid") } - if len(sources) == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Transfer laying belum memiliki sumber") + if transfer.SourceProjectFlockKandangId == nil || *transfer.SourceProjectFlockKandangId == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Transfer laying belum memiliki kandang sumber") + } + if transfer.SourceProductWarehouseId == nil || *transfer.SourceProductWarehouseId == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Transfer laying belum memiliki source product warehouse") } if len(targets) == 0 { return fiber.NewError(fiber.StatusBadRequest, "Transfer laying belum memiliki target") @@ -938,9 +1188,9 @@ func (s *transferLayingService) executeApprovedTransferMovement( } stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx) - sourceRepoTx := repository.NewLayingTransferSourceRepository(tx) targetRepoTx := repository.NewLayingTransferTargetRepository(tx) stockLogRepoTx := rStockLogs.NewStockLogRepository(tx) + repoTx := s.Repository.WithTx(tx) totalTargetQty := 0.0 for _, target := range targets { @@ -950,109 +1200,85 @@ func (s *transferLayingService) executeApprovedTransferMovement( return fiber.NewError(fiber.StatusBadRequest, "Total kuantitas target transfer laying harus lebih dari 0") } - totalSourceRequested := 0.0 - for _, source := range sources { - totalSourceRequested += source.RequestedQty - } - if totalSourceRequested <= 0 { + if transfer.SourceRequestedQty <= 0 { return fiber.NewError(fiber.StatusBadRequest, "Total kuantitas sumber transfer laying harus lebih dari 0") } - for _, source := range sources { - if source.ProductWarehouseId == nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Source product warehouse tidak ditemukan untuk transfer %d", transfer.Id)) - } - if source.RequestedQty <= 0 { + if err := repoTx.PatchOne(ctx, transfer.Id, map[string]any{ + "source_usage_qty": transfer.SourceUsageQty + totalTargetQty, + "source_pending_usage_qty": 0, + }, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal update source usage qty transfer laying") + } + + asOf := normalizeDateOnlyUTC(transfer.TransferDate) + if _, err := s.FifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: transferToLayingFlagGroupCode, + ProductWarehouseID: *transfer.SourceProductWarehouseId, + AsOf: &asOf, + Tx: tx, + }); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal consume FIFO v2 stock: %v", err)) + } + + refreshedTransfer, err := repoTx.GetByID(ctx, transfer.Id, nil) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal refresh transfer laying setelah reflow") + } + usageDelta := refreshedTransfer.SourceUsageQty - transfer.SourceUsageQty + pendingQty := refreshedTransfer.SourcePendingUsageQty + if pendingQty > 1e-6 || usageDelta < totalTargetQty-1e-6 { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Stok sumber tidak mencukupi untuk mengeksekusi transfer laying %s", transfer.TransferNumber), + ) + } + + if err := s.allocatePopulationForTransfer(ctx, tx, transfer.Id, *transfer.SourceProjectFlockKandangId, *transfer.SourceProductWarehouseId, usageDelta); err != nil { + return err + } + + for _, target := range targets { + if target.TotalQty <= 0 { continue } + mappingAllocation := &entity.StockAllocation{ + StockableType: fifo.UsableKeyTransferToLayingOut.String(), + StockableId: transfer.Id, + UsableType: fifo.StockableKeyTransferToLayingIn.String(), + UsableId: target.Id, + ProductWarehouseId: *transfer.SourceProductWarehouseId, + Qty: target.TotalQty, + Status: entity.StockAllocationStatusActive, + } + if err := stockAllocationRepo.CreateOne(ctx, mappingAllocation, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal create mapping allocation source→target") + } + } - sourceShare := (source.RequestedQty / totalSourceRequested) * totalTargetQty - if sourceShare <= 0 { - continue - } + stockLogDecrease := &entity.StockLog{ + ProductWarehouseId: *transfer.SourceProductWarehouseId, + CreatedBy: actorID, + Increase: 0, + Decrease: usageDelta, + LoggableType: string(utils.StockLogTypeTransferLaying), + LoggableId: transfer.Id, + Notes: fmt.Sprintf("TL #%s", transfer.TransferNumber), + } + stockLogs, err := stockLogRepoTx.GetByProductWarehouse(ctx, *transfer.SourceProductWarehouseId, 1) + if err != nil { + s.Log.Errorf("Failed to get stock logs: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + stockLogDecrease.Stock = latestStockLog.Stock - stockLogDecrease.Decrease + } else { + stockLogDecrease.Stock -= stockLogDecrease.Decrease + } - if err := sourceRepoTx.PatchOne(ctx, source.Id, map[string]any{ - "usage_qty": source.UsageQty + sourceShare, - "pending_usage_qty": 0, - }, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal update source usage qty") - } - - asOf := transfer.TransferDate - if transfer.EffectiveMoveDate != nil && !transfer.EffectiveMoveDate.IsZero() { - asOf = *transfer.EffectiveMoveDate - } - if _, err := s.FifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{ - FlagGroupCode: transferToLayingFlagGroupCode, - ProductWarehouseID: *source.ProductWarehouseId, - AsOf: &asOf, - Tx: tx, - }); err != nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal consume FIFO v2 stock: %v", err)) - } - - refreshedSource, err := sourceRepoTx.GetByID(ctx, source.Id, nil) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal refresh source transfer setelah reflow") - } - - usageDelta := refreshedSource.UsageQty - source.UsageQty - pendingQty := refreshedSource.PendingUsageQty - if pendingQty > 1e-6 || usageDelta < sourceShare-1e-6 { - return fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Stok sumber tidak mencukupi untuk mengeksekusi transfer laying %s", transfer.TransferNumber), - ) - } - - movedQty := usageDelta - if err := s.allocatePopulationForTransfer(ctx, tx, source, movedQty); err != nil { - return err - } - targetShares := distributeProportionalWithRounding(targets, totalTargetQty, movedQty) - for i, target := range targets { - roundedQty := math.Round(targetShares[i]) - if roundedQty <= 0 { - continue - } - mappingAllocation := &entity.StockAllocation{ - StockableType: fifo.UsableKeyTransferToLayingOut.String(), - StockableId: source.Id, - UsableType: fifo.StockableKeyTransferToLayingIn.String(), - UsableId: target.Id, - ProductWarehouseId: *source.ProductWarehouseId, - Qty: roundedQty, - Status: entity.StockAllocationStatusActive, - } - if err := stockAllocationRepo.CreateOne(ctx, mappingAllocation, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal create mapping allocation source→target") - } - } - - stockLogDecrease := &entity.StockLog{ - ProductWarehouseId: *source.ProductWarehouseId, - CreatedBy: actorID, - Increase: 0, - Decrease: movedQty, - LoggableType: string(utils.StockLogTypeTransferLaying), - LoggableId: transfer.Id, - Notes: fmt.Sprintf("TL #%s", transfer.TransferNumber), - } - stockLogs, err := stockLogRepoTx.GetByProductWarehouse(ctx, *source.ProductWarehouseId, 1) - if err != nil { - s.Log.Errorf("Failed to get stock logs: %+v", err) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - stockLogDecrease.Stock = latestStockLog.Stock - stockLogDecrease.Decrease - } else { - stockLogDecrease.Stock -= stockLogDecrease.Decrease - } - - if err := stockLogRepoTx.CreateOne(ctx, stockLogDecrease, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar") - } + if err := stockLogRepoTx.CreateOne(ctx, stockLogDecrease, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar") } for _, target := range targets { @@ -1111,7 +1337,9 @@ func (s *transferLayingService) executeApprovedTransferMovement( func (s *transferLayingService) allocatePopulationForTransfer( ctx context.Context, tx *gorm.DB, - source entity.LayingTransferSource, + transferID uint, + sourceProjectFlockKandangID uint, + sourceProductWarehouseID uint, consumeQty float64, ) error { if consumeQty <= 0 { @@ -1120,14 +1348,14 @@ func (s *transferLayingService) allocatePopulationForTransfer( if tx == nil { return errors.New("transaction is required") } - if source.SourceProjectFlockKandangId == 0 || source.ProductWarehouseId == nil || *source.ProductWarehouseId == 0 { + if sourceProjectFlockKandangID == 0 || sourceProductWarehouseID == 0 { return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang sumber atau product warehouse tidak valid") } populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangIDAndProductWarehouseID( ctx, - source.SourceProjectFlockKandangId, - *source.ProductWarehouseId, + sourceProjectFlockKandangID, + sourceProductWarehouseID, ) if err != nil { return err @@ -1140,16 +1368,16 @@ func (s *transferLayingService) allocatePopulationForTransfer( ctx, tx, populations, - *source.ProductWarehouseId, + sourceProductWarehouseID, fifo.UsableKeyTransferToLayingOut.String(), - source.Id, + transferID, consumeQty, ) } -func (s *transferLayingService) calculateEffectiveMoveDate(ctx context.Context, sources []entity.LayingTransferSource) (time.Time, error) { - if len(sources) == 0 { - return time.Time{}, fiber.NewError(fiber.StatusBadRequest, "Sumber transfer laying tidak ditemukan") +func (s *transferLayingService) calculateEconomicCutoffDate(ctx context.Context, sourceProjectFlockKandangID uint) (time.Time, error) { + if sourceProjectFlockKandangID == 0 { + return time.Time{}, fiber.NewError(fiber.StatusBadRequest, "Kandang sumber transfer laying tidak valid") } maxGrowingWeek := config.TransferToLayingGrowingMaxWeek @@ -1157,23 +1385,17 @@ func (s *transferLayingService) calculateEffectiveMoveDate(ctx context.Context, maxGrowingWeek = 19 } - var baselineChickInDate time.Time - for _, source := range sources { - chickInDate, err := s.resolveSourceChickInDate(ctx, source.SourceProjectFlockKandangId) - if err != nil { - return time.Time{}, err - } - if baselineChickInDate.IsZero() || chickInDate.Before(baselineChickInDate) { - baselineChickInDate = chickInDate - } + baselineChickInDate, err := s.resolveSourceChickInDate(ctx, sourceProjectFlockKandangID) + if err != nil { + return time.Time{}, err } if baselineChickInDate.IsZero() { return time.Time{}, fiber.NewError(fiber.StatusBadRequest, "Tanggal chick in sumber transfer laying tidak ditemukan") } - effectiveMoveDate := baselineChickInDate.AddDate(0, 0, maxGrowingWeek*7) - return normalizeDateOnlyUTC(effectiveMoveDate), nil + economicCutoffDate := baselineChickInDate.AddDate(0, 0, maxGrowingWeek*7) + return normalizeDateOnlyUTC(economicCutoffDate), nil } func (s *transferLayingService) resolveSourceChickInDate(ctx context.Context, sourceProjectFlockKandangID uint) (time.Time, error) { @@ -1321,37 +1543,107 @@ func (s transferLayingService) GetMaxTargetQtyPerKandang(c *fiber.Ctx, projectFl return kandangMaxTargetQty, nil } +func (s *transferLayingService) hasDownstreamRecordingOnTarget( + ctx context.Context, + tx *gorm.DB, + targetProjectFlockKandangID uint, + sinceDate time.Time, +) (bool, time.Time, error) { + if targetProjectFlockKandangID == 0 { + return false, time.Time{}, nil + } + + db := s.Repository.DB().WithContext(ctx) + if tx != nil { + db = tx.WithContext(ctx) + } + + var earliest entity.Recording + query := db.Model(&entity.Recording{}). + Where("project_flock_kandangs_id = ?", targetProjectFlockKandangID). + Where("deleted_at IS NULL") + if !sinceDate.IsZero() { + query = query.Where("record_datetime >= ?", sinceDate) + } + + if err := query.Order("record_datetime ASC").Limit(1).Take(&earliest).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, time.Time{}, nil + } + return false, time.Time{}, err + } + + return true, normalizeDateOnlyUTC(earliest.RecordDatetime), nil +} + +func (s *transferLayingService) resyncPopulationUsageByProjectFlockKandang(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error { + if projectFlockKandangID == 0 { + return nil + } + + db := s.Repository.DB().WithContext(ctx) + if tx != nil { + db = tx.WithContext(ctx) + } + + var populationIDs []uint + if err := db.Table("project_flock_populations pfp"). + Select("pfp.id"). + Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). + Where("pc.project_flock_kandang_id = ?", projectFlockKandangID). + Pluck("pfp.id", &populationIDs).Error; err != nil { + return err + } + if len(populationIDs) == 0 { + return nil + } + + type usageRow struct { + StockableID uint `gorm:"column:stockable_id"` + Used float64 `gorm:"column:used"` + } + var usageRows []usageRow + if err := db.Table("stock_allocations"). + Select("stockable_id, COALESCE(SUM(qty), 0) AS used"). + Where("stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()). + Where("status = ?", entity.StockAllocationStatusActive). + Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). + Where("stockable_id IN ?", populationIDs). + Group("stockable_id"). + Scan(&usageRows).Error; err != nil { + return err + } + + if err := db.Model(&entity.ProjectFlockPopulation{}). + Where("id IN ?", populationIDs). + Update("total_used_qty", 0).Error; err != nil { + return err + } + + for _, row := range usageRows { + if err := db.Model(&entity.ProjectFlockPopulation{}). + Where("id = ?", row.StockableID). + Update("total_used_qty", row.Used).Error; err != nil { + return err + } + } + + return nil +} + func normalizeDateOnlyUTC(value time.Time) time.Time { return time.Date(value.UTC().Year(), value.UTC().Month(), value.UTC().Day(), 0, 0, 0, 0, time.UTC) } -func distributeProportionalWithRounding(targets []entity.LayingTransferTarget, totalTargetQty, sourceShare float64) []float64 { - if len(targets) == 0 { - return []float64{} +func isLegacyTransfer(transfer *entity.LayingTransfer) bool { + if transfer == nil { + return false } - - targetShares := make([]float64, len(targets)) - totalRounded := 0.0 - - for i, target := range targets { - targetShares[i] = (target.TotalQty / totalTargetQty) * sourceShare - totalRounded += math.Round(targetShares[i]) + if transfer.SourceProjectFlockKandangId == nil || *transfer.SourceProjectFlockKandangId == 0 { + return true } - - diff := sourceShare - totalRounded - - if diff != 0 { - maxIdx := 0 - maxDecimal := 0.0 - for i, share := range targetShares { - decimal := share - math.Round(share) - if decimal > maxDecimal { - maxDecimal = decimal - maxIdx = i - } - } - targetShares[maxIdx] += diff + if len(transfer.Sources) > 0 { + return true } - - return targetShares + return false } diff --git a/internal/modules/production/transfer_layings/validations/transfer_laying.validation.go b/internal/modules/production/transfer_layings/validations/transfer_laying.validation.go index a2fef4a1..1b676cf3 100644 --- a/internal/modules/production/transfer_layings/validations/transfer_laying.validation.go +++ b/internal/modules/production/transfer_layings/validations/transfer_laying.validation.go @@ -14,7 +14,7 @@ type Create struct { TransferDate string `json:"transfer_date" validate:"required,datetime=2006-01-02"` SourceProjectFlockId uint `json:"source_project_flock_id" validate:"required"` TargetProjectFlockId uint `json:"target_project_flock_id" validate:"required"` - SourceKandangs []SourceKandangDetail `json:"source_kandangs" validate:"required,min=1,dive,required"` + SourceKandangs []SourceKandangDetail `json:"source_kandangs" validate:"required,min=1,max=1,dive,required"` TargetKandangs []TargetKandangDetail `json:"target_kandangs" validate:"required,min=1,dive,required"` Reason string `json:"reason" validate:"omitempty,max=1000"` } @@ -23,7 +23,7 @@ type Update struct { TransferDate string `json:"transfer_date" validate:"required,datetime=2006-01-02"` SourceProjectFlockId uint `json:"source_project_flock_id" validate:"required"` TargetProjectFlockId uint `json:"target_project_flock_id" validate:"required"` - SourceKandangs []SourceKandangDetail `json:"source_kandangs" validate:"required,min=1,dive,required"` + SourceKandangs []SourceKandangDetail `json:"source_kandangs" validate:"required,min=1,max=1,dive,required"` TargetKandangs []TargetKandangDetail `json:"target_kandangs" validate:"required,min=1,dive,required"` Reason string `json:"reason" validate:"omitempty,max=1000"` }