dev: initiate adjustment recording and trf to laying

This commit is contained in:
Adnan Zahir
2026-02-27 15:45:37 +07:00
committed by Hafizh A. Y
parent d335597bed
commit 4bb750fc98
13 changed files with 740 additions and 202 deletions
@@ -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
+6
View File
@@ -76,6 +76,7 @@ var (
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"))
@@ -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;
@@ -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';
+4
View File
@@ -12,6 +12,9 @@ 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"`
@@ -21,6 +24,7 @@ type LayingTransfer struct {
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:"-"`
@@ -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)
@@ -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{})
@@ -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 {
@@ -17,6 +17,8 @@ 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"`
}
@@ -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"`
}
@@ -91,6 +95,8 @@ func ToTransferLayingRelationDTO(e entity.LayingTransfer) TransferLayingRelation
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,
}
@@ -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,
)
@@ -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
}
@@ -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)
}
@@ -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,
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 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")
}
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: &note,
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{}