From 4bb750fc984f2e3fd7296112f1e6753e23601983 Mon Sep 17 00:00:00 2001 From: Adnan Zahir Date: Fri, 27 Feb 2026 15:45:37 +0700 Subject: [PATCH] dev: initiate adjustment recording and trf to laying --- .../repository/common.hpp.repository.go | 3 + internal/config/config.go | 114 ++-- ...red_execution_to_laying_transfers.down.sql | 11 + ...erred_execution_to_laying_transfers.up.sql | 46 ++ internal/entities/laying_transfer.go | 16 +- .../modules/production/recordings/module.go | 3 + .../recordings/services/recording.service.go | 93 ++++ .../controllers/transfer_laying.controller.go | 22 + .../dto/transfer_laying.dto.go | 30 +- .../production/transfer_layings/module.go | 42 ++ .../laying_transfer.repository.go | 58 ++ .../production/transfer_layings/route.go | 1 + .../services/transfer_laying.service.go | 503 +++++++++++++----- 13 files changed, 740 insertions(+), 202 deletions(-) create mode 100644 internal/database/migrations/20260227130000_add_deferred_execution_to_laying_transfers.down.sql create mode 100644 internal/database/migrations/20260227130000_add_deferred_execution_to_laying_transfers.up.sql diff --git a/internal/common/repository/common.hpp.repository.go b/internal/common/repository/common.hpp.repository.go index 260e78de..bc5037ec 100644 --- a/internal/common/repository/common.hpp.repository.go +++ b/internal/common/repository/common.hpp.repository.go @@ -299,6 +299,9 @@ func (r *HppRepositoryImpl) GetTransferSourceSummary(ctx context.Context, projec Table("laying_transfer_targets AS ltt"). Select("lt.from_project_flock_id AS project_flock_id, COALESCE(SUM(ltt.total_qty), 0) AS total_qty"). Joins("JOIN laying_transfers AS lt ON lt.id = ltt.laying_transfer_id"). + Where("lt.deleted_at IS NULL"). + Where("ltt.deleted_at IS NULL"). + Where("lt.executed_at IS NOT NULL"). Where("ltt.target_project_flock_kandang_id = ?", projectFlockKandangId). Group("lt.from_project_flock_id"). Scan(&summary).Error diff --git a/internal/config/config.go b/internal/config/config.go index af723b3b..95307f00 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,60 +22,61 @@ type SSOClientConfig struct { } var ( - IsProd bool - AppHost string - Version string - LogLevel string - AppPort int - DBHost string - DBUser string - DBPassword string - DBName string - DBPort int - DBSSLMode string - DBSSLRootCert string - DBSSLCert string - DBSSLKey string - JWTSecret string - JWTAccessExp int - JWTRefreshExp int - JWTResetPasswordExp int - JWTVerifyEmailExp int - RedisURL string - CORSAllowOrigins []string - CORSAllowMethods []string - CORSAllowHeaders []string - CORSExposeHeaders []string - CORSAllowCredentials bool - CORSMaxAge int - SSOIssuer string - SSOJWKSURL string - SSOAllowedAudiences []string - SSOAuthorizeURL string - SSOTokenURL string - SSOGetMeURL string - SSOPortalURL string - SSOClients map[string]SSOClientConfig - SSOAccessCookieName string - SSORefreshCookieName string - SSOCookieDomain string - SSOCookieSecure bool - SSOCookieSameSite string - SSOAccessTokenMaxBytes int - SSOTokenBlacklistPrefix string - SSOPKCETTL time.Duration - SSOUserSyncDrift time.Duration - SSOUserSyncNonceTTL time.Duration - SSOUserSyncMaxBodyBytes int - S3Endpoint string - S3Region string - S3Bucket string - S3AccessKey string - S3SecretKey string - S3ForcePathStyle bool - S3PublicBaseURL string - S3EnvPrefix string - S3DocumentKeyPrefix string + IsProd bool + AppHost string + Version string + LogLevel string + AppPort int + DBHost string + DBUser string + DBPassword string + DBName string + DBPort int + DBSSLMode string + DBSSLRootCert string + DBSSLCert string + DBSSLKey string + JWTSecret string + JWTAccessExp int + JWTRefreshExp int + JWTResetPasswordExp int + JWTVerifyEmailExp int + RedisURL string + CORSAllowOrigins []string + CORSAllowMethods []string + CORSAllowHeaders []string + CORSExposeHeaders []string + CORSAllowCredentials bool + CORSMaxAge int + SSOIssuer string + SSOJWKSURL string + SSOAllowedAudiences []string + SSOAuthorizeURL string + SSOTokenURL string + SSOGetMeURL string + SSOPortalURL string + SSOClients map[string]SSOClientConfig + SSOAccessCookieName string + SSORefreshCookieName string + SSOCookieDomain string + SSOCookieSecure bool + SSOCookieSameSite string + SSOAccessTokenMaxBytes int + SSOTokenBlacklistPrefix string + SSOPKCETTL time.Duration + SSOUserSyncDrift time.Duration + SSOUserSyncNonceTTL time.Duration + SSOUserSyncMaxBodyBytes int + S3Endpoint string + S3Region string + S3Bucket string + S3AccessKey string + S3SecretKey string + S3ForcePathStyle bool + S3PublicBaseURL string + S3EnvPrefix string + S3DocumentKeyPrefix string + TransferToLayingGrowingMaxWeek int ) func init() { @@ -117,6 +118,11 @@ func init() { // Redis RedisURL = viper.GetString("REDIS_URL") + TransferToLayingGrowingMaxWeek = viper.GetInt("TRANSFER_TO_LAYING_GROWING_MAX_WEEK") + if TransferToLayingGrowingMaxWeek <= 0 { + TransferToLayingGrowingMaxWeek = 19 + } + // Object storage S3Endpoint = strings.TrimSpace(viper.GetString("S3_ENDPOINT")) S3Region = strings.TrimSpace(viper.GetString("S3_REGION")) diff --git a/internal/database/migrations/20260227130000_add_deferred_execution_to_laying_transfers.down.sql b/internal/database/migrations/20260227130000_add_deferred_execution_to_laying_transfers.down.sql new file mode 100644 index 00000000..1b4d26c0 --- /dev/null +++ b/internal/database/migrations/20260227130000_add_deferred_execution_to_laying_transfers.down.sql @@ -0,0 +1,11 @@ +DROP INDEX IF EXISTS idx_laying_transfers_executed_by; +DROP INDEX IF EXISTS idx_laying_transfers_executed_at; +DROP INDEX IF EXISTS idx_laying_transfers_effective_move_date; + +ALTER TABLE laying_transfers + DROP CONSTRAINT IF EXISTS fk_laying_transfers_executed_by; + +ALTER TABLE laying_transfers + DROP COLUMN IF EXISTS executed_by, + DROP COLUMN IF EXISTS executed_at, + DROP COLUMN IF EXISTS effective_move_date; diff --git a/internal/database/migrations/20260227130000_add_deferred_execution_to_laying_transfers.up.sql b/internal/database/migrations/20260227130000_add_deferred_execution_to_laying_transfers.up.sql new file mode 100644 index 00000000..2fa1b39e --- /dev/null +++ b/internal/database/migrations/20260227130000_add_deferred_execution_to_laying_transfers.up.sql @@ -0,0 +1,46 @@ +ALTER TABLE laying_transfers + ADD COLUMN IF NOT EXISTS effective_move_date DATE, + ADD COLUMN IF NOT EXISTS executed_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS executed_by BIGINT; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') + AND NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_laying_transfers_executed_by' + ) THEN + ALTER TABLE laying_transfers + ADD CONSTRAINT fk_laying_transfers_executed_by + FOREIGN KEY (executed_by) + REFERENCES users(id) + ON DELETE SET NULL + ON UPDATE CASCADE; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_laying_transfers_effective_move_date + ON laying_transfers(effective_move_date); + +CREATE INDEX IF NOT EXISTS idx_laying_transfers_executed_at + ON laying_transfers(executed_at); + +CREATE INDEX IF NOT EXISTS idx_laying_transfers_executed_by + ON laying_transfers(executed_by); + +-- Backfill historical approved transfers. Before deferred execution, +-- approved transfers were executed immediately during approval. +UPDATE laying_transfers lt +SET + effective_move_date = COALESCE(lt.effective_move_date, lt.transfer_date), + executed_at = COALESCE(lt.executed_at, lt.updated_at), + executed_by = COALESCE(lt.executed_by, lt.created_by) +WHERE ( + SELECT a.action + FROM approvals a + WHERE a.approvable_type = 'TRANSFER_TO_LAYINGS' + AND a.approvable_id = lt.id + ORDER BY a.id DESC + LIMIT 1 +) = 'APPROVED'; diff --git a/internal/entities/laying_transfer.go b/internal/entities/laying_transfer.go index f983519f..db5ca775 100644 --- a/internal/entities/laying_transfer.go +++ b/internal/entities/laying_transfer.go @@ -12,16 +12,20 @@ type LayingTransfer struct { 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"` - FromProjectFlock *ProjectFlock `gorm:"foreignKey:FromProjectFlockId;references:Id"` - ToProjectFlock *ProjectFlock `gorm:"foreignKey:ToProjectFlockId;references:Id"` - CreatedUser *User `gorm:"foreignKey:CreatedBy;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"` + 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/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go index 0c130369..93263593 100644 --- a/internal/modules/production/recordings/module.go +++ b/internal/modules/production/recordings/module.go @@ -23,6 +23,7 @@ import ( sProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" 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" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -46,6 +47,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate productRepo := rProduct.NewProductRepository(db) chickinRepo := rChickin.NewChickinRepository(db) chickinDetailRepo := rChickin.NewChickinDetailRepository(db) + transferLayingRepo := rTransferLaying.NewTransferLayingRepository(db) stockLogRepo := rStockLogs.NewStockLogRepository(db) productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db) productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) @@ -112,6 +114,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate productionStandardService, projectFlockService, chickinService, + transferLayingRepo, 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 21ff718a..a020e378 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -19,6 +19,7 @@ import ( sProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" 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" 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" @@ -52,6 +53,7 @@ type recordingService struct { ProductionStandardSvc sProductionStandard.ProductionStandardService ProjectFlockSvc sProjectFlock.ProjectflockService ChickinSvc sChickin.ChickinService + TransferLayingRepo rTransferLaying.TransferLayingRepository FifoStockV2Svc commonSvc.FifoStockV2Service StockLogRepo rStockLogs.StockLogRepository } @@ -68,6 +70,7 @@ func NewRecordingService( productionStandardSvc sProductionStandard.ProductionStandardService, projectFlockSvc sProjectFlock.ProjectflockService, chickinSvc sChickin.ChickinService, + transferLayingRepo rTransferLaying.TransferLayingRepository, validate *validator.Validate, ) RecordingService { return &recordingService{ @@ -82,6 +85,7 @@ func NewRecordingService( ProductionStandardSvc: productionStandardSvc, ProjectFlockSvc: projectFlockSvc, ChickinSvc: chickinSvc, + TransferLayingRepo: transferLayingRepo, FifoStockV2Svc: fifoStockV2Svc, StockLogRepo: stockLogRepo, } @@ -287,6 +291,10 @@ 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 { + return nil, err + } + if err := s.ProjectFlockSvc.EnsureProjectFlockApproved(ctx, pfk.ProjectFlockId); err != nil { return nil, err } @@ -891,6 +899,91 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { }) } +func (s *recordingService) enforceTransferRecordingRoute( + ctx context.Context, + pfk *entity.ProjectFlockKandang, + recordTime time.Time, +) error { + if pfk == nil || pfk.Id == 0 || s.TransferLayingRepo == nil { + return nil + } + + recordDate := normalizeDateOnlyUTC(recordTime) + category := strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category)) + + switch category { + case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)): + transfer, err := s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx, pfk.Id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + s.Log.Errorf("Failed to resolve approved transfer by target kandang %d: %+v", pfk.Id, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying") + } + + effectiveDate := effectiveTransferDate(transfer) + if effectiveDate.IsZero() { + return nil + } + + if recordDate.Before(effectiveDate) { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Recording kandang laying hanya bisa dimulai pada %s. Sebelumnya gunakan kandang growing", effectiveDate.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")), + ) + } + + case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)): + transfer, err := s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, pfk.Id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + s.Log.Errorf("Failed to resolve approved transfer by source kandang %d: %+v", pfk.Id, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying") + } + + effectiveDate := effectiveTransferDate(transfer) + if effectiveDate.IsZero() { + return nil + } + + if !recordDate.Before(effectiveDate) { + 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")), + ) + } + } + + return nil +} + +func effectiveTransferDate(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 normalizeDateOnlyUTC(value time.Time) time.Time { + return time.Date(value.UTC().Year(), value.UTC().Month(), value.UTC().Day(), 0, 0, 0, 0, time.UTC) +} + func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []validation.Stock, depletions []validation.Depletion, eggs []validation.Egg) error { idSet := make(map[uint]struct{}) 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 581b9093..7b4b76ff 100644 --- a/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go +++ b/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go @@ -186,6 +186,28 @@ func (u *TransferLayingController) Approval(c *fiber.Ctx) error { }) } +func (u *TransferLayingController) Execute(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.Execute(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Execute 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 53e069b2..a23cc7df 100644 --- a/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go +++ b/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go @@ -14,10 +14,12 @@ import ( // === DTO Structs === type TransferLayingRelationDTO struct { - Id uint `json:"id"` - TransferNumber string `json:"transfer_number"` - TransferDate time.Time `json:"transfer_date"` - Notes string `json:"notes"` + 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"` } type ProjectFlockKandangWithKandangDTO struct { @@ -47,6 +49,8 @@ type TransferLayingListDTO struct { ToProjectFlock *projectFlockDTO.ProjectFlockRelationDTO `json:"to_project_flock,omitempty"` CreatedBy uint `json:"created_by"` CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` + ExecutedBy *uint `json:"executed_by,omitempty"` + ExecutedUser *userDTO.UserRelationDTO `json:"executed_user,omitempty"` CreatedAt time.Time `json:"created_at"` Approval *approvalDTO.ApprovalRelationDTO `json:"approval,omitempty"` } @@ -88,10 +92,12 @@ type MaxTargetQtyForTransferDTO struct { func ToTransferLayingRelationDTO(e entity.LayingTransfer) TransferLayingRelationDTO { return TransferLayingRelationDTO{ - Id: e.Id, - TransferNumber: e.TransferNumber, - TransferDate: e.TransferDate, - Notes: e.Notes, + Id: e.Id, + TransferNumber: e.TransferNumber, + TransferDate: e.TransferDate, + EffectiveMoveDate: e.EffectiveMoveDate, + ExecutedAt: e.ExecutedAt, + Notes: e.Notes, } } @@ -190,6 +196,12 @@ func ToTransferLayingListDTO(e entity.LayingTransfer) TransferLayingListDTO { createdUser = &mapped } + var executedUser *userDTO.UserRelationDTO + if e.ExecutedUser != nil && e.ExecutedUser.Id != 0 { + mapped := userDTO.ToUserRelationDTO(*e.ExecutedUser) + executedUser = &mapped + } + var approval *approvalDTO.ApprovalRelationDTO if e.LatestApproval != nil { mapped := approvalDTO.ToApprovalDTO(*e.LatestApproval) @@ -219,6 +231,8 @@ func ToTransferLayingListDTO(e entity.LayingTransfer) TransferLayingListDTO { ToProjectFlock: toProjectFlock, CreatedBy: e.CreatedBy, CreatedUser: createdUser, + ExecutedBy: e.ExecutedBy, + ExecutedUser: executedUser, CreatedAt: e.CreatedAt, Approval: approval, } diff --git a/internal/modules/production/transfer_layings/module.go b/internal/modules/production/transfer_layings/module.go index f7661034..a8044f79 100644 --- a/internal/modules/production/transfer_layings/module.go +++ b/internal/modules/production/transfer_layings/module.go @@ -2,6 +2,7 @@ package transfer_layings import ( "fmt" + "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -9,6 +10,7 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/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" @@ -32,8 +34,47 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db) productWarehouseRepo := rInventory.NewProductWarehouseRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) + + stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) + fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) + // daftarin jadi stockable + 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)) + } + } + + // daftarin jadi usable + if err := fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKeyTransferToLayingOut, + Table: "laying_transfer_sources", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "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) if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowTransferToLaying, utils.TransferToLayingApprovalSteps); err != nil { @@ -50,6 +91,7 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val productWarehouseRepo, warehouseRepo, approvalService, + fifoService, fifoStockV2Service, validate, ) diff --git a/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go b/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go index 68867265..1d28d9c9 100644 --- a/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go +++ b/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go @@ -7,6 +7,7 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) @@ -14,6 +15,8 @@ type TransferLayingRepository interface { repository.BaseRepository[entity.LayingTransfer] GetByTransferNumber(ctx context.Context, transferNumber string) (*entity.LayingTransfer, error) IdExists(ctx context.Context, id uint) (bool, error) + GetLatestApprovedBySourceKandang(ctx context.Context, sourceProjectFlockKandangID uint) (*entity.LayingTransfer, error) + GetLatestApprovedByTargetKandang(ctx context.Context, targetProjectFlockKandangID uint) (*entity.LayingTransfer, error) // Tambah method baru untuk query dengan filter lengkap GetAllWithFilters(ctx context.Context, offset int, limit int, params *GetAllFilterParams) ([]entity.LayingTransfer, int64, error) @@ -164,6 +167,7 @@ func (r *TransferLayingRepositoryImpl) GetAllWithFilters(ctx context.Context, of Preload("FromProjectFlock"). Preload("ToProjectFlock"). Preload("CreatedUser"). + Preload("ExecutedUser"). Preload("Sources"). Preload("Sources.SourceProjectFlockKandang"). Preload("Sources.SourceProjectFlockKandang.Kandang"). @@ -180,3 +184,57 @@ func (r *TransferLayingRepositoryImpl) GetAllWithFilters(ctx context.Context, of return records, total, nil } + +func (r *TransferLayingRepositoryImpl) GetLatestApprovedBySourceKandang(ctx context.Context, sourceProjectFlockKandangID uint) (*entity.LayingTransfer, error) { + if sourceProjectFlockKandangID == 0 { + return nil, gorm.ErrRecordNotFound + } + + 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). + Where("laying_transfers.deleted_at IS NULL"). + Where(`( + SELECT a.action + FROM approvals a + WHERE a.approvable_type = ? + AND a.approvable_id = laying_transfers.id + ORDER BY a.id DESC + LIMIT 1 + ) = ?`, string(utils.ApprovalWorkflowTransferToLaying), entity.ApprovalActionApproved). + Order("laying_transfers.id DESC"). + First(&transfer).Error + if err != nil { + return nil, err + } + return &transfer, nil +} + +func (r *TransferLayingRepositoryImpl) GetLatestApprovedByTargetKandang(ctx context.Context, targetProjectFlockKandangID uint) (*entity.LayingTransfer, error) { + if targetProjectFlockKandangID == 0 { + return nil, gorm.ErrRecordNotFound + } + + var transfer entity.LayingTransfer + err := r.db.WithContext(ctx). + Model(&entity.LayingTransfer{}). + Joins("JOIN laying_transfer_targets ltt ON ltt.laying_transfer_id = laying_transfers.id AND ltt.deleted_at IS NULL"). + Where("ltt.target_project_flock_kandang_id = ?", targetProjectFlockKandangID). + Where("laying_transfers.deleted_at IS NULL"). + Where(`( + SELECT a.action + FROM approvals a + WHERE a.approvable_type = ? + AND a.approvable_id = laying_transfers.id + ORDER BY a.id DESC + LIMIT 1 + ) = ?`, string(utils.ApprovalWorkflowTransferToLaying), entity.ApprovalActionApproved). + Order("laying_transfers.id DESC"). + First(&transfer).Error + if err != nil { + return nil, err + } + return &transfer, nil +} diff --git a/internal/modules/production/transfer_layings/route.go b/internal/modules/production/transfer_layings/route.go index c16ba1a8..edd1877c 100644 --- a/internal/modules/production/transfer_layings/route.go +++ b/internal/modules/production/transfer_layings/route.go @@ -27,6 +27,7 @@ func TransferLayingRoutes(v1 fiber.Router, u user.UserService, s transferLaying. route.Patch("/:id", m.RequirePermissions(m.P_TransferToLaying_UpdateOne), ctrl.UpdateOne) 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.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 3eb94a74..b1ef9440 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -10,6 +10,7 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/config" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" rInventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" @@ -19,6 +20,7 @@ import ( validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/validations" 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" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -33,6 +35,7 @@ type TransferLayingService interface { UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.LayingTransfer, error) 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) GetAvailableQtyPerKandang(ctx *fiber.Ctx, projectFlockID uint) (*entity.ProjectFlock, map[uint]float64, error) GetMaxTargetQtyPerKandang(ctx *fiber.Ctx, projectFlockID uint) (map[uint]float64, error) } @@ -50,9 +53,14 @@ type transferLayingService struct { WarehouseRepo rWarehouse.WarehouseRepository StockLogRepo rStockLogs.StockLogRepository ApprovalService commonSvc.ApprovalService + FifoSvc commonSvc.FifoService FifoStockV2Svc commonSvc.FifoStockV2Service } +const ( + transferToLayingFlagGroupCode = "AYAM" +) + func NewTransferLayingService( repo repository.TransferLayingRepository, layingTransferSourceRepo repository.LayingTransferSourceRepository, @@ -63,6 +71,7 @@ func NewTransferLayingService( productWarehouseRepo rInventory.ProductWarehouseRepository, warehouseRepo rWarehouse.WarehouseRepository, approvalService commonSvc.ApprovalService, + fifoSvc commonSvc.FifoService, fifoStockV2Svc commonSvc.FifoStockV2Service, validate *validator.Validate, ) TransferLayingService { @@ -79,6 +88,7 @@ func NewTransferLayingService( WarehouseRepo: warehouseRepo, StockLogRepo: rStockLogs.NewStockLogRepository(repo.DB()), ApprovalService: approvalService, + FifoSvc: fifoSvc, FifoStockV2Svc: fifoStockV2Svc, } } @@ -86,6 +96,7 @@ func NewTransferLayingService( func (s transferLayingService) withRelations(db *gorm.DB) *gorm.DB { return db. Preload("CreatedUser"). + Preload("ExecutedUser"). Preload("FromProjectFlock"). Preload("ToProjectFlock"). Preload("Sources"). @@ -744,11 +755,9 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( repoTx := s.Repository.WithTx(dbTransaction) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction) - targetRepoTx := repository.NewLayingTransferTargetRepository(dbTransaction) - stockLogRepoTx := rStockLogs.NewStockLogRepository(dbTransaction) for _, approvableID := range approvableIDs { - transfer, err := repoTx.GetByID(c.Context(), approvableID, nil) + _, err := repoTx.GetByID(c.Context(), approvableID, nil) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("TransferLaying %d not found", approvableID)) @@ -769,144 +778,21 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( } if action == entity.ApprovalActionApproved { - if s.FifoStockV2Svc == nil { - return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available") - } - sources, err := sourceRepoTx.GetByLayingTransferId(c.Context(), approvableID) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil sources transfer") } - - targets, err := targetRepoTx.GetByLayingTransferId(c.Context(), approvableID) + effectiveMoveDate, err := s.calculateEffectiveMoveDate(c.Context(), sources) if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil targets transfer") + return err } - totalTargetQty := 0.0 - for _, target := range targets { - totalTargetQty += target.TotalQty - } - - totalSourceRequested := 0.0 - for _, source := range sources { - totalSourceRequested += source.RequestedQty - } - - sourceBeforeUsage := make(map[uint]float64, len(sources)) - affectedPW := make(map[uint]struct{}) - - for _, source := range sources { - if source.ProductWarehouseId == nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Source product warehouse tidak ditemukan untuk transfer %d", approvableID)) - } - - sourceShare := 0.0 - if totalSourceRequested > 0 { - sourceShare = (source.RequestedQty / totalSourceRequested) * totalTargetQty - } - - sourceBeforeUsage[source.Id] = source.UsageQty - - if err := sourceRepoTx.PatchOne(c.Context(), source.Id, map[string]interface{}{ - "usage_qty": sourceShare, - "pending_usage_qty": 0, - }, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal update source usage qty") - } - - affectedPW[*source.ProductWarehouseId] = struct{}{} - } - - for _, target := range targets { - if target.ProductWarehouseId == nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", approvableID)) - } - - if err := targetRepoTx.PatchOne(c.Context(), target.Id, map[string]interface{}{ - "total_qty": target.TotalQty, - }, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal update target total qty") - } - affectedPW[*target.ProductWarehouseId] = struct{}{} - } - - for pwID := range affectedPW { - asOfCopy := transfer.TransferDate - if err := reflowTransferLayingScope(c.Context(), s.FifoStockV2Svc, dbTransaction, pwID, &asOfCopy); err != nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow FIFO stock transfer laying (pw=%d): %v", pwID, err)) - } - } - - for _, source := range sources { - if source.ProductWarehouseId == nil { - continue - } - refreshedSource, err := sourceRepoTx.GetByID(c.Context(), source.Id, nil) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal refresh source transfer setelah reflow") - } - - usageDelta := refreshedSource.UsageQty - sourceBeforeUsage[source.Id] - if usageDelta <= 0 { - continue - } - - stockLogDecrease := &entity.StockLog{ - ProductWarehouseId: *source.ProductWarehouseId, - CreatedBy: actorID, - Increase: 0, - Decrease: usageDelta, - LoggableType: string(utils.StockLogTypeTransferLaying), - LoggableId: approvableID, - Notes: fmt.Sprintf("TL #%s", transfer.TransferNumber), - } - stockLogs, err := stockLogRepoTx.GetByProductWarehouse(c.Context(), *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(c.Context(), stockLogDecrease, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar") - } - } - - for _, target := range targets { - if target.ProductWarehouseId == nil { - continue - } - - stockLogIncrease := &entity.StockLog{ - ProductWarehouseId: *target.ProductWarehouseId, - CreatedBy: actorID, - Increase: target.TotalQty, - Decrease: 0, - LoggableType: string(utils.StockLogTypeTransferLaying), - LoggableId: approvableID, - Notes: fmt.Sprintf("TL #%s", transfer.TransferNumber), - } - stockLogs, err := stockLogRepoTx.GetByProductWarehouse(c.Context(), *target.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] - 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") - } + if err := repoTx.PatchOne(c.Context(), approvableID, map[string]any{ + "effective_move_date": effectiveMoveDate, + "executed_at": nil, + "executed_by": nil, + }, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal menyimpan tanggal efektif transfer laying") } } } @@ -933,6 +819,351 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( return updated, nil } +func (s transferLayingService) Execute(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) + 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 + }) + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengeksekusi 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 len(targets) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Transfer laying belum memiliki target") + } + if s.FifoStockV2Svc == nil { + return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available") + } + if s.FifoSvc == nil { + return fiber.NewError(fiber.StatusInternalServerError, "FIFO service is not available") + } + + stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx) + sourceRepoTx := repository.NewLayingTransferSourceRepository(tx) + targetRepoTx := repository.NewLayingTransferTargetRepository(tx) + stockLogRepoTx := rStockLogs.NewStockLogRepository(tx) + + totalTargetQty := 0.0 + for _, target := range targets { + totalTargetQty += target.TotalQty + } + if totalTargetQty <= 0 { + 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 { + 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 { + continue + } + + sourceShare := (source.RequestedQty / totalSourceRequested) * totalTargetQty + if sourceShare <= 0 { + continue + } + + 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 + 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") + } + } + + for _, target := range targets { + if target.ProductWarehouseId == nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", transfer.Id)) + } + + note := fmt.Sprintf("Transfer to Laying #%s", transfer.TransferNumber) + _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: fifo.StockableKeyTransferToLayingIn, + StockableID: target.Id, + ProductWarehouseID: *target.ProductWarehouseId, + Quantity: target.TotalQty, + Note: ¬e, + Tx: tx, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal replenish stock ke target warehouse: %v", err)) + } + + if err := targetRepoTx.PatchOne(ctx, target.Id, map[string]any{ + "total_qty": target.TotalQty, + }, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal update target total qty") + } + + stockLogIncrease := &entity.StockLog{ + ProductWarehouseId: *target.ProductWarehouseId, + CreatedBy: actorID, + Increase: target.TotalQty, + Decrease: 0, + LoggableType: string(utils.StockLogTypeTransferLaying), + LoggableId: transfer.Id, + Notes: fmt.Sprintf("TL #%s", transfer.TransferNumber), + } + stockLogs, err := stockLogRepoTx.GetByProductWarehouse(ctx, *target.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] + stockLogIncrease.Stock = latestStockLog.Stock + stockLogIncrease.Increase + } else { + stockLogIncrease.Stock += stockLogIncrease.Increase + } + + if err := stockLogRepoTx.CreateOne(ctx, stockLogIncrease, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk") + } + } + + return nil +} + +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") + } + + maxGrowingWeek := config.TransferToLayingGrowingMaxWeek + if maxGrowingWeek <= 0 { + 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 + } + } + + 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 +} + +func (s *transferLayingService) resolveSourceChickInDate(ctx context.Context, sourceProjectFlockKandangID uint) (time.Time, error) { + if sourceProjectFlockKandangID == 0 { + return time.Time{}, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang sumber tidak valid") + } + + populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, sourceProjectFlockKandangID) + if err != nil { + return time.Time{}, err + } + + var earliestChickInDate time.Time + for _, population := range populations { + if population.ProjectChickin == nil || population.ProjectChickin.ChickInDate.IsZero() { + continue + } + chickInDate := normalizeDateOnlyUTC(population.ProjectChickin.ChickInDate) + if earliestChickInDate.IsZero() || chickInDate.Before(earliestChickInDate) { + earliestChickInDate = chickInDate + } + } + + if earliestChickInDate.IsZero() { + return time.Time{}, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Tanggal chick in untuk kandang sumber %d tidak ditemukan", sourceProjectFlockKandangID), + ) + } + + return earliestChickInDate, nil +} + func createApprovalTransferLaying(ctx context.Context, tx *gorm.DB, transferLayingID uint, actorID uint) error { if transferLayingID == 0 || actorID == 0 { return nil @@ -1047,6 +1278,10 @@ func (s transferLayingService) GetMaxTargetQtyPerKandang(c *fiber.Ctx, projectFl return kandangMaxTargetQty, 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{}