From c60c40af03774020b80e0818320ef8284b49cf6d Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 31 Dec 2025 10:27:15 +0700 Subject: [PATCH 01/14] feat(be): add GetByProjectFlockKandangIDForUpdate method to lock chickin rows and prevent race conditions --- .../project_chickin.repository.go | 15 ++++++ .../chickins/services/chickin.service.go | 50 +++++++++++++------ .../services/project_flock_kandang.service.go | 2 +- 3 files changed, 51 insertions(+), 16 deletions(-) diff --git a/internal/modules/production/chickins/repositories/project_chickin.repository.go b/internal/modules/production/chickins/repositories/project_chickin.repository.go index 43cafaac..ffdae20b 100644 --- a/internal/modules/production/chickins/repositories/project_chickin.repository.go +++ b/internal/modules/production/chickins/repositories/project_chickin.repository.go @@ -16,6 +16,7 @@ type ProjectChickinRepository interface { GetPendingByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectChickin, error) GetTotalPendingUsageQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) GetTotalChickinQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) + GetByProjectFlockKandangIDForUpdate(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectChickin, error) } type ChickinRepositoryImpl struct { @@ -64,6 +65,20 @@ func (r *ChickinRepositoryImpl) GetByProjectFlockKandangID(ctx context.Context, return chickins, nil } +// GetByProjectFlockKandangIDForUpdate locks chickin rows to prevent race condition +func (r *ChickinRepositoryImpl) GetByProjectFlockKandangIDForUpdate(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectChickin, error) { + var chickins []entity.ProjectChickin + err := r.db.WithContext(ctx). + Where("project_flock_kandang_id = ?", projectFlockKandangID). + Order("created_at DESC"). + Find(&chickins). + Error + if err != nil { + return nil, err + } + return chickins, nil +} + func (r *ChickinRepositoryImpl) GetPendingByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectChickin, error) { var chickins []entity.ProjectChickin err := r.db.WithContext(ctx). diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 871c8fce..5ba665ca 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -137,6 +137,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti if err != nil { return nil, err } + newChikins := make([]*entity.ProjectChickin, 0) chickinQtyMap := make(map[uint]float64) @@ -151,8 +152,8 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product warehouse %d is not bound to kandang's warehouse", chickinReq.ProductWarehouseId)) } - if productWarehouse.ProjectFlockKandangId == nil || *productWarehouse.ProjectFlockKandangId != req.ProjectFlockKandangId { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product warehouse %d is not attached to project_flock_kandang %d. Only product warehouses with matching project_flock_kandang_id can be chickin-ed", chickinReq.ProductWarehouseId, req.ProjectFlockKandangId)) + if productWarehouse.ProjectFlockKandangId != nil && *productWarehouse.ProjectFlockKandangId != req.ProjectFlockKandangId { + 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)) } chickinDate, err := utils.ParseDateString(chickinReq.ChickInDate) @@ -160,11 +161,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid ChickInDate format for product warehouse %d", chickinReq.ProductWarehouseId)) } - availableQty := productWarehouse.Quantity - if availableQty <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No available stock in product warehouse %d for chickin", chickinReq.ProductWarehouseId)) - } - newChickin := &entity.ProjectChickin{ ProjectFlockKandangId: req.ProjectFlockKandangId, ChickInDate: chickinDate, @@ -176,22 +172,46 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti } newChikins = append(newChikins, newChickin) - chickinQtyMap[uint(idx)] = availableQty + chickinQtyMap[uint(idx)] = productWarehouse.Quantity } if len(newChikins) == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "No chickins to create") } - existingChikins, err := s.Repository.GetByProjectFlockKandangID(c.Context(), req.ProjectFlockKandangId) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check existing chickins") - } - - isFirstTime := len(existingChikins) == 0 - err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + repositoryTx := repository.NewChickinRepository(dbTransaction) + existingChikins, err := repositoryTx.GetByProjectFlockKandangIDForUpdate(c.Context(), req.ProjectFlockKandangId) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to check existing chickins") + } + + isFirstTime := len(existingChikins) == 0 + + pendingQtyMap := make(map[uint]float64) + for _, existingChickin := range existingChikins { + if existingChickin.PendingUsageQty > 0 { + pendingQtyMap[existingChickin.ProductWarehouseId] += existingChickin.PendingUsageQty + } + } + + for idx, chickin := range newChikins { + pendingQty := pendingQtyMap[chickin.ProductWarehouseId] + desiredQty := chickinQtyMap[uint(idx)] + + availableQty := desiredQty - pendingQty + if availableQty < 0 { + availableQty = 0 + } + + if availableQty <= 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No available stock in product warehouse %d for chickin. Warehouse: %.0f, Pending: %.0f, Available: %.0f", chickin.ProductWarehouseId, desiredQty, pendingQty, availableQty)) + } + + chickinQtyMap[uint(idx)] = availableQty + } + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) if err := s.Repository.WithTx(dbTransaction).CreateMany(c.Context(), newChikins, nil); err != nil { diff --git a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go index 66fee8ce..6176daeb 100644 --- a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go +++ b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go @@ -191,7 +191,7 @@ func (s projectFlockKandangService) getAvailableQuantities(c *fiber.Ctx, project result := make(map[uint]float64) for _, pw := range products { - if pw.ProjectFlockKandangId != nil && *pw.ProjectFlockKandangId == projectFlockKandang.Id { + if pw.ProjectFlockKandangId == nil || *pw.ProjectFlockKandangId == projectFlockKandang.Id { availableQty, err := s.calculateAvailableQuantityForProductWarehouse(c, projectFlockKandang, &pw) if err != nil { s.Log.Warnf("Failed to calculate available quantity for product warehouse %d: %v", pw.Id, err) From 42853aaac058c6b293022ece613386f856c0ace1 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 31 Dec 2025 11:34:57 +0700 Subject: [PATCH 02/14] fix[BE]Reset chickin pending usage and skip zero entries --- .../production/chickins/services/chickin.service.go | 8 ++++++++ .../dto/project_flock_kandang.dto.go | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 5ba665ca..9784ef8a 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -572,6 +572,7 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti } ProjectFlockPopulationRepotx := s.ProjectflockPopulationRepo.WithTx(dbTransaction) + chickinRepoTx := s.Repository.WithTx(dbTransaction) var totalQuantityAdded float64 @@ -601,6 +602,13 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti return err } + // Reset PendingUsageQty to 0 since population has been created + if err := chickinRepoTx.PatchOne(ctx.Context(), chickin.Id, map[string]any{ + "pending_usage_qty": 0, + }, nil); err != nil { + return fmt.Errorf("failed to reset pending usage qty for chickin %d: %w", chickin.Id, err) + } + totalQuantityAdded += quantityToConvert } diff --git a/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go b/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go index a2ba8ad2..58f04f1b 100644 --- a/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go +++ b/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go @@ -204,6 +204,11 @@ func toAvailableQtyDTOsFromMap(chickins []entity.ProjectChickin, availableQtyMap result := make([]AvailableQtyDTO, 0, len(availableQtyMap)) for pwId, availableQty := range availableQtyMap { + // Skip jika available qty = 0 + if availableQty <= 0 { + continue + } + pw, exists := pwMap[pwId] if !exists || pw == nil { continue From bec6a93152b654360ae81731bb469d3ed1218c66 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 31 Dec 2025 11:43:03 +0700 Subject: [PATCH 03/14] FIX[BE]Stop adjusting target product warehouse quantity Avoid double counting: population entries and ConsumeChickinStocks already update inventory. Pullet/layer for the flock will be added via other flows (purchase, transfer, etc.) --- .../chickins/services/chickin.service.go | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 9784ef8a..e071f3be 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -612,15 +612,12 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti totalQuantityAdded += quantityToConvert } - if totalQuantityAdded > 0 { - if err := s.ProductWarehouseRepo.AdjustQuantities(ctx.Context(), map[uint]float64{ - targetPW.Id: totalQuantityAdded, - }, func(db *gorm.DB) *gorm.DB { - return dbTransaction - }); err != nil { - return fmt.Errorf("failed to update target product warehouse quantity: %w", err) - } - } + // NOTE: Tidak menambah target ProductWarehouse quantity karena: + // 1. Ayam sudah dipakai (masuk population) + // 2. ProductWarehouse source sudah berkurang saat create chickin (ConsumeChickinStocks) + // 3. Menambah quantity disini akan menyebabkan double count + // + // PULLET/LAYER untuk flock ini akan di-add lewat mekanisme lain (misal: purchase, transfer, dll) return nil } From ce083bccdc073a8f37de369a9de2566c3e0c6141 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 31 Dec 2025 12:59:17 +0700 Subject: [PATCH 04/14] feat(BE): add project flock ID to product warehouse creation for approval process --- .../services/transfer_laying.service.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 bf2c2ae3..3d291b6d 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -699,6 +699,7 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( targetWarehouse.Id, target.Qty, actorID, + &target.TargetProjectFlockKandangId, // Set flock ID agar bisa di-chickin di target flock ); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create or update product warehouse") } @@ -758,7 +759,7 @@ func createApprovalTransferLaying(ctx context.Context, tx *gorm.DB, transferLayi return err } -func (s *transferLayingService) getOrCreateProductWarehouse(ctx context.Context, tx *gorm.DB, productID uint, warehouseID uint, quantity float64, actorID uint) (*entity.ProductWarehouse, error) { +func (s *transferLayingService) getOrCreateProductWarehouse(ctx context.Context, tx *gorm.DB, productID uint, warehouseID uint, quantity float64, actorID uint, projectFlockKandangId *uint) (*entity.ProductWarehouse, error) { productWarehouseRepoTx := rInventory.NewProductWarehouseRepository(tx) @@ -775,9 +776,10 @@ func (s *transferLayingService) getOrCreateProductWarehouse(ctx context.Context, } newWarehouse := &entity.ProductWarehouse{ - ProductId: productID, - WarehouseId: warehouseID, - Quantity: quantity, + ProductId: productID, + WarehouseId: warehouseID, + ProjectFlockKandangId: projectFlockKandangId, // Set flock ID agar bisa di-chickin di target flock + Quantity: quantity, // CreatedBy: actorID, } From 7d6573fabd0821cf39cd93aa05331d57c4d2229e Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 31 Dec 2025 13:05:54 +0700 Subject: [PATCH 05/14] FIC[BE]Use qty column for warehouse updates --- .../services/transfer_laying.service.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 3d291b6d..7994727f 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -294,7 +294,7 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) return fiber.NewError(fiber.StatusInternalServerError, "Failed to reduce project flock population") } - if err := productWarehouseRepoTx.PatchOne(c.Context(), productWarehouseId, map[string]any{"quantity": gorm.Expr("quantity - ?", sourceDetail.Quantity)}, nil); err != nil { + if err := productWarehouseRepoTx.PatchOne(c.Context(), productWarehouseId, map[string]any{"quantity": gorm.Expr("qty - ?", sourceDetail.Quantity)}, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update source warehouse quantity") } } @@ -388,7 +388,7 @@ func (s transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, i if oldSource.ProductWarehouseId != nil && oldSource.Qty > 0 { if err := productWarehouseRepoTx.PatchOne(c.Context(), *oldSource.ProductWarehouseId, map[string]any{ - "quantity": gorm.Expr("quantity + ?", oldSource.Qty), + "quantity": gorm.Expr("qty + ?", oldSource.Qty), }, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to restore warehouse quantity") } @@ -466,7 +466,7 @@ func (s transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, i return err } - if err := productWarehouseRepoTx.PatchOne(c.Context(), productWarehouseId, map[string]any{"quantity": gorm.Expr("quantity - ?", sourceDetail.Quantity)}, nil); err != nil { + if err := productWarehouseRepoTx.PatchOne(c.Context(), productWarehouseId, map[string]any{"quantity": gorm.Expr("qty - ?", sourceDetail.Quantity)}, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update source warehouse quantity") } } @@ -544,7 +544,7 @@ func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error { if source.ProductWarehouseId != nil && source.Qty > 0 { if err := productWarehouseRepoTx.PatchOne(c.Context(), *source.ProductWarehouseId, map[string]any{ - "quantity": gorm.Expr("quantity + ?", source.Qty), + "quantity": gorm.Expr("qty + ?", source.Qty), }, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to restore source warehouse quantity") } @@ -766,7 +766,7 @@ func (s *transferLayingService) getOrCreateProductWarehouse(ctx context.Context, existing, err := productWarehouseRepoTx.GetProductWarehouseByProductAndWarehouseID(ctx, productID, warehouseID) if err == nil && existing != nil { - if err := productWarehouseRepoTx.PatchOne(ctx, existing.Id, map[string]any{"quantity": gorm.Expr("quantity + ?", quantity)}, nil); err != nil { + if err := productWarehouseRepoTx.PatchOne(ctx, existing.Id, map[string]any{"quantity": gorm.Expr("qty + ?", quantity)}, nil); err != nil { return nil, err } return existing, nil From 1b5437bc0191676b2d52319a67bf644db09b3e88 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 31 Dec 2025 13:09:13 +0700 Subject: [PATCH 06/14] FIX[BE]Lock chickins, accumulate pending qty, use qty key --- .../repositories/project_chickin.repository.go | 14 ++++++++++---- .../chickins/services/chickin.service.go | 6 ++++++ .../services/transfer_laying.service.go | 10 +++++----- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/internal/modules/production/chickins/repositories/project_chickin.repository.go b/internal/modules/production/chickins/repositories/project_chickin.repository.go index ffdae20b..f4cbf5e3 100644 --- a/internal/modules/production/chickins/repositories/project_chickin.repository.go +++ b/internal/modules/production/chickins/repositories/project_chickin.repository.go @@ -68,11 +68,17 @@ func (r *ChickinRepositoryImpl) GetByProjectFlockKandangID(ctx context.Context, // GetByProjectFlockKandangIDForUpdate locks chickin rows to prevent race condition func (r *ChickinRepositoryImpl) GetByProjectFlockKandangIDForUpdate(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectChickin, error) { var chickins []entity.ProjectChickin + // CRITICAL: Use FOR UPDATE to lock rows and prevent concurrent chickin requests + // This ensures that simultaneous requests wait for each other and read consistent pending_qty err := r.db.WithContext(ctx). - Where("project_flock_kandang_id = ?", projectFlockKandangID). - Order("created_at DESC"). - Find(&chickins). - Error + Raw(` + SELECT * FROM project_chickins + WHERE project_flock_kandang_id = ? + AND deleted_at IS NULL + ORDER BY created_at DESC + FOR UPDATE + `, projectFlockKandangID). + Scan(&chickins).Error if err != nil { return nil, err } diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index e071f3be..3ff5d8d9 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -196,6 +196,8 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti } } + // CRITICAL: Validate chickins sequentially to prevent over-allocation within the same request + // pendingQtyMap is accumulated as we validate each chickin to ensure total pending doesn't exceed available stock for idx, chickin := range newChikins { pendingQty := pendingQtyMap[chickin.ProductWarehouseId] desiredQty := chickinQtyMap[uint(idx)] @@ -210,6 +212,10 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti } chickinQtyMap[uint(idx)] = availableQty + + // ACCUMULATE pending for this product warehouse so NEXT chickin in same request sees it + // This prevents multiple chickins in same request from over-allocating the same stock + pendingQtyMap[chickin.ProductWarehouseId] += availableQty } approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) 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 7994727f..0bfbc378 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -294,7 +294,7 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) return fiber.NewError(fiber.StatusInternalServerError, "Failed to reduce project flock population") } - if err := productWarehouseRepoTx.PatchOne(c.Context(), productWarehouseId, map[string]any{"quantity": gorm.Expr("qty - ?", sourceDetail.Quantity)}, nil); err != nil { + if err := productWarehouseRepoTx.PatchOne(c.Context(), productWarehouseId, map[string]any{"qty": gorm.Expr("qty - ?", sourceDetail.Quantity)}, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update source warehouse quantity") } } @@ -388,7 +388,7 @@ func (s transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, i if oldSource.ProductWarehouseId != nil && oldSource.Qty > 0 { if err := productWarehouseRepoTx.PatchOne(c.Context(), *oldSource.ProductWarehouseId, map[string]any{ - "quantity": gorm.Expr("qty + ?", oldSource.Qty), + "qty": gorm.Expr("qty + ?", oldSource.Qty), }, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to restore warehouse quantity") } @@ -466,7 +466,7 @@ func (s transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, i return err } - if err := productWarehouseRepoTx.PatchOne(c.Context(), productWarehouseId, map[string]any{"quantity": gorm.Expr("qty - ?", sourceDetail.Quantity)}, nil); err != nil { + if err := productWarehouseRepoTx.PatchOne(c.Context(), productWarehouseId, map[string]any{"qty": gorm.Expr("qty - ?", sourceDetail.Quantity)}, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update source warehouse quantity") } } @@ -544,7 +544,7 @@ func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error { if source.ProductWarehouseId != nil && source.Qty > 0 { if err := productWarehouseRepoTx.PatchOne(c.Context(), *source.ProductWarehouseId, map[string]any{ - "quantity": gorm.Expr("qty + ?", source.Qty), + "qty": gorm.Expr("qty + ?", source.Qty), }, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to restore source warehouse quantity") } @@ -766,7 +766,7 @@ func (s *transferLayingService) getOrCreateProductWarehouse(ctx context.Context, existing, err := productWarehouseRepoTx.GetProductWarehouseByProductAndWarehouseID(ctx, productID, warehouseID) if err == nil && existing != nil { - if err := productWarehouseRepoTx.PatchOne(ctx, existing.Id, map[string]any{"quantity": gorm.Expr("qty + ?", quantity)}, nil); err != nil { + if err := productWarehouseRepoTx.PatchOne(ctx, existing.Id, map[string]any{"qty": gorm.Expr("qty + ?", quantity)}, nil); err != nil { return nil, err } return existing, nil From e4213079656e3450076d3e34744ed399bbd59137 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 31 Dec 2025 13:29:46 +0700 Subject: [PATCH 07/14] feat(BE): add validation for product category based on flock category in CreateOne method --- .../chickins/services/chickin.service.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 3ff5d8d9..8c896aef 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -156,6 +156,25 @@ 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)) } + // CRITICAL: Validate product category based on flock category + // GROWING: Input DOC, Output PULLET + // LAYING: Input PULLET, Output LAYER + if productWarehouse.Product.Id != 0 { + productCategoryCode := productWarehouse.Product.ProductCategory.Code + var allowedCategory string + if projectFlockKandang.ProjectFlock.Category == string(utils.ProjectFlockCategoryGrowing) { + allowedCategory = "DOC" + } else if projectFlockKandang.ProjectFlock.Category == string(utils.ProjectFlockCategoryLaying) { + allowedCategory = "PULLET" + } else { + return nil, fmt.Errorf("invalid flock category for chickin") + } + + if productCategoryCode != allowedCategory { + return nil, fmt.Errorf("product warehouse %d cannot be used for %s chickin. Only %s products can be used as input (current: %s)", chickinReq.ProductWarehouseId, projectFlockKandang.ProjectFlock.Category, allowedCategory, productCategoryCode) + } + } + 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)) From 0c6d42070ad33dd237f94f588f94ca60da47fb0f Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 6 Jan 2026 09:03:39 +0700 Subject: [PATCH 08/14] feat(BE): add items field to TransferDeliveryDTO in ToTransferDetailDTO function --- .../modules/inventory/transfers/dto/transfer.dto.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go index 8f075715..652b2a70 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -235,6 +235,15 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { var deliveries []TransferDeliveryDTO for _, del := range e.Deliveries { + var items []TransferDeliveryItemDTO + for _, item := range del.Items { + items = append(items, TransferDeliveryItemDTO{ + Id: item.Id, + StockTransferDetailId: item.StockTransferDetailId, + Quantity: item.Quantity, + }) + } + var document *DocumentDTO if len(del.Documents) > 0 { doc := del.Documents[0] // Take first document @@ -258,6 +267,7 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { DocumentNumber: del.DocumentNumber, ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, + Items: items, Document: document, }) } From d8fb42773430ef6ba2f07c8d2980c6f443dd6254 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 6 Jan 2026 11:12:41 +0700 Subject: [PATCH 09/14] feat(BE): add grand total calculation to ExpenseListDTO and update CreateOne method in expense service --- internal/modules/expenses/dto/expense.dto.go | 27 +++++++++++++++++++ .../expenses/services/expense.service.go | 1 + 2 files changed, 28 insertions(+) diff --git a/internal/modules/expenses/dto/expense.dto.go b/internal/modules/expenses/dto/expense.dto.go index 129c2e96..30cecd99 100644 --- a/internal/modules/expenses/dto/expense.dto.go +++ b/internal/modules/expenses/dto/expense.dto.go @@ -9,6 +9,7 @@ import ( nonstockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/dto" supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" ) // === DTO Structs === @@ -32,6 +33,7 @@ type ExpenseBaseDTO struct { type ExpenseListDTO struct { ExpenseBaseDTO + GrandTotal float64 `json:"grand_total"` CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -140,8 +142,11 @@ func ToExpenseListDTO(e entity.Expense) ExpenseListDTO { latestApproval = &mapped } + grandTotal := calculateGrandTotal(&e) + return ExpenseListDTO{ ExpenseBaseDTO: ToExpenseBaseDTO(&e), + GrandTotal: grandTotal, CreatedUser: createdUser, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, @@ -344,3 +349,25 @@ func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseReali return kandangs } + +func calculateGrandTotal(expense *entity.Expense) float64 { + + useRealization := expense.LatestApproval != nil && expense.LatestApproval.StepNumber >= uint16(utils.ExpenseStepRealisasi) + + if useRealization { + + var total float64 + for _, ns := range expense.Nonstocks { + if ns.Realization != nil { + total += ns.Realization.Qty * ns.Realization.Price + } + } + return total + } + + var total float64 + for _, ns := range expense.Nonstocks { + total += ns.Qty * ns.Price + } + return total +} diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 50646ed6..3e50da26 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -211,6 +211,7 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen if !hasKandang && req.Category == string(utils.ExpenseCategoryBOP) { projectFlockRepoTx := projectFlockKandangRepo.NewProjectflockRepository(dbTransaction) activeProjectFlocks, err := projectFlockRepoTx.GetActiveByLocationID(c.Context(), req.LocationID) + if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get active project flocks for location") } From a08466a28ea0fbc7d5ffba2d4d304f3027157e4d Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 6 Jan 2026 12:43:52 +0700 Subject: [PATCH 10/14] FIX(BE): update foreign key constraints to use ON DELETE NO ACTION for expense and marketing tables --- ...13501_alter_expense_fk_to_cascade.down.sql | 15 ++++++++ ...6113501_alter_expense_fk_to_cascade.up.sql | 16 +++++++++ ...13502_ensure_marketing_fk_cascade.down.sql | 20 +++++++++++ ...6113502_ensure_marketing_fk_cascade.up.sql | 35 +++++++++++++++++++ 4 files changed, 86 insertions(+) create mode 100644 internal/database/migrations/20260106113501_alter_expense_fk_to_cascade.down.sql create mode 100644 internal/database/migrations/20260106113501_alter_expense_fk_to_cascade.up.sql create mode 100644 internal/database/migrations/20260106113502_ensure_marketing_fk_cascade.down.sql create mode 100644 internal/database/migrations/20260106113502_ensure_marketing_fk_cascade.up.sql diff --git a/internal/database/migrations/20260106113501_alter_expense_fk_to_cascade.down.sql b/internal/database/migrations/20260106113501_alter_expense_fk_to_cascade.down.sql new file mode 100644 index 00000000..2a212b3b --- /dev/null +++ b/internal/database/migrations/20260106113501_alter_expense_fk_to_cascade.down.sql @@ -0,0 +1,15 @@ +-- Revert back to NO ACTION (RESTRICT behavior) +ALTER TABLE expense_nonstocks DROP CONSTRAINT IF EXISTS fk_expense_nonstocks_expense_id; + +ALTER TABLE expense_nonstocks + ADD CONSTRAINT fk_expense_nonstocks_expense_id + FOREIGN KEY (expense_id) REFERENCES expenses(id) + ON DELETE NO ACTION; + +-- Revert expense_realizations FK +ALTER TABLE expense_realizations DROP CONSTRAINT IF EXISTS fk_expense_realizations_nonstock_id; + +ALTER TABLE expense_realizations + ADD CONSTRAINT fk_expense_realizations_nonstock_id + FOREIGN KEY (expense_nonstock_id) REFERENCES expense_nonstocks(id) + ON DELETE NO ACTION; diff --git a/internal/database/migrations/20260106113501_alter_expense_fk_to_cascade.up.sql b/internal/database/migrations/20260106113501_alter_expense_fk_to_cascade.up.sql new file mode 100644 index 00000000..6567c5d2 --- /dev/null +++ b/internal/database/migrations/20260106113501_alter_expense_fk_to_cascade.up.sql @@ -0,0 +1,16 @@ +-- Drop existing FK constraints +ALTER TABLE expense_nonstocks DROP CONSTRAINT IF EXISTS fk_expense_nonstocks_expense_id; + +-- Recreate with ON DELETE CASCADE +ALTER TABLE expense_nonstocks + ADD CONSTRAINT fk_expense_nonstocks_expense_id + FOREIGN KEY (expense_id) REFERENCES expenses(id) + ON DELETE CASCADE; + +-- Drop and recreate expense_realizations FK +ALTER TABLE expense_realizations DROP CONSTRAINT IF EXISTS fk_expense_realizations_nonstock_id; + +ALTER TABLE expense_realizations + ADD CONSTRAINT fk_expense_realizations_nonstock_id + FOREIGN KEY (expense_nonstock_id) REFERENCES expense_nonstocks(id) + ON DELETE CASCADE; diff --git a/internal/database/migrations/20260106113502_ensure_marketing_fk_cascade.down.sql b/internal/database/migrations/20260106113502_ensure_marketing_fk_cascade.down.sql new file mode 100644 index 00000000..91ef5903 --- /dev/null +++ b/internal/database/migrations/20260106113502_ensure_marketing_fk_cascade.down.sql @@ -0,0 +1,20 @@ +-- Revert back to NO ACTION (for rollback safety) +DO $$ +BEGIN + ALTER TABLE marketing_products DROP CONSTRAINT IF EXISTS fk_marketing_products_marketing_id; + + ALTER TABLE marketing_products + ADD CONSTRAINT fk_marketing_products_marketing_id + FOREIGN KEY (marketing_id) REFERENCES marketings(id) + ON DELETE NO ACTION; +END $$; + +DO $$ +BEGIN + ALTER TABLE marketing_delivery_products DROP CONSTRAINT IF EXISTS fk_marketing_delivery_products_marketing_product_id; + + ALTER TABLE marketing_delivery_products + ADD CONSTRAINT fk_marketing_delivery_products_marketing_product_id + FOREIGN KEY (marketing_product_id) REFERENCES marketing_products(id) + ON DELETE NO ACTION; +END $$; diff --git a/internal/database/migrations/20260106113502_ensure_marketing_fk_cascade.up.sql b/internal/database/migrations/20260106113502_ensure_marketing_fk_cascade.up.sql new file mode 100644 index 00000000..801c841d --- /dev/null +++ b/internal/database/migrations/20260106113502_ensure_marketing_fk_cascade.up.sql @@ -0,0 +1,35 @@ +-- Ensure marketing_products FK is CASCADE (it should already be, but let's make sure) +DO $$ +BEGIN + -- Drop existing FK if exists + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_marketing_products_marketing_id' + ) THEN + ALTER TABLE marketing_products DROP CONSTRAINT fk_marketing_products_marketing_id; + END IF; + + -- Recreate with ON DELETE CASCADE + ALTER TABLE marketing_products + ADD CONSTRAINT fk_marketing_products_marketing_id + FOREIGN KEY (marketing_id) REFERENCES marketings(id) + ON DELETE CASCADE; +END $$; + +-- Ensure marketing_delivery_products FK is CASCADE +DO $$ +BEGIN + -- Drop existing FK if exists + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_marketing_delivery_products_marketing_product_id' + ) THEN + ALTER TABLE marketing_delivery_products DROP CONSTRAINT fk_marketing_delivery_products_marketing_product_id; + END IF; + + -- Recreate with ON DELETE CASCADE + ALTER TABLE marketing_delivery_products + ADD CONSTRAINT fk_marketing_delivery_products_marketing_product_id + FOREIGN KEY (marketing_product_id) REFERENCES marketing_products(id) + ON DELETE CASCADE; +END $$; From 0a84e427c1693aec10ed39b62727551d403173dd Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 7 Jan 2026 09:27:39 +0700 Subject: [PATCH 11/14] FIX[BE]: fixing bug transfer to laying, delet biaya, nominal expesen e, chickin --- ...uct_warehouse_to_laying_transfers.down.sql | 9 ++ ...oduct_warehouse_to_laying_transfers.up.sql | 19 +++ ...ckable_fields_to_laying_transfers.down.sql | 14 ++ ...tockable_fields_to_laying_transfers.up.sql | 30 ++++ internal/entities/laying_transfer.go | 23 ++- .../modules/inventory/adjustments/module.go | 4 +- .../services/adjustment.service.go | 20 +-- .../modules/inventory/transfers/module.go | 7 +- .../transfers/services/transfer.service.go | 11 +- .../modules/production/chickins/module.go | 18 +++ .../project_chickin.repository.go | 11 ++ .../chickins/services/chickin.service.go | 143 ++++++++++++++---- .../services/project_flock_kandang.service.go | 25 ++- .../project_flock_population_repository.go | 17 ++- .../projectflock_kandang.repository.go | 14 ++ .../production/transfer_layings/module.go | 41 +++++ .../services/transfer_laying.service.go | 126 ++++++++------- internal/modules/purchases/module.go | 2 +- .../purchases/services/purchase.service.go | 5 +- .../hpp_per_kandang.repository.go | 2 +- internal/utils/fifo/constants.go | 17 ++- 21 files changed, 432 insertions(+), 126 deletions(-) create mode 100644 internal/database/migrations/20260106113503_add_product_warehouse_to_laying_transfers.down.sql create mode 100644 internal/database/migrations/20260106113503_add_product_warehouse_to_laying_transfers.up.sql create mode 100644 internal/database/migrations/20260106140815_add_stockable_fields_to_laying_transfers.down.sql create mode 100644 internal/database/migrations/20260106140815_add_stockable_fields_to_laying_transfers.up.sql diff --git a/internal/database/migrations/20260106113503_add_product_warehouse_to_laying_transfers.down.sql b/internal/database/migrations/20260106113503_add_product_warehouse_to_laying_transfers.down.sql new file mode 100644 index 00000000..af4a6477 --- /dev/null +++ b/internal/database/migrations/20260106113503_add_product_warehouse_to_laying_transfers.down.sql @@ -0,0 +1,9 @@ +-- Drop foreign key and column +ALTER TABLE laying_transfers +DROP CONSTRAINT IF EXISTS fk_laying_transfers_product_warehouse_id; + +ALTER TABLE laying_transfers +DROP COLUMN IF EXISTS product_warehouse_id; + +-- Drop index +DROP INDEX IF EXISTS idx_laying_transfers_product_warehouse_id; diff --git a/internal/database/migrations/20260106113503_add_product_warehouse_to_laying_transfers.up.sql b/internal/database/migrations/20260106113503_add_product_warehouse_to_laying_transfers.up.sql new file mode 100644 index 00000000..7e417ff6 --- /dev/null +++ b/internal/database/migrations/20260106113503_add_product_warehouse_to_laying_transfers.up.sql @@ -0,0 +1,19 @@ +-- Add product_warehouse_id to laying_transfers for FIFO support +ALTER TABLE laying_transfers +ADD COLUMN product_warehouse_id BIGINT; + +-- Add foreign key +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'product_warehouses') THEN + ALTER TABLE laying_transfers + ADD CONSTRAINT fk_laying_transfers_product_warehouse_id + FOREIGN KEY (product_warehouse_id) + REFERENCES product_warehouses(id) + ON DELETE SET NULL; + END IF; +END $$; + +-- Add index +CREATE INDEX idx_laying_transfers_product_warehouse_id +ON laying_transfers(product_warehouse_id); diff --git a/internal/database/migrations/20260106140815_add_stockable_fields_to_laying_transfers.down.sql b/internal/database/migrations/20260106140815_add_stockable_fields_to_laying_transfers.down.sql new file mode 100644 index 00000000..391731f2 --- /dev/null +++ b/internal/database/migrations/20260106140815_add_stockable_fields_to_laying_transfers.down.sql @@ -0,0 +1,14 @@ +-- Rollback: Remove STOCKABLE fields from laying_transfers + +-- Drop index +DROP INDEX IF EXISTS idx_laying_transfers_dest_product_warehouse_id; + +-- Drop foreign key constraint +ALTER TABLE laying_transfers +DROP CONSTRAINT IF EXISTS fk_laying_transfers_dest_product_warehouse_id; + +-- Drop columns +ALTER TABLE laying_transfers +DROP COLUMN IF EXISTS dest_product_warehouse_id, +DROP COLUMN IF EXISTS total_qty, +DROP COLUMN IF EXISTS total_used; diff --git a/internal/database/migrations/20260106140815_add_stockable_fields_to_laying_transfers.up.sql b/internal/database/migrations/20260106140815_add_stockable_fields_to_laying_transfers.up.sql new file mode 100644 index 00000000..7a4ce8a6 --- /dev/null +++ b/internal/database/migrations/20260106140815_add_stockable_fields_to_laying_transfers.up.sql @@ -0,0 +1,30 @@ +-- Add STOCKABLE fields to laying_transfers for destination warehouse +-- This enables Transfer to Laying to work as DUAL ROLE (Stockable + Usable) + +-- Add columns for STOCKABLE role (destination warehouse) +ALTER TABLE laying_transfers +ADD COLUMN dest_product_warehouse_id BIGINT, +ADD COLUMN total_qty NUMERIC(15, 3) DEFAULT 0, +ADD COLUMN total_used NUMERIC(15, 3) DEFAULT 0; + +-- Add foreign key constraint +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'product_warehouses') THEN + ALTER TABLE laying_transfers + ADD CONSTRAINT fk_laying_transfers_dest_product_warehouse_id + FOREIGN KEY (dest_product_warehouse_id) + REFERENCES product_warehouses(id) + ON DELETE SET NULL; + END IF; +END $$; + +-- Add index for performance +CREATE INDEX idx_laying_transfers_dest_product_warehouse_id +ON laying_transfers(dest_product_warehouse_id); + +-- Add comment for documentation +COMMENT ON COLUMN laying_transfers.product_warehouse_id IS 'Product warehouse at source (Growing flock) - for USABLE role'; +COMMENT ON COLUMN laying_transfers.dest_product_warehouse_id IS 'Product warehouse at destination (Laying flock) - for STOCKABLE role'; +COMMENT ON COLUMN laying_transfers.total_qty IS 'Total lot quantity introduced to destination warehouse - for STOCKABLE role'; +COMMENT ON COLUMN laying_transfers.total_used IS 'Quantity already consumed from this lot at destination - for STOCKABLE role'; diff --git a/internal/entities/laying_transfer.go b/internal/entities/laying_transfer.go index dd173042..97a7df12 100644 --- a/internal/entities/laying_transfer.go +++ b/internal/entities/laying_transfer.go @@ -12,18 +12,29 @@ type LayingTransfer struct { FromProjectFlockId uint `gorm:"not null"` ToProjectFlockId uint `gorm:"not null"` TransferDate time.Time `gorm:"type:date;not null"` + + PendingUsageQty *float64 `gorm:"type:numeric(15,3)"` UsageQty *float64 `gorm:"type:numeric(15,3)"` + ProductWarehouseId *uint `gorm:"type:bigint"` // Source PW (PULLET) + + + DestProductWarehouseID *uint `gorm:"column:dest_product_warehouse_id;type:bigint"` // Destination PW (LAYER) + TotalQty float64 `gorm:"column:total_qty;type:numeric(15,3);default:0"` // Total lot introduced to destination + TotalUsed float64 `gorm:"column:total_used;type:numeric(15,3);default:0"` // Already consumed from this lot + 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"` + ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` // Source PW + DestProductWarehouse *ProductWarehouse `gorm:"foreignKey:DestProductWarehouseID;references:Id"` // Destination PW + 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:"-"` } diff --git a/internal/modules/inventory/adjustments/module.go b/internal/modules/inventory/adjustments/module.go index 08e556ea..6b137902 100644 --- a/internal/modules/inventory/adjustments/module.go +++ b/internal/modules/inventory/adjustments/module.go @@ -36,7 +36,7 @@ func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) err := fifoService.RegisterStockable(fifo.StockableConfig{ - Key: fifo.StockableKey("ADJUSTMENT_IN"), + Key: fifo.StockableKeyAdjustmentIn, Table: "adjustment_stocks", Columns: fifo.StockableColumns{ ID: "id", @@ -52,7 +52,7 @@ func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat } err = fifoService.RegisterUsable(fifo.UsableConfig{ - Key: fifo.UsableKey("ADJUSTMENT_OUT"), + Key: fifo.UsableKeyAdjustmentOut, Table: "adjustment_stocks", Columns: fifo.UsableColumns{ ID: "id", diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 47d41648..71b985c2 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -20,6 +20,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" ) @@ -123,15 +124,9 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e projectFlockKandangID = &pfkID } - pw, err := s.ProductWarehouseRepo.FindByProductWarehouseAndPfk( - ctx, - uint(req.ProductID), - uint(req.WarehouseID), - projectFlockKandangID, - ) + pw, err := s.ProductWarehouseRepo.FindByProductWarehouseAndPfk(ctx, uint(req.ProductID), uint(req.WarehouseID), projectFlockKandangID) if err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { - s.Log.Errorf("Failed to find product warehouse: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") } @@ -143,7 +138,6 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e } if err := s.ProductWarehouseRepo.CreateOne(ctx, newPW, nil); err != nil { - s.Log.Errorf("Failed to create product warehouse: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create product warehouse") } pw = newPW @@ -163,7 +157,6 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") } - // Create StockLog for history tracking afterQuantity := productWarehouse.Quantity newLog := &entity.StockLog{ LoggableType: string(utils.StockLogTypeAdjustment), @@ -189,7 +182,6 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return err } - // Create AdjustmentStock record for FIFO tracking adjustmentStock := &entity.AdjustmentStock{ StockLogId: newLog.Id, ProductWarehouseId: productWarehouse.Id, @@ -200,10 +192,10 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e } if transactionType == string(utils.StockLogTransactionTypeIncrease) { - // Adjustment INCREASE → Replenish stock (Stockable) + note := fmt.Sprintf("Stock Adjustment IN #%d", newLog.Id) _, err := s.FifoSvc.Replenish(ctx, common.StockReplenishRequest{ - StockableKey: "ADJUSTMENT_IN", + StockableKey: fifo.StockableKeyAdjustmentIn, StockableID: adjustmentStock.Id, ProductWarehouseID: uint(productWarehouse.Id), Quantity: req.Quantity, @@ -215,9 +207,8 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e } } else { - // Adjustment DECREASE → Consume stock (Usable) _, err := s.FifoSvc.Consume(ctx, common.StockConsumeRequest{ - UsableKey: "ADJUSTMENT_OUT", + UsableKey: fifo.UsableKeyAdjustmentOut, UsableID: adjustmentStock.Id, ProductWarehouseID: uint(productWarehouse.Id), Quantity: req.Quantity, @@ -230,6 +221,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e } // Update ProductWarehouse quantity (for backward compatibility/reporting) + productWarehouse.Quantity = afterQuantity if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(ctx, productWarehouse.Id, productWarehouse, nil); err != nil { s.Log.Errorf("Failed to update product warehouse quantity: %+v", err) diff --git a/internal/modules/inventory/transfers/module.go b/internal/modules/inventory/transfers/module.go index 60d1764a..bbb3c4aa 100644 --- a/internal/modules/inventory/transfers/module.go +++ b/internal/modules/inventory/transfers/module.go @@ -43,12 +43,10 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate panic(err) } - // Initialize FIFO Service fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) - // Register Transfer as Stockable (adds stock to destination warehouse) err = fifoService.RegisterStockable(fifo.StockableConfig{ - Key: fifo.StockableKey("STOCK_TRANSFER_IN"), + Key: fifo.StockableKeyStockTransferIn, Table: "stock_transfer_details", Columns: fifo.StockableColumns{ ID: "id", @@ -63,9 +61,8 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate panic(err) } - // Register Transfer as Usable (consumes stock from source warehouse) err = fifoService.RegisterUsable(fifo.UsableConfig{ - Key: fifo.UsableKey("STOCK_TRANSFER_OUT"), + Key: fifo.UsableKeyStockTransferOut, Table: "stock_transfer_details", Columns: fifo.UsableColumns{ ID: "id", diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 1ca35a71..afbb4627 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -21,6 +21,7 @@ import ( projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" 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" "gorm.io/gorm" ) @@ -337,24 +338,21 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } } - // Execute FIFO operations for each product for _, product := range req.Products { detail := detailMap[uint64(product.ProductID)] - // Step 1: Consume stock from source warehouse (STOCK_TRANSFER_OUT) consumeResult, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{ - UsableKey: "STOCK_TRANSFER_OUT", + UsableKey: fifo.UsableKeyStockTransferOut, UsableID: uint(detail.Id), ProductWarehouseID: uint(*detail.SourceProductWarehouseID), Quantity: product.ProductQty, - AllowPending: false, // Don't allow pending, must have actual stock + AllowPending: false, Tx: tx, }) if err != nil { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak cukup di gudang asal untuk produk %d: %v", product.ProductID, err)) } - // Update usage tracking fields for source warehouse if err := tx.Model(&entity.StockTransferDetail{}). Where("id = ?", detail.Id). Updates(map[string]interface{}{ @@ -367,7 +365,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques // Step 2: Replenish stock to destination warehouse (STOCK_TRANSFER_IN) note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber) replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ - StockableKey: "STOCK_TRANSFER_IN", + StockableKey: fifo.StockableKeyStockTransferIn, StockableID: uint(detail.Id), ProductWarehouseID: uint(*detail.DestProductWarehouseID), Quantity: product.ProductQty, @@ -378,7 +376,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal menambah stok di gudang tujuan untuk produk %d: %v", product.ProductID, err)) } - // Update total tracking fields for destination warehouse if err := tx.Model(&entity.StockTransferDetail{}). Where("id = ?", detail.Id). Updates(map[string]interface{}{ diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go index 6c9b8984..0c7c2a09 100644 --- a/internal/modules/production/chickins/module.go +++ b/internal/modules/production/chickins/module.go @@ -58,6 +58,24 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * } } + + if err := fifoService.RegisterStockable(fifo.StockableConfig{ + Key: fifo.StockableKeyProjectFlockPopulation, + Table: "project_flock_populations", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used_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 project flock population stockable workflow: %v", err)) + } + } + approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowChickin, utils.ChickinApprovalSteps); err != nil { diff --git a/internal/modules/production/chickins/repositories/project_chickin.repository.go b/internal/modules/production/chickins/repositories/project_chickin.repository.go index f4cbf5e3..7f56a261 100644 --- a/internal/modules/production/chickins/repositories/project_chickin.repository.go +++ b/internal/modules/production/chickins/repositories/project_chickin.repository.go @@ -17,6 +17,7 @@ type ProjectChickinRepository interface { GetTotalPendingUsageQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) GetTotalChickinQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) GetByProjectFlockKandangIDForUpdate(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectChickin, error) + UpdateUsageFields(ctx context.Context, tx *gorm.DB, chickinID uint, usageQty, pendingUsageQty float64) error } type ChickinRepositoryImpl struct { @@ -123,3 +124,13 @@ func (r *ChickinRepositoryImpl) GetTotalChickinQtyByProjectFlockID(ctx context.C Scan(&result).Error return result, err } + +func (r *ChickinRepositoryImpl) UpdateUsageFields(ctx context.Context, tx *gorm.DB, chickinID uint, usageQty, pendingUsageQty float64) error { + return tx.WithContext(ctx). + Model(&entity.ProjectChickin{}). + Where("id = ?", chickinID). + Updates(map[string]interface{}{ + "usage_qty": usageQty, + "pending_usage_qty": pendingUsageQty, + }).Error +} diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 8c896aef..02ae12ec 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -214,9 +214,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti pendingQtyMap[existingChickin.ProductWarehouseId] += existingChickin.PendingUsageQty } } - - // CRITICAL: Validate chickins sequentially to prevent over-allocation within the same request - // pendingQtyMap is accumulated as we validate each chickin to ensure total pending doesn't exceed available stock for idx, chickin := range newChikins { pendingQty := pendingQtyMap[chickin.ProductWarehouseId] desiredQty := chickinQtyMap[uint(idx)] @@ -232,8 +229,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti chickinQtyMap[uint(idx)] = availableQty - // ACCUMULATE pending for this product warehouse so NEXT chickin in same request sees it - // This prevents multiple chickins in same request from over-allocating the same stock pendingQtyMap[chickin.ProductWarehouseId] += availableQty } @@ -358,12 +353,15 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { } if chickin.UsageQty > 0 { + + currentUsageQty := chickin.UsageQty + if err := s.ReleaseChickinStocks(c.Context(), s.Repository.DB(), chickin, actorID); err != nil { return err } warehouseDeltas := make(map[uint]float64) - warehouseDeltas[chickin.ProductWarehouseId] += chickin.UsageQty + warehouseDeltas[chickin.ProductWarehouseId] += currentUsageQty if err := s.adjustProductWarehouseQuantities(c.Context(), s.Repository.DB(), warehouseDeltas); err != nil { s.Log.Errorf("Failed to adjust product warehouses for deleted chickin %d: %+v", chickin.Id, err) return err @@ -618,7 +616,7 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti population := &entity.ProjectFlockPopulation{ ProjectChickinId: chickin.Id, ProductWarehouseId: targetPW.Id, - TotalQty: quantityToConvert, + TotalQty: 0, // Will be set by FIFO Replenish TotalUsedQty: 0, Notes: chickin.Notes, CreatedBy: actorID, @@ -634,15 +632,22 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti return fmt.Errorf("failed to reset pending usage qty for chickin %d: %w", chickin.Id, err) } + // Replenish stock to target ProductWarehouse based on source flag + // StockableKey is PROJECT_CHICKIN but StockableID refers to Population ID + if err := s.ReplenishChickinStocks(ctx.Context(), dbTransaction, &chickin, targetPW, population, actorID); err != nil { + s.Log.Errorf("Failed to replenish stock for chickin %d: %+v", chickin.Id, err) + return err + } + totalQuantityAdded += quantityToConvert } - // NOTE: Tidak menambah target ProductWarehouse quantity karena: - // 1. Ayam sudah dipakai (masuk population) - // 2. ProductWarehouse source sudah berkurang saat create chickin (ConsumeChickinStocks) - // 3. Menambah quantity disini akan menyebabkan double count - // - // PULLET/LAYER untuk flock ini akan di-add lewat mekanisme lain (misal: purchase, transfer, dll) + // NOTE: ProductWarehouse target sudah ditambah melalui ReplenishChickinStocks + // yang dipanggil di atas untuk setiap chickin berdasarkan flag source: + // - DOC → replenish ke PULLET + // - PULLET → replenish ke LAYER + // - LAYER → tidak perlu replenish (sudah final) + // - DOC+PULLET+LAYER → replenish ke dirinya sendiri return nil } @@ -671,10 +676,7 @@ func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, s.Log.Infof("ConsumeChickinStocks result: usage_qty=%.3f, pending_qty=%.3f, allocated_allocations=%d", result.UsageQuantity, result.PendingQuantity, len(result.AddedAllocations)) - if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Updates(map[string]interface{}{ - "usage_qty": result.UsageQuantity, - "pending_usage_qty": result.PendingQuantity, - }).Error; err != nil { + if err := s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, result.UsageQuantity, result.PendingQuantity); err != nil { return err } @@ -696,6 +698,101 @@ func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, return nil } +func (s *chickinService) ReplenishChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, targetPW *entity.ProductWarehouse, population *entity.ProjectFlockPopulation, actorID uint) error { + if chickin == nil || targetPW == nil || population == nil || s.FifoSvc == nil { + return nil + } + + sourcePW, err := s.ProductWarehouseRepo.GetByID(ctx, chickin.ProductWarehouseId, func(db *gorm.DB) *gorm.DB { + return db.Preload("Product.Flags") + }) + if err != nil { + + return err + } + if sourcePW == nil || sourcePW.Product.Id == 0 { + return fmt.Errorf("source product warehouse or product not found for chickin %d", chickin.Id) + } + + sourceFlags := sourcePW.Product.Flags + if len(sourceFlags) == 0 { + s.Log.Warnf("Source product %d has no flags, skipping replenish for chickin %d", sourcePW.Product.Id, chickin.Id) + return nil + } + + hasDoc := false + hasPullet := false + hasLayer := false + for _, flag := range sourceFlags { + flagName := utils.FlagType(flag.Name) + if flagName == utils.FlagDOC { + hasDoc = true + } else if flagName == utils.FlagPullet { + hasPullet = true + } else if flagName == utils.FlagLayer { + hasLayer = true + } + } + + if hasDoc && hasPullet && hasLayer { + s.Log.Infof("Chickin %d has mixed flags (DOC+PULLET+LAYER), replenishing to source PW %d", chickin.Id, sourcePW.Id) + _, err = s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: fifo.StockableKeyProjectFlockPopulation, + StockableID: population.Id, + ProductWarehouseID: sourcePW.Id, + Quantity: chickin.UsageQty, + Tx: tx, + }) + if err != nil { + s.Log.Errorf("Failed to replenish stock to source PW for chickin %d: %+v", chickin.Id, err) + return err + } + return nil + } + + // LAYER only - no replenish needed + if hasLayer && !hasDoc && !hasPullet { + s.Log.Infof("Chickin %d has LAYER flag only, skipping replenish", chickin.Id) + return nil + } + + if hasDoc && !hasPullet && !hasLayer { + s.Log.Infof("Chickin %d has DOC flag, replenishing to PULLET PW %d", chickin.Id, targetPW.Id) + _, err = s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: fifo.StockableKeyProjectFlockPopulation, + StockableID: population.Id, + ProductWarehouseID: targetPW.Id, + Quantity: chickin.UsageQty, + Tx: tx, + }) + if err != nil { + s.Log.Errorf("Failed to replenish stock to PULLET PW for chickin %d: %+v", chickin.Id, err) + return err + } + return nil + } + + if hasPullet && !hasDoc && !hasLayer { + s.Log.Infof("Chickin %d has PULLET flag, replenishing to LAYER PW %d", chickin.Id, targetPW.Id) + _, err = s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: fifo.StockableKeyProjectFlockPopulation, + StockableID: population.Id, + ProductWarehouseID: targetPW.Id, + Quantity: chickin.UsageQty, + Tx: tx, + }) + if err != nil { + s.Log.Errorf("Failed to replenish stock to LAYER PW for chickin %d: %+v", chickin.Id, err) + return err + } + return nil + } + + // Other combinations (e.g., DOC + PULLET without LAYER) - skip for now + s.Log.Warnf("Chickin %d has unsupported flag combination, skipping replenish", chickin.Id) + return nil +} + func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, actorID uint) error { if chickin == nil || s.FifoSvc == nil { return nil @@ -703,8 +800,7 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, var currentUsage float64 if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Select("usage_qty").Scan(¤tUsage).Error; err != nil { - s.Log.Warnf("Failed to get current usage for chickin %d: %+v", chickin.Id, err) - currentUsage = 0 + } if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ @@ -716,14 +812,10 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, return err } - if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Updates(map[string]interface{}{ - "usage_qty": 0, - "pending_usage_qty": 0, - }).Error; err != nil { + if err := s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, 0, 0); err != nil { return err } - // Create stock log for the restoration if currentUsage > 0 { increaseLog := &entity.StockLog{ Increase: currentUsage, @@ -734,8 +826,7 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, Notes: fmt.Sprintf("Chickin #%d - Stock released", chickin.Id), } if err := s.StockLogRepo.CreateOne(ctx, increaseLog, nil); err != nil { - s.Log.Errorf("Failed to create stock log for chickin %d: %+v", chickin.Id, err) - // Don't return error here, stock already released + s.Log.Errorf("Failed to create stock log for released chickin %d: %+v", chickin.Id, err) } } diff --git a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go index 6176daeb..92ff2748 100644 --- a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go +++ b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go @@ -552,6 +552,7 @@ func (s projectFlockKandangService) Closing(c *fiber.Ctx, id uint, req *validati } func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehouse(c *fiber.Ctx, projectFlockKandang *entity.ProjectFlockKandang, productWarehouse *entity.ProductWarehouse) (float64, error) { + availableQty := productWarehouse.Quantity if projectFlockKandang.ProjectFlock.Category == string(utils.ProjectFlockCategoryGrowing) { @@ -564,7 +565,17 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous } } - availableQty = productWarehouse.Quantity - totalPendingQty + totalPopulationQty := 0.0 + if s.PopulationRepo != nil { + popQty, err := s.PopulationRepo.GetTotalQtyByProductWarehouseID(c.Context(), productWarehouse.Id) + if err != nil { + s.Log.Errorf("Failed to get population qty for PW %d: %+v", productWarehouse.Id, err) + } else { + totalPopulationQty = popQty + } + } + + availableQty = productWarehouse.Quantity - totalPendingQty - totalPopulationQty if availableQty < 0 { availableQty = 0 } @@ -578,7 +589,17 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous } } - availableQty = productWarehouse.Quantity - totalPendingQty + totalPopulationQty := 0.0 + if s.PopulationRepo != nil { + popQty, err := s.PopulationRepo.GetTotalQtyByProductWarehouseID(c.Context(), productWarehouse.Id) + if err != nil { + s.Log.Errorf("Failed to get population qty for PW %d: %+v", productWarehouse.Id, err) + } else { + totalPopulationQty = popQty + } + } + + availableQty = productWarehouse.Quantity - totalPendingQty - totalPopulationQty if availableQty < 0 { availableQty = 0 } diff --git a/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go b/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go index fd263b27..04ae56e1 100644 --- a/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go +++ b/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go @@ -9,19 +9,17 @@ import ( ) type ProjectFlockPopulationRepository interface { - // domain-specific GetByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectFlockPopulation, error) ExistsByProjectChickinID(ctx context.Context, projectChickinID uint) (bool, error) GetByProjectChickinIDAndProductWarehouseID(ctx context.Context, projectChickinID uint, productWarehouseID uint) ([]entity.ProjectFlockPopulation, error) GetByProjectFlockKandangIDAndProductWarehouseID(ctx context.Context, projectFlockKandangID uint, productWarehouseID uint) ([]entity.ProjectFlockPopulation, error) GetTotalQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) + GetTotalQtyByProductWarehouseID(ctx context.Context, productWarehouseID uint) (float64, error) GetAvailableQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) - // subset of base repository methods used by services CreateOne(ctx context.Context, entity *entity.ProjectFlockPopulation, modifier func(*gorm.DB) *gorm.DB) error PatchOne(ctx context.Context, id uint, updates map[string]any, modifier func(*gorm.DB) *gorm.DB) error - // transaction helpers WithTx(tx *gorm.DB) ProjectFlockPopulationRepository DB() *gorm.DB } @@ -108,6 +106,19 @@ func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProjectFlockKandangI return total, nil } +func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProductWarehouseID(ctx context.Context, productWarehouseID uint) (float64, error) { + var total float64 + err := r.DB().WithContext(ctx). + Model(&entity.ProjectFlockPopulation{}). + Where("product_warehouse_id = ?", productWarehouseID). + Select("COALESCE(SUM(total_qty), 0)"). + Scan(&total).Error + if err != nil { + return 0, err + } + return total, nil +} + func (r *projectFlockPopulationRepositoryImpl) GetAvailableQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) { var total float64 err := r.DB().WithContext(ctx). diff --git a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go index 42dcafd9..474a53c2 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -28,6 +28,7 @@ type ProjectFlockKandangRepository interface { ProjectPeriodsByProjectIDs(ctx context.Context, projectIDs []uint) (map[uint]int, error) HasOpenNewerPeriod(ctx context.Context, kandangID uint, currentPeriod int, excludeID *uint) (bool, error) ListIDsByProjectAndKandang(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error) + GetTotalPendingChickinQtyByProductWarehouseID(ctx context.Context, productWarehouseID uint) (float64, error) WithTx(tx *gorm.DB) ProjectFlockKandangRepository DB() *gorm.DB IdExists(ctx context.Context, id uint) (bool, error) @@ -206,6 +207,19 @@ func (r *projectFlockKandangRepositoryImpl) IdExists(ctx context.Context, id uin return repository.Exists[entity.ProjectFlockKandang](ctx, r.db, id) } +func (r *projectFlockKandangRepositoryImpl) GetTotalPendingChickinQtyByProductWarehouseID(ctx context.Context, productWarehouseID uint) (float64, error) { + var total float64 + err := r.db.WithContext(ctx). + Table("project_chickins"). + Select("COALESCE(SUM(pending_usage_qty), 0)"). + Where("product_warehouse_id = ?", productWarehouseID). + Scan(&total).Error + if err != nil { + return 0, err + } + return total, nil +} + func (r *projectFlockKandangRepositoryImpl) GetByID(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error) { record := new(entity.ProjectFlockKandang) if err := r.db.WithContext(ctx). diff --git a/internal/modules/production/transfer_layings/module.go b/internal/modules/production/transfer_layings/module.go index 27851b71..381f2492 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" @@ -13,6 +14,7 @@ import ( 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" @@ -31,6 +33,44 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val productWarehouseRepo := rInventory.NewProductWarehouseRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) + stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) + fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) + + + if err := fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKeyTransferToLaying, + Table: "laying_transfers", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_usage_qty", + CreatedAt: "created_at", + }, + }); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "already registered") { + panic(fmt.Sprintf("failed to register transfer to laying usable workflow: %v", err)) + } + } + + + if err := fifoService.RegisterStockable(fifo.StockableConfig{ + Key: fifo.StockableKeyTransferToLaying, + Table: "laying_transfers", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "dest_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)) + } + } + approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowTransferToLaying, utils.TransferToLayingApprovalSteps); err != nil { @@ -45,6 +85,7 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val productWarehouseRepo, warehouseRepo, approvalService, + fifoService, validate, ) userService := sUser.NewUserService(userRepo, validate) 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 0bfbc378..28fe9853 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -17,6 +17,7 @@ import ( repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/validations" "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" @@ -45,6 +46,7 @@ type transferLayingService struct { ProductWarehouseRepo rInventory.ProductWarehouseRepository WarehouseRepo rWarehouse.WarehouseRepository ApprovalService commonSvc.ApprovalService + FifoSvc commonSvc.FifoService } func NewTransferLayingService( @@ -55,6 +57,7 @@ func NewTransferLayingService( productWarehouseRepo rInventory.ProductWarehouseRepository, warehouseRepo rWarehouse.WarehouseRepository, approvalService commonSvc.ApprovalService, + fifoSvc commonSvc.FifoService, validate *validator.Validate, ) TransferLayingService { return &transferLayingService{ @@ -67,6 +70,7 @@ func NewTransferLayingService( ProductWarehouseRepo: productWarehouseRepo, WarehouseRepo: warehouseRepo, ApprovalService: approvalService, + FifoSvc: fifoSvc, } } @@ -268,15 +272,20 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) CreatedBy: actorID, } - err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + if len(sourceWarehouseMap) > 0 { + for _, pwID := range sourceWarehouseMap { + createBody.ProductWarehouseId = &pwID + break + } + } - if err := s.Repository.WithTx(dbTransaction).CreateOne(c.Context(), createBody, nil); err != nil { + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + repoTx := s.Repository.WithTx(dbTransaction) + + if err := repoTx.CreateOne(c.Context(), createBody, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create transfer laying record") } - productWarehouseRepoTx := s.ProductWarehouseRepo.WithTx(dbTransaction) - projectFlockPopulationRepoTx := s.ProjectFlockPopulationRepo.WithTx(dbTransaction) - for _, sourceDetail := range req.SourceKandangs { productWarehouseId := sourceWarehouseMap[sourceDetail.ProjectFlockKandangId] @@ -290,13 +299,6 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) return fiber.NewError(fiber.StatusInternalServerError, "Failed to create transfer source") } - if err := s.reduceProjectFlockPopulation(c.Context(), projectFlockPopulationRepoTx, sourceDetail.ProjectFlockKandangId, sourceDetail.Quantity); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to reduce project flock population") - } - - if err := productWarehouseRepoTx.PatchOne(c.Context(), productWarehouseId, map[string]any{"qty": gorm.Expr("qty - ?", sourceDetail.Quantity)}, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update source warehouse quantity") - } } for _, targetDetail := range req.TargetKandangs { @@ -325,6 +327,22 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) } } + // Set DestProductWarehouseID untuk STOCKABLE role (ambil dari target pertama) + if len(req.TargetKandangs) > 0 { + firstTargetPWID := req.TargetKandangs[0].ProjectFlockKandangId + // Cari ProductWarehouse untuk target kandang + targetWarehouse, _ := s.WarehouseRepo.GetLatestByKandangID(c.Context(), firstTargetPWID) + if targetWarehouse != nil { + // Query ProductWarehouse by warehouse and kandang + var targetPW entity.ProductWarehouse + err := dbTransaction.Where("warehouse_id = ? AND project_flock_kandang_id = ?", targetWarehouse.Id, firstTargetPWID). + First(&targetPW).Error + if err == nil { + createBody.DestProductWarehouseID = &targetPW.Id + } + } + } + if err := createApprovalTransferLaying(c.Context(), dbTransaction, createBody.Id, createBody.CreatedBy); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create transfer approval") } @@ -339,7 +357,7 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) return s.GetOne(c, createBody.Id) } -func (s transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.LayingTransfer, error) { +func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.LayingTransfer, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } @@ -381,6 +399,7 @@ func (s transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, i } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + repoTx := s.Repository.WithTx(dbTransaction) projectFlockPopulationRepoTx := s.ProjectFlockPopulationRepo.WithTx(dbTransaction) productWarehouseRepoTx := s.ProductWarehouseRepo.WithTx(dbTransaction) @@ -416,7 +435,7 @@ func (s transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, i totalSourceQty += source.Quantity } - if err := s.Repository.WithTx(dbTransaction).PatchOne(c.Context(), id, map[string]any{ + if err := repoTx.PatchOne(c.Context(), id, map[string]any{ "transfer_date": transferDate, "notes": req.Reason, "pending_usage_qty": &totalSourceQty, @@ -531,8 +550,9 @@ func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error { } } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { - + repoTx := s.Repository.WithTx(dbTransaction) productWarehouseRepoTx := s.ProductWarehouseRepo.WithTx(dbTransaction) + projectFlockPopulationRepoTx := s.ProjectFlockPopulationRepo.WithTx(dbTransaction) sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction) sources, err := sourceRepoTx.GetByLayingTransferId(c.Context(), id) @@ -551,7 +571,6 @@ func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error { } } - projectFlockPopulationRepoTx := s.ProjectFlockPopulationRepo.WithTx(dbTransaction) for _, source := range sources { populations, err := projectFlockPopulationRepoTx.GetByProjectFlockKandangID(c.Context(), source.SourceProjectFlockKandangId) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { @@ -575,7 +594,7 @@ func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error { } } - if err := s.Repository.WithTx(dbTransaction).DeleteOne(c.Context(), id); err != nil { + if err := repoTx.DeleteOne(c.Context(), id); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete transfer laying") } @@ -624,14 +643,13 @@ 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) - productWarehouseRepoTx := s.ProductWarehouseRepo.WithTx(dbTransaction) for _, approvableID := range approvableIDs { - transfer, err := s.Repository.GetByID(c.Context(), approvableID, nil) + transfer, 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)) @@ -664,44 +682,45 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( } if len(sources) > 0 && len(targets) > 0 { - firstSource := sources[0] - if firstSource.ProductWarehouseId == nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Source product warehouse not found for transfer %d", approvableID)) - } - sourceWarehouse, err := productWarehouseRepoTx.GetByID(c.Context(), *firstSource.ProductWarehouseId, nil) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get source warehouse") - } - - for _, target := range targets { - - targetPFK, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), target.TargetProjectFlockKandangId) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - continue - } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get target project flock kandang") + for _, source := range sources { + if source.ProductWarehouseId == nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Source product warehouse not found for transfer %d", approvableID)) } - targetWarehouse, err := s.WarehouseRepo.GetLatestByKandangID(c.Context(), targetPFK.KandangId) + _, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{ + UsableKey: fifo.UsableKeyTransferToLaying, + UsableID: approvableID, + ProductWarehouseID: *source.ProductWarehouseId, + Quantity: source.Qty, + AllowPending: false, + Tx: dbTransaction, + }) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - continue - } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get target warehouse") + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to consume FIFO stock for source %d: %v", source.ProductWarehouseId, err)) + } + } + + if transfer.DestProductWarehouseID != nil { + note := fmt.Sprintf("Transfer to Laying #%s", transfer.TransferNumber) + replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ + StockableKey: fifo.StockableKeyTransferToLaying, + StockableID: approvableID, + ProductWarehouseID: *transfer.DestProductWarehouseID, + Quantity: *transfer.PendingUsageQty, + Note: ¬e, + Tx: dbTransaction, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to replenish stock to destination warehouse: %v", err)) } - if _, err := s.getOrCreateProductWarehouse( - c.Context(), - dbTransaction, - sourceWarehouse.ProductId, - targetWarehouse.Id, - target.Qty, - actorID, - &target.TargetProjectFlockKandangId, // Set flock ID agar bisa di-chickin di target flock - ); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to create or update product warehouse") + if err := dbTransaction.Model(&entity.LayingTransfer{}). + Where("id = ?", approvableID). + Updates(map[string]interface{}{ + "total_qty": replenishResult.AddedQuantity, + }).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update total quantity for transfer") } } } @@ -709,9 +728,10 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( usageQty := *transfer.PendingUsageQty updateData := map[string]any{ "usage_qty": usageQty, + "total_qty": usageQty, // Same as usage_qty for initial transfer "pending_usage_qty": nil, } - if err := s.Repository.WithTx(dbTransaction).PatchOne(c.Context(), approvableID, updateData, nil); err != nil { + if err := repoTx.PatchOne(c.Context(), approvableID, updateData, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update transfer laying status") } } diff --git a/internal/modules/purchases/module.go b/internal/modules/purchases/module.go index 7e80de38..fae714fb 100644 --- a/internal/modules/purchases/module.go +++ b/internal/modules/purchases/module.go @@ -75,7 +75,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) _ = fifoService.RegisterStockable(fifo.StockableConfig{ - Key: fifo.StockableKey("PURCHASE_ITEMS"), + Key: fifo.StockableKeyPurchaseItems, Table: "purchase_items", Columns: fifo.StockableColumns{ ID: "id", diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 68b21d6a..e46788d8 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -42,8 +42,7 @@ type PurchaseService interface { } const ( - priceTolerance = 0.0001 - purchaseStockableKey = fifo.StockableKey("PURCHASE_ITEMS") + priceTolerance = 0.0001 ) type purchaseService struct { @@ -924,7 +923,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation continue } if _, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ - StockableKey: purchaseStockableKey, + StockableKey: fifo.StockableKeyPurchaseItems, StockableID: adj.itemID, ProductWarehouseID: adj.pwID, Quantity: adj.qty, diff --git a/internal/modules/repports/repositories/hpp_per_kandang.repository.go b/internal/modules/repports/repositories/hpp_per_kandang.repository.go index 7e1c8143..6d4185e8 100644 --- a/internal/modules/repports/repositories/hpp_per_kandang.repository.go +++ b/internal/modules/repports/repositories/hpp_per_kandang.repository.go @@ -106,7 +106,7 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, Where("r.deleted_at IS NULL") recordingPfk = applyLocationFilters(recordingPfk, areaIDs, locationIDs, kandangIDs) - purchaseStockableKey := fifo.StockableKey("PURCHASE_ITEMS").String() + purchaseStockableKey := fifo.StockableKeyPurchaseItems.String() transferStockableKey := fifo.StockableKey("STOCK_TRANSFER_DETAILS").String() query := r.db.WithContext(ctx). diff --git a/internal/utils/fifo/constants.go b/internal/utils/fifo/constants.go index ea6f96c0..2f96beaa 100644 --- a/internal/utils/fifo/constants.go +++ b/internal/utils/fifo/constants.go @@ -1,7 +1,18 @@ package fifo const ( - UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" - UsableKeyProjectChickin UsableKey = "PROJECT_CHICKIN" - UsableKeyMarketingDelivery UsableKey = "MARKETING_DELIVERY" + // Usable Keys + UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" + UsableKeyProjectChickin UsableKey = "PROJECT_CHICKIN" + UsableKeyMarketingDelivery UsableKey = "MARKETING_DELIVERY" + UsableKeyTransferToLaying UsableKey = "TRANSFER_TO_LAYING" + UsableKeyStockTransferOut UsableKey = "STOCK_TRANSFER_OUT" + UsableKeyAdjustmentOut UsableKey = "ADJUSTMENT_OUT" + + // Stockable Keys + StockableKeyTransferToLaying StockableKey = "TRANSFER_TO_LAYING" + StockableKeyStockTransferIn StockableKey = "STOCK_TRANSFER_IN" + StockableKeyAdjustmentIn StockableKey = "ADJUSTMENT_IN" + StockableKeyPurchaseItems StockableKey = "PURCHASE_ITEMS" + StockableKeyProjectFlockPopulation StockableKey = "PROJECT_FLOCK_POPULATION" ) From 76d5b6b69a9a024b4aa01bce28479ea21c80ad32 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 7 Jan 2026 13:39:54 +0700 Subject: [PATCH 12/14] feat(BE): enhance ProjectFlockKandang structure and approval fetching methods --- internal/entities/projectflock_kandang.go | 9 +- .../dto/project_flock_kandang.dto.go | 51 +++++------ .../services/project_flock_kandang.service.go | 91 ++++++++++++++----- 3 files changed, 94 insertions(+), 57 deletions(-) diff --git a/internal/entities/projectflock_kandang.go b/internal/entities/projectflock_kandang.go index 0ce4fc25..5fa20404 100644 --- a/internal/entities/projectflock_kandang.go +++ b/internal/entities/projectflock_kandang.go @@ -10,8 +10,9 @@ type ProjectFlockKandang struct { ClosedAt *time.Time `gorm:"index"` CreatedAt time.Time `gorm:"autoCreateTime"` - ProjectFlock ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"` - Kandang Kandang `gorm:"foreignKey:KandangId;references:Id"` - Chickins []ProjectChickin `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` - LatestApproval *Approval `gorm:"-" json:"-"` + ProjectFlock ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"` + Kandang Kandang `gorm:"foreignKey:KandangId;references:Id"` + Chickins []ProjectChickin `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` + LatestProjectFlockApproval *Approval `gorm:"-" json:"-"` + LatestChickinApproval *Approval `gorm:"-" json:"-"` } diff --git a/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go b/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go index 58f04f1b..452cc7b3 100644 --- a/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go +++ b/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go @@ -28,14 +28,14 @@ type ProjectFlockKandangRelationDTO struct { type ProjectFlockDTO struct { projectFlockDTO.ProjectFlockRelationDTO - Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` - Category string `json:"category"` - Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` + Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` + Category string `json:"category"` + Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"` - Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` - CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type ProductWarehouseDTO struct { @@ -51,11 +51,12 @@ type AvailableQtyDTO struct { type ProjectFlockKandangListDTO struct { ProjectFlockKandangRelationDTO - ProjectFlock *ProjectFlockDTO `json:"project_flock,omitempty"` - Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` - CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` - CreatedAt time.Time `json:"created_at"` - Approval *approvalDTO.ApprovalRelationDTO `json:"approval,omitempty"` + ProjectFlock *ProjectFlockDTO `json:"project_flock,omitempty"` + Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + Approval *approvalDTO.ApprovalRelationDTO `json:"approval,omitempty"` + ChickinApproval *approvalDTO.ApprovalRelationDTO `json:"chickin_approval,omitempty"` } type ProjectFlockKandangDetailDTO struct { @@ -105,7 +106,8 @@ func ToProjectFlockKandangDetailDTOWithAvailableQty(e entity.ProjectFlockKandang Kandang: toKandangRelation(e.Kandang), CreatedAt: e.CreatedAt, CreatedUser: toCreatedUserDTO(e.ProjectFlock), - Approval: toApprovalDTO(e), + Approval: toApprovalDTOSelector(e, func(x entity.ProjectFlockKandang) *entity.Approval { return x.LatestProjectFlockApproval }), + ChickinApproval: toApprovalDTOSelector(e, func(x entity.ProjectFlockKandang) *entity.Approval { return x.LatestChickinApproval }), } return ProjectFlockKandangDetailDTO{ @@ -124,9 +126,11 @@ func toKandangRelation(kandang entity.Kandang) *kandangDTO.KandangRelationDTO { return &mapped } -func toApprovalDTO(e entity.ProjectFlockKandang) *approvalDTO.ApprovalRelationDTO { - if e.LatestApproval != nil { - mapped := approvalDTO.ToApprovalDTO(*e.LatestApproval) +func toApprovalDTOSelector( + e entity.ProjectFlockKandang, selector func(entity.ProjectFlockKandang) *entity.Approval) *approvalDTO.ApprovalRelationDTO { + approval := selector(e) + if approval != nil { + mapped := approvalDTO.ToApprovalDTO(*approval) return &mapped } return nil @@ -145,18 +149,11 @@ func ToProjectFlockKandangListDTO(e entity.ProjectFlockKandang) ProjectFlockKand Kandang: toKandangRelation(e.Kandang), CreatedAt: e.CreatedAt, CreatedUser: toCreatedUserDTO(e.ProjectFlock), - Approval: toApprovalDTO(e), + Approval: toApprovalDTOSelector(e, func(x entity.ProjectFlockKandang) *entity.Approval { return x.LatestProjectFlockApproval }), + ChickinApproval: toApprovalDTOSelector(e, func(x entity.ProjectFlockKandang) *entity.Approval { return x.LatestChickinApproval }), } } -func ToProjectFlockKandangListDTOs(e []entity.ProjectFlockKandang) []ProjectFlockKandangListDTO { - result := make([]ProjectFlockKandangListDTO, len(e)) - for i, r := range e { - result[i] = ToProjectFlockKandangListDTO(r) - } - return result -} - func toCreatedUserDTO(pf entity.ProjectFlock) *userDTO.UserRelationDTO { if pf.CreatedUser.Id != 0 { mapped := userDTO.ToUserRelationDTO(pf.CreatedUser) @@ -187,7 +184,6 @@ func toAvailableQtyDTOsFromMap(chickins []entity.ProjectChickin, availableQtyMap return nil } - // First, build map from chickins pwMap := make(map[uint]*entity.ProductWarehouse) for _, chickin := range chickins { if chickin.ProductWarehouse != nil && chickin.ProductWarehouse.Id != 0 { @@ -195,7 +191,6 @@ func toAvailableQtyDTOsFromMap(chickins []entity.ProjectChickin, availableQtyMap } } - // Then, add productWarehouses that are not in chickins yet for i := range productWarehouses { if _, exists := pwMap[productWarehouses[i].Id]; !exists { pwMap[productWarehouses[i].Id] = &productWarehouses[i] @@ -204,7 +199,7 @@ func toAvailableQtyDTOsFromMap(chickins []entity.ProjectChickin, availableQtyMap result := make([]AvailableQtyDTO, 0, len(availableQtyMap)) for pwId, availableQty := range availableQtyMap { - // Skip jika available qty = 0 + if availableQty <= 0 { continue } diff --git a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go index 92ff2748..6f019ffa 100644 --- a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go +++ b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go @@ -98,23 +98,8 @@ func (s projectFlockKandangService) GetAll(c *fiber.Ctx, params *validation.Quer } if s.ApprovalSvc != nil { - projectFlockKandangIDs := make([]uint, len(projectFlockKandangs)) - for i, pfk := range projectFlockKandangs { - projectFlockKandangIDs[i] = pfk.Id - } - - approvalMap, err := s.ApprovalSvc.LatestByTargets(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, projectFlockKandangIDs, func(db *gorm.DB) *gorm.DB { - return db.Preload("ActionUser") - }) - if err != nil { - s.Log.Warnf("Failed to fetch approvals for projectFlockKandangs: %+v", err) - } else { - for i := range projectFlockKandangs { - if approval, ok := approvalMap[projectFlockKandangs[i].Id]; ok { - projectFlockKandangs[i].LatestApproval = approval - } - } - } + s.fetchProjectFlockApprovals(c, projectFlockKandangs) + s.fetchChickinApprovals(c, projectFlockKandangs) } return projectFlockKandangs, total, nil @@ -130,14 +115,8 @@ func (s projectFlockKandangService) GetOne(c *fiber.Ctx, id uint) (*entity.Proje } if len(projectFlockKandang.Chickins) > 0 && s.ApprovalSvc != nil { - latest, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, projectFlockKandang.Id, nil) - if err != nil { - s.Log.Errorf("Failed to fetch latest kandang approval for projectFlockKandang %d: %+v", projectFlockKandang.Id, err) - } - - if latest != nil { - projectFlockKandang.LatestApproval = latest - } + s.fetchProjectFlockApproval(c, projectFlockKandang) + s.fetchChickinApproval(c, projectFlockKandang) } availableQtyMap, err := s.getAvailableQuantities(c, projectFlockKandang) @@ -164,6 +143,68 @@ func (s projectFlockKandangService) GetOne(c *fiber.Ctx, id uint) (*entity.Proje return projectFlockKandang, availableQtyMap, productWarehouses, nil } +func (s projectFlockKandangService) fetchProjectFlockApprovals(c *fiber.Ctx, projectFlockKandangs []entity.ProjectFlockKandang) { + projectFlockKandangIDs := make([]uint, len(projectFlockKandangs)) + for i, pfk := range projectFlockKandangs { + projectFlockKandangIDs[i] = pfk.Id + } + + approvalMap, err := s.ApprovalSvc.LatestByTargets(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, projectFlockKandangIDs, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + s.Log.Warnf("Failed to fetch approvals for projectFlockKandangs: %+v", err) + } else { + for i := range projectFlockKandangs { + if approval, ok := approvalMap[projectFlockKandangs[i].Id]; ok { + projectFlockKandangs[i].LatestProjectFlockApproval = approval + } + } + } +} + +func (s projectFlockKandangService) fetchChickinApprovals(c *fiber.Ctx, projectFlockKandangs []entity.ProjectFlockKandang) { + projectFlockKandangIDs := make([]uint, len(projectFlockKandangs)) + for i, pfk := range projectFlockKandangs { + projectFlockKandangIDs[i] = pfk.Id + } + + chickinApprovalMap, err := s.ApprovalSvc.LatestByTargets(c.Context(), utils.ApprovalWorkflowChickin, projectFlockKandangIDs, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + s.Log.Warnf("Failed to fetch chickin approvals for projectFlockKandangs: %+v", err) + } else { + for i := range projectFlockKandangs { + if approval, ok := chickinApprovalMap[projectFlockKandangs[i].Id]; ok { + projectFlockKandangs[i].LatestChickinApproval = approval + } + } + } +} + +func (s projectFlockKandangService) fetchProjectFlockApproval(c *fiber.Ctx, projectFlockKandang *entity.ProjectFlockKandang) { + latest, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, projectFlockKandang.Id, nil) + if err != nil { + s.Log.Errorf("Failed to fetch latest kandang approval for projectFlockKandang %d: %+v", projectFlockKandang.Id, err) + } + + if latest != nil { + projectFlockKandang.LatestProjectFlockApproval = latest + } +} + +func (s projectFlockKandangService) fetchChickinApproval(c *fiber.Ctx, projectFlockKandang *entity.ProjectFlockKandang) { + latestChickin, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, projectFlockKandang.Id, nil) + if err != nil { + s.Log.Errorf("Failed to fetch latest chickin approval for projectFlockKandang %d: %+v", projectFlockKandang.Id, err) + } + + if latestChickin != nil { + projectFlockKandang.LatestChickinApproval = latestChickin + } +} + func (s projectFlockKandangService) getAvailableQuantities(c *fiber.Ctx, projectFlockKandang *entity.ProjectFlockKandang) (map[uint]float64, error) { if projectFlockKandang.Kandang.Id == 0 || s.WarehouseRepo == nil || s.ProductWarehouseRepo == nil { return nil, nil From 933628957336015073659219a2f74bec9edfe9fc Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 7 Jan 2026 13:54:55 +0700 Subject: [PATCH 13/14] feat(BE): add excluded stockables support in FIFO allocation and fetching methods --- .../common/service/common.fifo.service.go | 31 ++++++++++++++++--- internal/utils/fifo/registry.go | 11 ++++--- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go index 2a65c1b4..b99e6c35 100644 --- a/internal/common/service/common.fifo.service.go +++ b/internal/common/service/common.fifo.service.go @@ -228,7 +228,13 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St switch { case delta > 0: - allocationRes, err := s.allocateFromStock(ctx, tx, productWarehouseID, req.UsableKey, req.UsableID, delta) + + var excludedStockables []fifo.StockableKey + if cfg.ExcludedStockables != nil { + excludedStockables = cfg.ExcludedStockables + } + + allocationRes, err := s.allocateFromStock(ctx, tx, productWarehouseID, req.UsableKey, req.UsableID, delta, excludedStockables) if err != nil { return err } @@ -410,8 +416,9 @@ func (s *fifoService) allocateFromStock( usableKey fifo.UsableKey, usableID uint, requestQty float64, + excludedStockables []fifo.StockableKey, ) (*allocationOutcome, error) { - lots, err := s.fetchStockLots(ctx, tx, productWarehouseID) + lots, err := s.fetchStockLots(ctx, tx, productWarehouseID, excludedStockables) if err != nil { return nil, err } @@ -492,14 +499,24 @@ func (s *fifoService) allocateFromStock( }, nil } -func (s *fifoService) fetchStockLots(ctx context.Context, tx *gorm.DB, productWarehouseID uint) ([]stockLot, error) { +func (s *fifoService) fetchStockLots(ctx context.Context, tx *gorm.DB, productWarehouseID uint, excludedStockables []fifo.StockableKey) ([]stockLot, error) { configs := fifo.Stockables() if len(configs) == 0 { return nil, nil } + // Create exclusion set for faster lookup + excludedSet := make(map[fifo.StockableKey]bool) + for _, key := range excludedStockables { + excludedSet[key] = true + } + var lots []stockLot for key, cfg := range configs { + // Skip excluded stockables + if excludedSet[key] { + continue + } usesNumericTime := cfg.Columns.CreatedAt == cfg.Columns.ID @@ -616,7 +633,13 @@ func (s *fifoService) resolvePendingForWarehouse(ctx context.Context, tx *gorm.D continue } - outcome, err := s.allocateFromStock(ctx, tx, productWarehouseID, candidate.UsableKey, candidate.UsableID, candidate.Pending) + // Get excluded stockables from candidate usable config + var excludedStockables []fifo.StockableKey + if candidate.Config.ExcludedStockables != nil { + excludedStockables = candidate.Config.ExcludedStockables + } + + outcome, err := s.allocateFromStock(ctx, tx, productWarehouseID, candidate.UsableKey, candidate.UsableID, candidate.Pending, excludedStockables) if err != nil { return nil, err } diff --git a/internal/utils/fifo/registry.go b/internal/utils/fifo/registry.go index 61fed294..d9801185 100644 --- a/internal/utils/fifo/registry.go +++ b/internal/utils/fifo/registry.go @@ -54,11 +54,12 @@ type StockableConfig struct { // UsableConfig registers a table that consumes stock (recordings, adjustments, sales, etc). type UsableConfig struct { - Key UsableKey - Table string - Columns UsableColumns - OrderBy []string - Scope QueryScope + Key UsableKey + Table string + Columns UsableColumns + OrderBy []string + Scope QueryScope + ExcludedStockables []StockableKey // Stockables to exclude when consuming stock } var ( From 375e057e7c81d8aa89bc96576dd8ea46778c2030 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 7 Jan 2026 14:02:39 +0700 Subject: [PATCH 14/14] feat(BE): enhance chickin and transfer laying services with product warehouse validation and stockable support --- .../modules/production/chickins/module.go | 3 +- .../chickins/services/chickin.service.go | 41 ++++++++++----- .../services/transfer_laying.service.go | 52 +++++++++++++------ 3 files changed, 68 insertions(+), 28 deletions(-) diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go index 0c7c2a09..143ebad2 100644 --- a/internal/modules/production/chickins/module.go +++ b/internal/modules/production/chickins/module.go @@ -52,13 +52,14 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * PendingQuantity: "pending_usage_qty", CreatedAt: "created_at", }, + + ExcludedStockables: []fifo.StockableKey{fifo.StockableKeyProjectFlockPopulation}, }); err != nil { if !strings.Contains(strings.ToLower(err.Error()), "already registered") { panic(fmt.Sprintf("failed to register chickin usable workflow: %v", err)) } } - if err := fifoService.RegisterStockable(fifo.StockableConfig{ Key: fifo.StockableKeyProjectFlockPopulation, Table: "project_flock_populations", diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 02ae12ec..de49bb1e 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -112,7 +112,6 @@ func (s chickinService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectChickin, e return nil, fiber.NewError(fiber.StatusNotFound, "Chickin not found") } if err != nil { - s.Log.Errorf("Failed get chickin by id: %+v", err) return nil, err } return chickin, nil @@ -143,7 +142,9 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti for idx, chickinReq := range req.ChickinRequests { - productWarehouse, err := s.ProductWarehouseRepo.GetByID(c.Context(), chickinReq.ProductWarehouseId, nil) + productWarehouse, err := s.ProductWarehouseRepo.GetByID(c.Context(), chickinReq.ProductWarehouseId, func(db *gorm.DB) *gorm.DB { + return db.Preload("Product.Flags") + }) if err != nil { return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Product warehouse %d not found", chickinReq.ProductWarehouseId)) } @@ -156,22 +157,27 @@ 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)) } - // CRITICAL: Validate product category based on flock category - // GROWING: Input DOC, Output PULLET - // LAYING: Input PULLET, Output LAYER if productWarehouse.Product.Id != 0 { - productCategoryCode := productWarehouse.Product.ProductCategory.Code - var allowedCategory string + + var requiredFlag utils.FlagType if projectFlockKandang.ProjectFlock.Category == string(utils.ProjectFlockCategoryGrowing) { - allowedCategory = "DOC" + requiredFlag = utils.FlagDOC } else if projectFlockKandang.ProjectFlock.Category == string(utils.ProjectFlockCategoryLaying) { - allowedCategory = "PULLET" + requiredFlag = utils.FlagPullet } else { return nil, fmt.Errorf("invalid flock category for chickin") } - if productCategoryCode != allowedCategory { - return nil, fmt.Errorf("product warehouse %d cannot be used for %s chickin. Only %s products can be used as input (current: %s)", chickinReq.ProductWarehouseId, projectFlockKandang.ProjectFlock.Category, allowedCategory, productCategoryCode) + hasRequiredFlag := false + for _, flag := range productWarehouse.Product.Flags { + if utils.FlagType(flag.Name) == requiredFlag { + hasRequiredFlag = true + break + } + } + + if !hasRequiredFlag { + return nil, fmt.Errorf("product warehouse %d cannot be used for %s chickin. Product must have %s flag (product ID: %d, warehouse ID: %d)", chickinReq.ProductWarehouseId, projectFlockKandang.ProjectFlock.Category, requiredFlag, productWarehouse.Product.Id, productWarehouse.Id) } } @@ -191,7 +197,18 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti } newChikins = append(newChikins, newChickin) - chickinQtyMap[uint(idx)] = productWarehouse.Quantity + + totalPopulationQty, err := s.ProjectflockPopulationRepo.GetTotalQtyByProjectFlockKandangID(c.Context(), req.ProjectFlockKandangId) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get total population quantity for project_flock_kandang %d", req.ProjectFlockKandangId)) + } + + availableQty := productWarehouse.Quantity - totalPopulationQty + if availableQty < 0 { + availableQty = 0 + } + + chickinQtyMap[uint(idx)] = availableQty } if len(newChikins) == 0 { 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 28fe9853..22c712e5 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -301,7 +301,9 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) } - for _, targetDetail := range req.TargetKandangs { + var firstTargetProductWarehouseID *uint + + for i, targetDetail := range req.TargetKandangs { targetPFK, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetDetail.ProjectFlockKandangId) if err != nil { @@ -316,30 +318,40 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) return fiber.NewError(fiber.StatusInternalServerError, "Failed to get target warehouse") } + var targetPW entity.ProductWarehouse + err = dbTransaction.Where("warehouse_id = ? AND project_flock_kandang_id = ?", targetWarehouse.Id, targetDetail.ProjectFlockKandangId). + First(&targetPW).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No product warehouse found for target kandang %d in warehouse %d", targetDetail.ProjectFlockKandangId, targetWarehouse.Id)) + } + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get product warehouse for target kandang %d: %v", targetDetail.ProjectFlockKandangId, err)) + } + target := entity.LayingTransferTarget{ LayingTransferId: createBody.Id, TargetProjectFlockKandangId: targetDetail.ProjectFlockKandangId, Qty: targetDetail.Quantity, - ProductWarehouseId: &targetWarehouse.Id, + ProductWarehouseId: &targetPW.Id, } if err := dbTransaction.Create(&target).Error; err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create transfer target") } + + if i == 0 { + firstTargetProductWarehouseID = &targetPW.Id + } } // Set DestProductWarehouseID untuk STOCKABLE role (ambil dari target pertama) - if len(req.TargetKandangs) > 0 { - firstTargetPWID := req.TargetKandangs[0].ProjectFlockKandangId - // Cari ProductWarehouse untuk target kandang - targetWarehouse, _ := s.WarehouseRepo.GetLatestByKandangID(c.Context(), firstTargetPWID) - if targetWarehouse != nil { - // Query ProductWarehouse by warehouse and kandang - var targetPW entity.ProductWarehouse - err := dbTransaction.Where("warehouse_id = ? AND project_flock_kandang_id = ?", targetWarehouse.Id, firstTargetPWID). - First(&targetPW).Error - if err == nil { - createBody.DestProductWarehouseID = &targetPW.Id - } + if firstTargetProductWarehouseID != nil { + createBody.DestProductWarehouseID = firstTargetProductWarehouseID + + // Update DestProductWarehouseID ke database + if err := dbTransaction.Model(&entity.LayingTransfer{}). + Where("id = ?", createBody.Id). + Update("dest_product_warehouse_id", *firstTargetProductWarehouseID).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update DestProductWarehouseID") } } @@ -504,11 +516,21 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, return fiber.NewError(fiber.StatusInternalServerError, "Failed to get target warehouse") } + var targetPW entity.ProductWarehouse + err = dbTransaction.Where("warehouse_id = ? AND project_flock_kandang_id = ?", targetWarehouse.Id, targetDetail.ProjectFlockKandangId). + First(&targetPW).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No product warehouse found for target kandang %d in warehouse %d", targetDetail.ProjectFlockKandangId, targetWarehouse.Id)) + } + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get product warehouse for target kandang %d: %v", targetDetail.ProjectFlockKandangId, err)) + } + target := entity.LayingTransferTarget{ LayingTransferId: id, TargetProjectFlockKandangId: targetDetail.ProjectFlockKandangId, Qty: targetDetail.Quantity, - ProductWarehouseId: &targetWarehouse.Id, + ProductWarehouseId: &targetPW.Id, } if err := dbTransaction.Create(&target).Error; err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create transfer target")