From 29956528e58b61a540896ca3e09aad28c8aea3b1 Mon Sep 17 00:00:00 2001 From: ragilap Date: Fri, 13 Mar 2026 11:22:10 +0700 Subject: [PATCH] fixing filter pw for transfer, add transfer delete --- internal/entities/product_warehouse.go | 11 +- internal/middleware/permissions.go | 1 + .../product_warehouse.controller.go | 1 + .../dto/product_warehouse.dto.go | 18 +- .../services/product_warehouse.service.go | 86 ++++ .../product_warehouse.validation.go | 1 + .../controllers/transfer.controller.go | 20 + internal/modules/inventory/transfers/route.go | 1 + .../transfers/services/transfer.service.go | 391 +++++++++++++++++- .../chickins/services/chickin.service.go | 304 ++++++++++---- .../project-flock-kandangs/module.go | 9 +- .../services/project_flock_kandang.service.go | 90 +++- .../modules/production/recordings/module.go | 1 - .../production/transfer_layings/module.go | 1 - .../laying_transfer_target.repository.go | 132 ++++++ .../services/transfer_laying.service.go | 274 +++++++----- 16 files changed, 1122 insertions(+), 219 deletions(-) diff --git a/internal/entities/product_warehouse.go b/internal/entities/product_warehouse.go index 8e1ece25..a0d347db 100644 --- a/internal/entities/product_warehouse.go +++ b/internal/entities/product_warehouse.go @@ -1,11 +1,12 @@ package entities type ProductWarehouse struct { - Id uint `gorm:"primaryKey;column:id"` - ProductId uint `gorm:"column:product_id;not null"` - WarehouseId uint `gorm:"column:warehouse_id;not null"` - ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id"` - Quantity float64 `gorm:"column:qty;type:numeric(15,3);default:0"` + Id uint `gorm:"primaryKey;column:id"` + ProductId uint `gorm:"column:product_id;not null"` + WarehouseId uint `gorm:"column:warehouse_id;not null"` + ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id"` + Quantity float64 `gorm:"column:qty;type:numeric(15,3);default:0"` + AvailableQty *float64 `gorm:"-"` // Relations Product Product `gorm:"foreignKey:ProductId;references:Id"` diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index 05c80d54..aa31719f 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -70,6 +70,7 @@ const ( P_TransferGetAll = "lti.inventory.transfer.list" P_TransferGetOne = "lti.inventory.transfer.detail" P_TransferCreateOne = "lti.inventory.transfer.create" + P_TransferDeleteOne = "lti.inventory.transfer.delete" ) const ( diff --git a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go index bc6cdaed..5737e9f0 100644 --- a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go +++ b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go @@ -32,6 +32,7 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error { Flags: c.Query("flags", ""), KandangId: uint(c.QueryInt("kandang_id", 0)), TransferContext: c.Query(utils.TransferContextKey, ""), + StockMode: c.Query("stock_mode", ""), Type: c.Query("type", ""), } diff --git a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go index b9c95004..4953012a 100644 --- a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go +++ b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go @@ -12,10 +12,11 @@ import ( // === DTO Structs === type ProductWarehouseRelationDTO struct { - Id uint `json:"id"` - ProductId uint `json:"product_id"` - WarehouseId uint `json:"warehouse_id"` - Quantity float64 `json:"quantity"` + Id uint `json:"id"` + ProductId uint `json:"product_id"` + WarehouseId uint `json:"warehouse_id"` + Quantity float64 `json:"quantity"` + AvailableQty *float64 `json:"available_qty,omitempty"` } type ProductWarehouseListDTO struct { @@ -61,10 +62,11 @@ type ProjectFlockRelationDTO struct { func ToProductWarehouseRelationDTO(e entity.ProductWarehouse) ProductWarehouseRelationDTO { return ProductWarehouseRelationDTO{ - Id: e.Id, - ProductId: e.ProductId, // Field yang benar dari entity - WarehouseId: e.WarehouseId, // Field yang benar dari entity - Quantity: e.Quantity, + Id: e.Id, + ProductId: e.ProductId, // Field yang benar dari entity + WarehouseId: e.WarehouseId, // Field yang benar dari entity + Quantity: e.Quantity, + AvailableQty: e.AvailableQty, } } diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index 188c4506..d1dfc6ca 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -2,6 +2,7 @@ package service import ( "errors" + "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -12,6 +13,7 @@ import ( validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/validations" kandangrepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gorm.io/gorm" ) @@ -27,6 +29,8 @@ type productWarehouseService struct { KandangRepo kandangrepo.KandangRepository } +const stockModeExcludeChickin = "exclude_chickin" + func NewProductWarehouseService(repo repository.ProductWarehouseRepository, validate *validator.Validate, kandangRepo kandangrepo.KandangRepository) ProductWarehouseService { return &productWarehouseService{ Log: utils.Log, @@ -189,6 +193,11 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) s.Log.Errorf("Failed to get productWarehouses: %+v", err) return nil, 0, err } + + productWarehouses, err = s.applyTransferAvailableQty(c, params, productWarehouses) + if err != nil { + return nil, 0, err + } return productWarehouses, total, nil } @@ -229,3 +238,80 @@ func (s productWarehouseService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductW } return productWarehouse, nil } + +func (s productWarehouseService) applyTransferAvailableQty(c *fiber.Ctx, params *validation.Query, rows []entity.ProductWarehouse) ([]entity.ProductWarehouse, error) { + if len(rows) == 0 { + return rows, nil + } + if params == nil || + params.TransferContext != utils.TransferContextInventoryTransfer || + params.StockMode != stockModeExcludeChickin { + return rows, nil + } + + ayamPWIDs := make([]uint, 0) + for i := range rows { + if isAyamProductByFlags(rows[i].Product.Flags) { + ayamPWIDs = append(ayamPWIDs, rows[i].Id) + } + } + if len(ayamPWIDs) == 0 { + return rows, nil + } + + type usageRow struct { + ProductWarehouseID uint `gorm:"column:product_warehouse_id"` + UsedQty float64 `gorm:"column:used_qty"` + } + usageRows := make([]usageRow, 0) + if err := s.Repository.DB().WithContext(c.Context()). + Table("stock_allocations"). + Select("product_warehouse_id, COALESCE(SUM(qty), 0) AS used_qty"). + Where("product_warehouse_id IN ?", ayamPWIDs). + Where("usable_type = ?", fifo.UsableKeyProjectChickin.String()). + Where("status = ?", entity.StockAllocationStatusActive). + Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). + Where("deleted_at IS NULL"). + Group("product_warehouse_id"). + Scan(&usageRows).Error; err != nil { + s.Log.Errorf("Failed to calculate available transfer stock after chickin consumption: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menghitung stok tersedia untuk transfer") + } + + usageMap := make(map[uint]float64, len(usageRows)) + for _, row := range usageRows { + usageMap[row.ProductWarehouseID] = row.UsedQty + } + + filtered := make([]entity.ProductWarehouse, 0, len(rows)) + for i := range rows { + row := rows[i] + if !isAyamProductByFlags(row.Product.Flags) { + filtered = append(filtered, row) + continue + } + + available := row.Quantity - usageMap[row.Id] + if available < 0 { + available = 0 + } + row.AvailableQty = &available + + if available <= 0 { + continue + } + + filtered = append(filtered, row) + } + + return filtered, nil +} + +func isAyamProductByFlags(flags []entity.Flag) bool { + for _, flag := range flags { + if utils.CanonicalFlagType(strings.TrimSpace(flag.Name)) == utils.FlagAyam { + return true + } + } + return false +} diff --git a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go index 5d1f4e0a..348fd96d 100644 --- a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go +++ b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go @@ -20,5 +20,6 @@ type Query struct { Flags string `query:"flags" validate:"omitempty"` KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"` TransferContext string `query:"transfer_context" validate:"omitempty,oneof=inventory_transfer"` + StockMode string `query:"stock_mode" validate:"omitempty,oneof=exclude_chickin"` Type string `query:"type" validate:"omitempty"` } diff --git a/internal/modules/inventory/transfers/controllers/transfer.controller.go b/internal/modules/inventory/transfers/controllers/transfer.controller.go index 530d70dc..64efeada 100644 --- a/internal/modules/inventory/transfers/controllers/transfer.controller.go +++ b/internal/modules/inventory/transfers/controllers/transfer.controller.go @@ -109,3 +109,23 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error { Data: dto.ToTransferDetailDTO(*result), }) } + +func (u *TransferController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := u.TransferService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete transfer successfully", + }) +} diff --git a/internal/modules/inventory/transfers/route.go b/internal/modules/inventory/transfers/route.go index f754148c..3c8bd0a8 100644 --- a/internal/modules/inventory/transfers/route.go +++ b/internal/modules/inventory/transfers/route.go @@ -18,5 +18,6 @@ func TransferRoutes(v1 fiber.Router, u user.UserService, s transfer.TransferServ route.Get("/", m.RequirePermissions(m.P_TransferGetAll), ctrl.GetAll) route.Post("/", m.RequirePermissions(m.P_TransferCreateOne), ctrl.CreateOne) route.Get("/:id", m.RequirePermissions(m.P_TransferGetOne), ctrl.GetOne) + route.Delete("/:id", m.RequirePermissions(m.P_TransferDeleteOne), ctrl.DeleteOne) } diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index e04d1a8f..83e918fd 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -5,7 +5,9 @@ import ( "errors" "fmt" "mime/multipart" + "sort" "strings" + "time" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -21,14 +23,17 @@ 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" + "gorm.io/gorm/clause" ) type TransferService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.StockTransfer, error) CreateOne(ctx *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) + DeleteOne(ctx *fiber.Ctx, id uint) error } type transferService struct { @@ -49,6 +54,8 @@ type transferService struct { ExpenseBridge TransferExpenseBridge } +const transferDeleteDownstreamGuardMessage = "Transfer stock tidak dapat dihapus karena stok transfer sudah dipakai transaksi turunan. Hapus dependensi terkait secara manual terlebih dahulu." + func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, projectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository, documentSvc commonSvc.DocumentService, fifoStockV2Svc commonSvc.FifoStockV2Service, expenseBridge TransferExpenseBridge) TransferService { return &transferService{ Log: utils.Log, @@ -104,6 +111,7 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit transfers, total, err := s.StockTransferRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + db = db.Where("stock_transfers.deleted_at IS NULL") if scope.Restrict { if len(scope.IDs) == 0 { return db.Where("1 = 0") @@ -145,6 +153,7 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e Joins("JOIN warehouses w_from ON w_from.id = stock_transfers.from_warehouse_id"). Joins("JOIN warehouses w_to ON w_to.id = stock_transfers.to_warehouse_id"). Where("stock_transfers.id = ?", id). + Where("stock_transfers.deleted_at IS NULL"). Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs). Count(&count).Error; err != nil { return nil, err @@ -155,7 +164,7 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e } transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { - return s.withRelations(db) + return s.withRelations(db).Where("stock_transfers.deleted_at IS NULL") }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -619,6 +628,210 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return result, nil } +func (s *transferService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := s.ensureTransferAccess(c.Context(), id, c); err != nil { + return err + } + if s.FifoStockV2Svc == nil { + return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available") + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return err + } + + var deletedDetails []entity.StockTransferDetail + err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + stockLogRepoTx := rStockLogs.NewStockLogRepository(tx) + + var transfer entity.StockTransfer + if err := tx.WithContext(c.Context()). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ?", uint64(id)). + Where("deleted_at IS NULL"). + Take(&transfer).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Transfer dengan ID %d tidak ditemukan", id)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data transfer") + } + + var details []entity.StockTransferDetail + if err := tx.WithContext(c.Context()). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("stock_transfer_id = ?", transfer.Id). + Where("deleted_at IS NULL"). + Order("id ASC"). + Find(&details).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil detail transfer") + } + if len(details) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Transfer tidak memiliki detail produk") + } + + detailIDs := make([]uint64, 0, len(details)) + for _, detail := range details { + detailIDs = append(detailIDs, detail.Id) + } + if err := s.ensureNoDownstreamConsumptionForDelete(c.Context(), tx, detailIDs); err != nil { + return err + } + + type reflowKey struct { + flagGroupCode string + productWarehouseID uint + } + destReflows := make(map[reflowKey]struct{}) + + for _, detail := range details { + if detail.SourceProductWarehouseID == nil || *detail.SourceProductWarehouseID == 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Detail transfer %d tidak memiliki source product warehouse valid", detail.Id)) + } + if detail.DestProductWarehouseID == nil || *detail.DestProductWarehouseID == 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Detail transfer %d tidak memiliki destination product warehouse valid", detail.Id)) + } + + flagGroupCode, err := s.resolveTransferFlagGroup(c.Context(), tx, uint(detail.ProductId)) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("FIFO v2 route tidak ditemukan untuk produk %d: %v", detail.ProductId, err)) + } + + rollbackRes, err := s.FifoStockV2Svc.Rollback(c.Context(), commonSvc.FifoStockV2RollbackRequest{ + ProductWarehouseID: uint(*detail.SourceProductWarehouseID), + Usable: commonSvc.FifoStockV2Ref{ + ID: uint(detail.Id), + LegacyTypeKey: fifo.UsableKeyStockTransferOut.String(), + FunctionCode: "STOCK_TRANSFER_OUT", + }, + Reason: fmt.Sprintf("transfer delete #%s", transfer.MovementNumber), + Tx: tx, + }) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback FIFO v2 transfer detail %d: %v", detail.Id, err)) + } + + releasedQty := 0.0 + if rollbackRes != nil { + releasedQty = rollbackRes.ReleasedQty + } + if detail.UsageQty > 1e-6 && releasedQty < detail.UsageQty-1e-6 { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Rollback FIFO v2 source transfer detail %d tidak lengkap. Dibutuhkan %.3f, terlepas %.3f", detail.Id, detail.UsageQty, releasedQty), + ) + } + + if releasedQty > 1e-6 { + if err := s.appendStockLog( + c.Context(), + stockLogRepoTx, + uint(*detail.SourceProductWarehouseID), + actorID, + releasedQty, + 0, + uint(detail.Id), + fmt.Sprintf("TRANSFER DELETE #%s", transfer.MovementNumber), + ); err != nil { + return err + } + } + + destDecreaseQty := detail.TotalQty + if destDecreaseQty <= 1e-6 { + destDecreaseQty = detail.UsageQty + } + if destDecreaseQty > 1e-6 { + if err := s.appendStockLog( + c.Context(), + stockLogRepoTx, + uint(*detail.DestProductWarehouseID), + actorID, + 0, + destDecreaseQty, + uint(detail.Id), + fmt.Sprintf("TRANSFER DELETE #%s", transfer.MovementNumber), + ); err != nil { + return err + } + } + + destReflows[reflowKey{ + flagGroupCode: flagGroupCode, + productWarehouseID: uint(*detail.DestProductWarehouseID), + }] = struct{}{} + } + + now := time.Now().UTC() + if err := tx.WithContext(c.Context()). + Where("stock_transfer_detail_id IN ?", detailIDs). + Delete(&entity.StockTransferDeliveryItem{}).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus item delivery transfer") + } + if err := tx.WithContext(c.Context()). + Model(&entity.StockTransferDelivery{}). + Where("stock_transfer_id = ?", transfer.Id). + Where("deleted_at IS NULL"). + Updates(map[string]any{ + "deleted_at": now, + "updated_at": now, + }).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus delivery transfer") + } + if err := tx.WithContext(c.Context()). + Model(&entity.StockTransferDetail{}). + Where("id IN ?", detailIDs). + Where("deleted_at IS NULL"). + Updates(map[string]any{ + "deleted_at": now, + "updated_at": now, + }).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus detail transfer") + } + + asOf := transfer.TransferDate + for key := range destReflows { + if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: key.flagGroupCode, + ProductWarehouseID: key.productWarehouseID, + AsOf: &asOf, + Tx: tx, + }); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow stok tujuan saat delete transfer: %v", err)) + } + } + + if err := tx.WithContext(c.Context()). + Model(&entity.StockTransfer{}). + Where("id = ?", transfer.Id). + Where("deleted_at IS NULL"). + Updates(map[string]any{ + "deleted_at": now, + "updated_at": now, + }).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus transfer") + } + + deletedDetails = append(deletedDetails, details...) + return nil + }) + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return fiberErr + } + return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus transfer") + } + + if len(deletedDetails) > 0 && s.ExpenseBridge != nil { + if err := s.ExpenseBridge.OnItemsDeleted(c.Context(), uint64(id), deletedDetails); err != nil { + s.Log.Errorf("Failed to cleanup transfer expense link for transfer_id=%d: %+v", id, err) + return fiber.NewError(fiber.StatusInternalServerError, "Transfer berhasil dihapus, namun sinkronisasi expense gagal. Silakan cek modul expense") + } + } + + return nil +} + func (s *transferService) resolveTransferFlagGroup( ctx context.Context, tx *gorm.DB, @@ -692,3 +905,179 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa return uint(projectFlockKandang.Id), nil } + +func (s *transferService) ensureTransferAccess(ctx context.Context, id uint, c *fiber.Ctx) error { + scope, err := m.ResolveLocationScope(c, s.StockTransferRepo.DB()) + if err != nil { + return err + } + if !scope.Restrict { + return nil + } + if len(scope.IDs) == 0 { + return fiber.NewError(fiber.StatusNotFound, "Transfer not found") + } + + var count int64 + if err := s.StockTransferRepo.DB().WithContext(ctx). + Table("stock_transfers"). + Joins("JOIN warehouses w_from ON w_from.id = stock_transfers.from_warehouse_id"). + Joins("JOIN warehouses w_to ON w_to.id = stock_transfers.to_warehouse_id"). + Where("stock_transfers.id = ?", id). + Where("stock_transfers.deleted_at IS NULL"). + Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs). + Count(&count).Error; err != nil { + return err + } + if count == 0 { + return fiber.NewError(fiber.StatusNotFound, "Transfer not found") + } + + return nil +} + +func (s *transferService) ensureNoDownstreamConsumptionForDelete(ctx context.Context, tx *gorm.DB, detailIDs []uint64) error { + if len(detailIDs) == 0 { + return nil + } + + db := s.StockTransferRepo.DB().WithContext(ctx) + if tx != nil { + db = tx.WithContext(ctx) + } + + type downstreamRow struct { + UsableType string `gorm:"column:usable_type"` + UsableID uint64 `gorm:"column:usable_id"` + } + + var rows []downstreamRow + if err := db.Table("stock_allocations"). + Select("usable_type, usable_id"). + Where("stockable_type = ?", fifo.StockableKeyStockTransferIn.String()). + Where("stockable_id IN ?", detailIDs). + Where("status = ?", entity.StockAllocationStatusActive). + Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). + Where("deleted_at IS NULL"). + Group("usable_type, usable_id"). + Scan(&rows).Error; err != nil { + s.Log.Errorf("Failed to validate downstream stock transfer consumption: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transaksi turunan transfer stock") + } + + if len(rows) == 0 { + return nil + } + + dependencyMap := make(map[string]map[uint64]struct{}) + for _, row := range rows { + label := mapTransferDownstreamUsableLabel(row.UsableType) + if _, ok := dependencyMap[label]; !ok { + dependencyMap[label] = make(map[uint64]struct{}) + } + dependencyMap[label][row.UsableID] = struct{}{} + } + + labels := make([]string, 0, len(dependencyMap)) + for label := range dependencyMap { + labels = append(labels, label) + } + sort.Strings(labels) + + details := make([]string, 0, len(labels)) + for _, label := range labels { + ids := sortedUint64Keys(dependencyMap[label]) + details = append(details, fmt.Sprintf("%s=%s", label, joinUint64(ids))) + } + + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("%s Dependensi aktif: %s.", transferDeleteDownstreamGuardMessage, strings.Join(details, ", ")), + ) +} + +func mapTransferDownstreamUsableLabel(usableType string) string { + switch strings.ToUpper(strings.TrimSpace(usableType)) { + case fifo.UsableKeyRecordingStock.String(), fifo.UsableKeyRecordingDepletion.String(): + return "Recording" + case fifo.UsableKeyProjectChickin.String(): + return "Chickin" + case fifo.UsableKeyMarketingDelivery.String(): + return "Marketing" + case fifo.UsableKeyTransferToLayingOut.String(): + return "TransferToLaying" + case fifo.UsableKeyStockTransferOut.String(): + return "TransferStock" + case fifo.UsableKeyAdjustmentOut.String(): + return "Adjustment" + default: + return strings.ToUpper(strings.TrimSpace(usableType)) + } +} + +func sortedUint64Keys(input map[uint64]struct{}) []uint64 { + if len(input) == 0 { + return nil + } + out := make([]uint64, 0, len(input)) + for id := range input { + if id == 0 { + continue + } + out = append(out, id) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +func joinUint64(values []uint64) string { + if len(values) == 0 { + return "-" + } + parts := make([]string, 0, len(values)) + for _, value := range values { + parts = append(parts, fmt.Sprintf("%d", value)) + } + return strings.Join(parts, "|") +} + +func (s *transferService) appendStockLog( + ctx context.Context, + stockLogRepo rStockLogs.StockLogRepository, + productWarehouseID uint, + actorID uint, + increase float64, + decrease float64, + loggableID uint, + notes string, +) error { + if productWarehouseID == 0 || (increase <= 1e-6 && decrease <= 1e-6) { + return nil + } + + stockLog := &entity.StockLog{ + ProductWarehouseId: productWarehouseID, + CreatedBy: actorID, + Increase: increase, + Decrease: decrease, + LoggableType: string(utils.StockLogTypeTransfer), + LoggableId: loggableID, + Notes: notes, + } + + stockLogs, err := stockLogRepo.GetByProductWarehouse(ctx, productWarehouseID, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + stockLog.Stock = latestStockLog.Stock + increase - decrease + } else { + stockLog.Stock = increase - decrease + } + if err := stockLogRepo.CreateOne(ctx, stockLog, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat stock log saat delete transfer") + } + + return nil +} diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 21b3a5df..693455e6 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -36,7 +36,7 @@ import ( const ( chickinDeletePopulationGuardMessage = "Chickin tidak dapat dihapus karena masih memiliki population aktif" - chickinDeleteDownstreamGuardMessage = "Chickin tidak bisa dihapus karena masih dipakai oleh transaksi turunan. Hapus/unexecute Marketing, Recording, dan Transfer to Laying terlebih dahulu." + chickinDeleteDownstreamGuardMessage = "Chickin tidak bisa dihapus karena masih dipakai oleh transaksi turunan. Hapus/unexecute Marketing, Recording, Transfer, Adjustment, dan Transfer to Laying terlebih dahulu." ) type ChickinService interface { @@ -264,16 +264,16 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti availableQty = 0 } if flockCategory == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) { - transferAvailable, err := s.resolveLayingTransferAvailableQty(c.Context(), nil, req.ProjectFlockKandangId, chickinReq.ProductWarehouseId) + sourceAvailable, err := s.resolveLayingSourceAvailableQty(c.Context(), nil, chickinReq.ProductWarehouseId, &chickinDate) if err != nil { s.Log.Errorf("Failed to resolve laying transfer availability for pfk=%d pw=%d: %+v", req.ProjectFlockKandangId, chickinReq.ProductWarehouseId, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi stok transfer laying") } - if transferAvailable < 0 { - transferAvailable = 0 + if sourceAvailable < 0 { + sourceAvailable = 0 } - if transferAvailable < availableQty { - availableQty = transferAvailable + if sourceAvailable < availableQty { + availableQty = sourceAvailable } } @@ -554,36 +554,44 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { return nil } -func (s *chickinService) resolveLayingTransferAvailableQty(ctx context.Context, tx *gorm.DB, targetProjectFlockKandangID, productWarehouseID uint) (float64, error) { - if targetProjectFlockKandangID == 0 || productWarehouseID == 0 { +func (s *chickinService) resolveLayingSourceAvailableQty(ctx context.Context, tx *gorm.DB, productWarehouseID uint, asOf *time.Time) (float64, error) { + if productWarehouseID == 0 || s.FifoStockV2Svc == nil { return 0, nil } - db := s.Repository.DB().WithContext(ctx) + db := s.Repository.DB() if tx != nil { - db = tx.WithContext(ctx) + db = tx } - var available float64 - err := db.Table("laying_transfer_targets ltt"). - Select("COALESCE(SUM(GREATEST(0, COALESCE(ltt.total_qty,0) - COALESCE(ltt.total_used,0))), 0) AS available"). - Joins("JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id AND lt.deleted_at IS NULL"). - Where("ltt.deleted_at IS NULL"). - Where("ltt.target_project_flock_kandang_id = ?", targetProjectFlockKandangID). - Where("ltt.product_warehouse_id = ?", productWarehouseID). - Where("lt.executed_at IS NOT NULL"). - Where(`( - SELECT a.action - FROM approvals a - WHERE a.approvable_type = ? - AND a.approvable_id = lt.id - ORDER BY a.id DESC - LIMIT 1 - ) = ?`, string(utils.ApprovalWorkflowTransferToLaying), entity.ApprovalActionApproved). - Scan(&available).Error + flagGroupCode, err := resolveChickinFlagGroupByProductWarehouse(ctx, db, productWarehouseID) if err != nil { return 0, err } + if strings.TrimSpace(flagGroupCode) == "" { + return 0, nil + } + + gatherRows, err := s.FifoStockV2Svc.Gather(ctx, commonSvc.FifoStockV2GatherRequest{ + FlagGroupCode: flagGroupCode, + Lane: commonSvc.FifoStockV2Lane("STOCKABLE"), + AllocationPurpose: entity.StockAllocationPurposeConsume, + ProductWarehouseID: productWarehouseID, + AsOf: asOf, + Limit: 10000, + Tx: tx, + }) + if err != nil { + return 0, err + } + + available := 0.0 + for _, row := range gatherRows { + if row.AvailableQuantity <= 0 { + continue + } + available += row.AvailableQuantity + } return available, nil } @@ -650,6 +658,8 @@ func (s *chickinService) ensureNoDownstreamConsumptionForDelete(ctx context.Cont Where("sa.usable_type IN ?", []string{ fifo.UsableKeyMarketingDelivery.String(), fifo.UsableKeyRecordingDepletion.String(), + fifo.UsableKeyStockTransferOut.String(), + fifo.UsableKeyAdjustmentOut.String(), fifo.UsableKeyTransferToLayingOut.String(), }). Group("sa.usable_type, sa.usable_id"). @@ -664,6 +674,8 @@ func (s *chickinService) ensureNoDownstreamConsumptionForDelete(ctx context.Cont marketingIDs := make(map[uint]struct{}) recordingIDs := make(map[uint]struct{}) + transferIDs := make(map[uint]struct{}) + adjustmentIDs := make(map[uint]struct{}) transferLayingIDs := make(map[uint]struct{}) for _, row := range rows { @@ -672,18 +684,28 @@ func (s *chickinService) ensureNoDownstreamConsumptionForDelete(ctx context.Cont marketingIDs[row.UsableID] = struct{}{} case fifo.UsableKeyRecordingDepletion.String(): recordingIDs[row.UsableID] = struct{}{} + case fifo.UsableKeyStockTransferOut.String(): + transferIDs[row.UsableID] = struct{}{} + case fifo.UsableKeyAdjustmentOut.String(): + adjustmentIDs[row.UsableID] = struct{}{} case fifo.UsableKeyTransferToLayingOut.String(): transferLayingIDs[row.UsableID] = struct{}{} } } - details := make([]string, 0, 3) + details := make([]string, 0, 5) if ids := sortedIDs(marketingIDs); len(ids) > 0 { details = append(details, fmt.Sprintf("Marketing=%s", joinUint(ids))) } if ids := sortedIDs(recordingIDs); len(ids) > 0 { details = append(details, fmt.Sprintf("Recording=%s", joinUint(ids))) } + if ids := sortedIDs(transferIDs); len(ids) > 0 { + details = append(details, fmt.Sprintf("Transfer=%s", joinUint(ids))) + } + if ids := sortedIDs(adjustmentIDs); len(ids) > 0 { + details = append(details, fmt.Sprintf("Adjustment=%s", joinUint(ids))) + } if ids := sortedIDs(transferLayingIDs); len(ids) > 0 { details = append(details, fmt.Sprintf("TransferToLaying=%s", joinUint(ids))) } @@ -1292,17 +1314,8 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, } if !shouldRestoreWarehouseQty { - var affectedTransferTargetIDs []uint - if err := tx.WithContext(ctx). - Model(&entity.StockAllocation{}). - Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ? AND stockable_type = ?", - fifo.UsableKeyProjectChickin.String(), - chickin.Id, - entity.StockAllocationStatusActive, - entity.StockAllocationPurposeConsume, - fifo.StockableKeyTransferToLayingIn.String(), - ). - Pluck("stockable_id", &affectedTransferTargetIDs).Error; err != nil { + affectedStockables, err := s.listActiveConsumeStockableRefsByUsable(ctx, tx, chickin.Id) + if err != nil { return err } @@ -1325,12 +1338,15 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, return err } s.Log.Infof( - "Release chickin stock laying id=%d released_consume_alloc=%d transfer_targets=%d", + "Release chickin stock laying id=%d released_consume_alloc=%d transfer_targets=%d stock_transfer_sources=%d purchase_sources=%d adjustment_sources=%d", chickin.Id, releaseResult.RowsAffected, - len(affectedTransferTargetIDs), + len(affectedStockables[fifo.StockableKeyTransferToLayingIn.String()]), + len(affectedStockables[fifo.StockableKeyStockTransferIn.String()]), + len(affectedStockables[fifo.StockableKeyPurchaseItems.String()]), + len(affectedStockables[fifo.StockableKeyAdjustmentIn.String()]), ) - if err := s.resyncTransferTargetUsageFromAllocations(ctx, tx, affectedTransferTargetIDs); err != nil { + if err := s.resyncStockableSourceUsageAfterRelease(ctx, tx, affectedStockables); err != nil { return err } if err := s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, 0, 0); err != nil { @@ -1465,63 +1481,179 @@ func (s *chickinService) logWarehouseQtySnapshot( ) } -func (s *chickinService) resyncTransferTargetUsageFromAllocations(ctx context.Context, tx *gorm.DB, transferTargetIDs []uint) error { - if tx == nil || len(transferTargetIDs) == 0 { - return nil +func (s *chickinService) listActiveConsumeStockableRefsByUsable(ctx context.Context, tx *gorm.DB, chickinID uint) (map[string][]uint, error) { + result := map[string][]uint{ + fifo.StockableKeyTransferToLayingIn.String(): nil, + fifo.StockableKeyStockTransferIn.String(): nil, + fifo.StockableKeyPurchaseItems.String(): nil, + fifo.StockableKeyAdjustmentIn.String(): nil, + } + if tx == nil || chickinID == 0 { + return result, nil } - unique := make([]uint, 0, len(transferTargetIDs)) - seen := make(map[uint]struct{}, len(transferTargetIDs)) - for _, id := range transferTargetIDs { - if id == 0 { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - unique = append(unique, id) + type row struct { + StockableType string `gorm:"column:stockable_type"` + StockableID uint `gorm:"column:stockable_id"` } - if len(unique) == 0 { - return nil - } - - if err := tx.WithContext(ctx). - Model(&entity.LayingTransferTarget{}). - Where("id IN ?", unique). - Update("total_used", 0).Error; err != nil { - return err - } - - type usageRow struct { - StockableID uint `gorm:"column:stockable_id"` - Used float64 `gorm:"column:used"` - } - var usageRows []usageRow + var rows []row if err := tx.WithContext(ctx). Table("stock_allocations"). - Select("stockable_id, COALESCE(SUM(qty), 0) AS used"). - Where("stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()). - Where("status = ?", entity.StockAllocationStatusActive). - Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). - Where("stockable_id IN ?", unique). - Group("stockable_id"). - Scan(&usageRows).Error; err != nil { + Select("stockable_type, stockable_id"). + Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", + fifo.UsableKeyProjectChickin.String(), + chickinID, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Where("stockable_type IN ?", []string{ + fifo.StockableKeyTransferToLayingIn.String(), + fifo.StockableKeyStockTransferIn.String(), + fifo.StockableKeyPurchaseItems.String(), + fifo.StockableKeyAdjustmentIn.String(), + }). + Group("stockable_type, stockable_id"). + Scan(&rows).Error; err != nil { + return nil, err + } + + for _, row := range rows { + if row.StockableID == 0 { + continue + } + result[row.StockableType] = append(result[row.StockableType], row.StockableID) + } + for key, ids := range result { + result[key] = uniqueUint(ids) + } + + return result, nil +} + +func (s *chickinService) resyncStockableSourceUsageAfterRelease(ctx context.Context, tx *gorm.DB, stockableRefs map[string][]uint) error { + if tx == nil || len(stockableRefs) == 0 { + return nil + } + + if err := s.resetAndResyncUsedQuantity( + ctx, + tx, + "laying_transfer_targets", + "id", + "total_used", + fifo.StockableKeyTransferToLayingIn.String(), + stockableRefs[fifo.StockableKeyTransferToLayingIn.String()], + ); err != nil { return err } - for _, row := range usageRows { - if err := tx.WithContext(ctx). - Model(&entity.LayingTransferTarget{}). - Where("id = ?", row.StockableID). - Update("total_used", row.Used).Error; err != nil { - return err - } + if err := s.resetAndResyncUsedQuantity( + ctx, + tx, + "stock_transfer_details", + "id", + "total_used", + fifo.StockableKeyStockTransferIn.String(), + stockableRefs[fifo.StockableKeyStockTransferIn.String()], + ); err != nil { + return err + } + + if err := s.resetAndResyncUsedQuantity( + ctx, + tx, + "purchase_items", + "id", + "total_used", + fifo.StockableKeyPurchaseItems.String(), + stockableRefs[fifo.StockableKeyPurchaseItems.String()], + ); err != nil { + return err + } + + if err := s.resetAndResyncUsedQuantity( + ctx, + tx, + "adjustment_stocks", + "id", + "total_used", + fifo.StockableKeyAdjustmentIn.String(), + stockableRefs[fifo.StockableKeyAdjustmentIn.String()], + ); err != nil { + return err } return nil } +func (s *chickinService) resetAndResyncUsedQuantity( + ctx context.Context, + tx *gorm.DB, + tableName string, + idColumn string, + usedColumn string, + stockableType string, + ids []uint, +) error { + ids = uniqueUint(ids) + if tx == nil || len(ids) == 0 { + return nil + } + + if err := tx.WithContext(ctx). + Table(tableName). + Where(fmt.Sprintf("%s IN ?", idColumn), ids). + Update(usedColumn, 0).Error; err != nil { + return err + } + + query := fmt.Sprintf(` + UPDATE %s AS t + SET %s = a.used + FROM ( + SELECT stockable_id, COALESCE(SUM(qty), 0) AS used + FROM stock_allocations + WHERE stockable_type = ? + AND status = ? + AND allocation_purpose = ? + AND stockable_id IN ? + GROUP BY stockable_id + ) AS a + WHERE t.%s = a.stockable_id + `, tableName, usedColumn, idColumn) + + if err := tx.WithContext(ctx).Exec( + query, + stockableType, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ids, + ).Error; err != nil { + return err + } + + return nil +} + +func uniqueUint(values []uint) []uint { + if len(values) == 0 { + return nil + } + out := make([]uint, 0, len(values)) + seen := make(map[uint]struct{}, len(values)) + for _, value := range values { + if value == 0 { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + func normalizeDateOnlyUTC(value time.Time) time.Time { if value.IsZero() { return time.Time{} diff --git a/internal/modules/production/project-flock-kandangs/module.go b/internal/modules/production/project-flock-kandangs/module.go index 00ae03ff..aa9a7863 100644 --- a/internal/modules/production/project-flock-kandangs/module.go +++ b/internal/modules/production/project-flock-kandangs/module.go @@ -7,14 +7,14 @@ import ( "github.com/gofiber/fiber/v2" "gorm.io/gorm" - sProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/services" - rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" - rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" rExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + sProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/services" + rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" @@ -33,13 +33,14 @@ func (ProjectFlockKandangModule) RegisterRoutes(router fiber.Router, db *gorm.DB approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) + fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) // register workflow steps for chickin approvals if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowProjectFlockKandang, utils.ProjectFlockKandangApprovalSteps); err != nil { panic(fmt.Sprintf("failed to register chickin approval workflow: %v", err)) } expenseRepo := rExpense.NewExpenseRepository(db) - projectFlockKandangService := sProjectFlockKandang.NewProjectFlockKandangService(projectFlockKandangRepo, approvalService, expenseRepo, warehouseRepo, productWarehouseRepo, projectFlockPopulationRepo,kandangRepo, validate) + projectFlockKandangService := sProjectFlockKandang.NewProjectFlockKandangService(projectFlockKandangRepo, approvalService, fifoStockV2Service, expenseRepo, warehouseRepo, productWarehouseRepo, projectFlockPopulationRepo, kandangRepo, validate) userService := sUser.NewUserService(userRepo, validate) ProjectFlockKandangRoutes(router, userService, projectFlockKandangService) 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 61a593d5..d173efc3 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 @@ -1,8 +1,10 @@ package service import ( + "context" "errors" "fmt" + "math" "strings" "time" @@ -35,6 +37,7 @@ type projectFlockKandangService struct { Validate *validator.Validate Repository repository.ProjectFlockKandangRepository ApprovalSvc commonSvc.ApprovalService + FifoStockV2Svc commonSvc.FifoStockV2Service ExpenseRepo expenseRepo.ExpenseRepository WarehouseRepo rWarehouse.WarehouseRepository ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository @@ -69,12 +72,13 @@ type ExpenseSummary struct { Reference string `json:"reference_number"` } -func NewProjectFlockKandangService(repo repository.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, expenseRepo expenseRepo.ExpenseRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, populationRepo repository.ProjectFlockPopulationRepository, kandangRepo kandangRepo.KandangRepository, validate *validator.Validate) ProjectFlockKandangService { +func NewProjectFlockKandangService(repo repository.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, fifoStockV2Svc commonSvc.FifoStockV2Service, expenseRepo expenseRepo.ExpenseRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, populationRepo repository.ProjectFlockPopulationRepository, kandangRepo kandangRepo.KandangRepository, validate *validator.Validate) ProjectFlockKandangService { return &projectFlockKandangService{ Log: utils.Log, Validate: validate, Repository: repo, ApprovalSvc: approvalSvc, + FifoStockV2Svc: fifoStockV2Svc, ExpenseRepo: expenseRepo, WarehouseRepo: warehouseRepo, ProductWarehouseRepo: productWarehouseRepo, @@ -671,7 +675,91 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous if availableQty < 0 { availableQty = 0 } + + sourceAvailable, err := s.resolveLayingSourceAvailableQty(c.Context(), nil, productWarehouse.Id, nil) + if err != nil { + return 0, err + } + if sourceAvailable < availableQty { + availableQty = sourceAvailable + } } return availableQty, nil } + +func (s projectFlockKandangService) resolveLayingSourceAvailableQty(ctx context.Context, tx *gorm.DB, productWarehouseID uint, asOf *time.Time) (float64, error) { + if productWarehouseID == 0 || s.FifoStockV2Svc == nil { + return 0, nil + } + + flagGroupCode, err := s.resolveFlagGroupByProductWarehouse(ctx, tx, productWarehouseID) + if err != nil { + return 0, err + } + if strings.TrimSpace(flagGroupCode) == "" { + return 0, nil + } + + gatherRows, err := s.FifoStockV2Svc.Gather(ctx, commonSvc.FifoStockV2GatherRequest{ + FlagGroupCode: flagGroupCode, + Lane: commonSvc.FifoStockV2Lane("STOCKABLE"), + AllocationPurpose: entity.StockAllocationPurposeConsume, + ProductWarehouseID: productWarehouseID, + AsOf: asOf, + Limit: 10000, + Tx: tx, + }) + if err != nil { + return 0, err + } + + total := 0.0 + for _, row := range gatherRows { + if row.AvailableQuantity <= 0 { + continue + } + total += row.AvailableQuantity + } + return math.Max(total, 0), nil +} + +func (s projectFlockKandangService) resolveFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (string, error) { + type row struct { + FlagGroupCode string `gorm:"column:flag_group_code"` + } + selected := row{} + + db := s.Repository.DB() + if tx != nil { + db = tx + } + + err := db.WithContext(ctx). + Table("fifo_stock_v2_route_rules rr"). + Select("rr.flag_group_code"). + Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE"). + Where("rr.is_active = TRUE"). + Where("rr.lane = 'STOCKABLE'"). + Where(` + EXISTS ( + SELECT 1 + FROM product_warehouses pw + JOIN flags f ON f.flagable_id = pw.product_id + JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE + WHERE pw.id = ? + AND f.flagable_type = ? + AND fm.flag_group_code = rr.flag_group_code + ) + `, productWarehouseID, entity.FlagableTypeProduct). + Order("fg.priority ASC, rr.id ASC"). + Limit(1). + Take(&selected).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", nil + } + return "", err + } + return strings.TrimSpace(selected.FlagGroupCode), nil +} diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go index d17c8958..adbf6a40 100644 --- a/internal/modules/production/recordings/module.go +++ b/internal/modules/production/recordings/module.go @@ -155,7 +155,6 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate productWarehouseRepo, warehouseRepo, approvalService, - fifoService, fifoStockV2Service, validate, ) diff --git a/internal/modules/production/transfer_layings/module.go b/internal/modules/production/transfer_layings/module.go index d7dbaa50..e297e0c7 100644 --- a/internal/modules/production/transfer_layings/module.go +++ b/internal/modules/production/transfer_layings/module.go @@ -91,7 +91,6 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val productWarehouseRepo, warehouseRepo, approvalService, - fifoService, fifoStockV2Service, validate, ) diff --git a/internal/modules/production/transfer_layings/repositories/laying_transfer_target.repository.go b/internal/modules/production/transfer_layings/repositories/laying_transfer_target.repository.go index 486008cc..9e7e62f8 100644 --- a/internal/modules/production/transfer_layings/repositories/laying_transfer_target.repository.go +++ b/internal/modules/production/transfer_layings/repositories/laying_transfer_target.repository.go @@ -2,15 +2,22 @@ package repository import ( "context" + "errors" + "time" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gorm.io/gorm" ) type LayingTransferTargetRepository interface { repository.BaseRepository[entity.LayingTransferTarget] GetByLayingTransferId(ctx context.Context, layingTransferId uint) ([]entity.LayingTransferTarget, error) + GetActiveDownstreamConsumptions(ctx context.Context, targetIDs []uint) ([]TargetDownstreamConsumption, error) + GetEarliestRecordingDateByTarget(ctx context.Context, targetProjectFlockKandangID uint, sinceDate time.Time) (*time.Time, error) + CountActiveTransferSourceConsumeAllocations(ctx context.Context, transferID uint, productWarehouseID uint) (int64, error) + SyncPopulationUsageByProjectFlockKandang(ctx context.Context, projectFlockKandangID uint) error } type LayingTransferTargetRepositoryImpl struct { @@ -18,6 +25,11 @@ type LayingTransferTargetRepositoryImpl struct { db *gorm.DB } +type TargetDownstreamConsumption struct { + UsableType string `gorm:"column:usable_type"` + UsableID uint `gorm:"column:usable_id"` +} + func NewLayingTransferTargetRepository(db *gorm.DB) LayingTransferTargetRepository { return &LayingTransferTargetRepositoryImpl{ BaseRepositoryImpl: repository.NewBaseRepository[entity.LayingTransferTarget](db), @@ -36,3 +48,123 @@ func (r *LayingTransferTargetRepositoryImpl) GetByLayingTransferId(ctx context.C } return targets, nil } + +func (r *LayingTransferTargetRepositoryImpl) GetActiveDownstreamConsumptions(ctx context.Context, targetIDs []uint) ([]TargetDownstreamConsumption, error) { + if len(targetIDs) == 0 { + return nil, nil + } + + var rows []TargetDownstreamConsumption + err := r.db.WithContext(ctx). + Table("stock_allocations"). + Select("usable_type, usable_id"). + Where("stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()). + Where("stockable_id IN ?", targetIDs). + Where("status = ?", entity.StockAllocationStatusActive). + Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). + Where("deleted_at IS NULL"). + Group("usable_type, usable_id"). + Scan(&rows).Error + if err != nil { + return nil, err + } + return rows, nil +} + +func (r *LayingTransferTargetRepositoryImpl) GetEarliestRecordingDateByTarget(ctx context.Context, targetProjectFlockKandangID uint, sinceDate time.Time) (*time.Time, error) { + if targetProjectFlockKandangID == 0 { + return nil, nil + } + + var earliest entity.Recording + query := r.db.WithContext(ctx). + Model(&entity.Recording{}). + Where("project_flock_kandangs_id = ?", targetProjectFlockKandangID). + Where("deleted_at IS NULL") + if !sinceDate.IsZero() { + query = query.Where("record_datetime >= ?", sinceDate) + } + if err := query.Order("record_datetime ASC").Limit(1).Take(&earliest).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + + d := earliest.RecordDatetime.UTC() + return &d, nil +} + +func (r *LayingTransferTargetRepositoryImpl) CountActiveTransferSourceConsumeAllocations(ctx context.Context, transferID uint, productWarehouseID uint) (int64, error) { + if transferID == 0 || productWarehouseID == 0 { + return 0, nil + } + + var count int64 + err := r.db.WithContext(ctx). + Model(&entity.StockAllocation{}). + Where("product_warehouse_id = ?", productWarehouseID). + Where("usable_type = ?", fifo.UsableKeyTransferToLayingOut.String()). + Where("usable_id = ?", transferID). + Where("status = ?", entity.StockAllocationStatusActive). + Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). + Count(&count).Error + if err != nil { + return 0, err + } + return count, nil +} + +func (r *LayingTransferTargetRepositoryImpl) SyncPopulationUsageByProjectFlockKandang(ctx context.Context, projectFlockKandangID uint) error { + if projectFlockKandangID == 0 { + return nil + } + + var populationIDs []uint + if err := r.db.WithContext(ctx). + Table("project_flock_populations pfp"). + Select("pfp.id"). + Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). + Where("pc.project_flock_kandang_id = ?", projectFlockKandangID). + Pluck("pfp.id", &populationIDs).Error; err != nil { + return err + } + if len(populationIDs) == 0 { + return nil + } + + type usageRow struct { + StockableID uint `gorm:"column:stockable_id"` + Used float64 `gorm:"column:used"` + } + var usageRows []usageRow + if err := r.db.WithContext(ctx). + Table("stock_allocations"). + Select("stockable_id, COALESCE(SUM(qty), 0) AS used"). + Where("stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()). + Where("status = ?", entity.StockAllocationStatusActive). + Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). + Where("stockable_id IN ?", populationIDs). + Group("stockable_id"). + Scan(&usageRows).Error; err != nil { + return err + } + + if err := r.db.WithContext(ctx). + Model(&entity.ProjectFlockPopulation{}). + Where("id IN ?", populationIDs). + Update("total_used_qty", 0).Error; err != nil { + return err + } + + for _, row := range usageRows { + if err := r.db.WithContext(ctx). + Model(&entity.ProjectFlockPopulation{}). + Where("id = ?", row.StockableID). + Update("total_used_qty", row.Used).Error; err != nil { + return err + } + } + + return nil +} 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 1f3300b5..ce267544 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "sort" "strings" "time" @@ -56,12 +57,12 @@ type transferLayingService struct { WarehouseRepo rWarehouse.WarehouseRepository StockLogRepo rStockLogs.StockLogRepository ApprovalService commonSvc.ApprovalService - FifoSvc commonSvc.FifoService FifoStockV2Svc commonSvc.FifoStockV2Service } const ( - transferToLayingFlagGroupCode = "AYAM" + transferToLayingFlagGroupCode = "AYAM" + transferLayingDeleteDownstreamGuardMessage = "Transfer laying tidak dapat dihapus karena stok target transfer sudah dipakai transaksi turunan. Hapus dependensi terkait secara manual terlebih dahulu." ) func NewTransferLayingService( @@ -74,7 +75,6 @@ func NewTransferLayingService( productWarehouseRepo rInventory.ProductWarehouseRepository, warehouseRepo rWarehouse.WarehouseRepository, approvalService commonSvc.ApprovalService, - fifoSvc commonSvc.FifoService, fifoStockV2Svc commonSvc.FifoStockV2Service, validate *validator.Validate, ) TransferLayingService { @@ -91,7 +91,6 @@ func NewTransferLayingService( WarehouseRepo: warehouseRepo, StockLogRepo: rStockLogs.NewStockLogRepository(repo.DB()), ApprovalService: approvalService, - FifoSvc: fifoSvc, FifoStockV2Svc: fifoStockV2Svc, } } @@ -610,6 +609,9 @@ func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error { if isLegacyTransfer(transfer) { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Transfer laying legacy %s tidak dapat dihapus", transfer.TransferNumber)) } + if err := s.ensureNoDownstreamConsumptionForDelete(c.Context(), nil, transfer.TransferNumber, transfer.Targets); err != nil { + return err + } approvalRepo := commonRepo.NewApprovalRepository(s.Repository.DB()) @@ -635,6 +637,16 @@ 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) + // Lock header row to keep delete deterministic after single downstream guard check. + if _, err := repoTx.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + return db.Clauses(clause.Locking{Strength: "UPDATE"}) + }); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "TransferLaying not found") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer laying") + } + if err := repoTx.DeleteOne(c.Context(), id); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete transfer laying") } @@ -1053,6 +1065,11 @@ func (s transferLayingService) Unexecute(c *fiber.Ctx, id uint) (*entity.LayingT ) } + type targetReflowKey struct { + productWarehouseID uint + } + targetReflow := make(map[targetReflowKey]struct{}) + for _, target := range targets { if target.ProductWarehouseId == nil || *target.ProductWarehouseId == 0 { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", transfer.Id)) @@ -1060,15 +1077,6 @@ func (s transferLayingService) Unexecute(c *fiber.Ctx, id uint) (*entity.LayingT if target.TotalQty <= 0 { continue } - if err := s.FifoSvc.AdjustStockableQuantity(c.Context(), commonSvc.StockAdjustRequest{ - StockableKey: fifo.StockableKeyTransferToLayingIn, - StockableID: target.Id, - ProductWarehouseID: *target.ProductWarehouseId, - Quantity: -target.TotalQty, - Tx: dbTransaction, - }); err != nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback stok target transfer laying: %v", err)) - } stockLogDecrease := &entity.StockLog{ ProductWarehouseId: *target.ProductWarehouseId, @@ -1092,6 +1100,20 @@ func (s transferLayingService) Unexecute(c *fiber.Ctx, id uint) (*entity.LayingT if err := stockLogRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar target saat unexecute") } + + if err := targetRepoTx.PatchOne(c.Context(), target.Id, map[string]any{ + "total_qty": 0, + "total_used": 0, + }, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal rollback kuantitas target transfer laying") + } + targetReflow[targetReflowKey{productWarehouseID: *target.ProductWarehouseId}] = struct{}{} + } + asOf := normalizeDateOnlyUTC(transfer.TransferDate) + for key := range targetReflow { + if err := reflowTransferLayingScope(c.Context(), s.FifoStockV2Svc, dbTransaction, key.productWarehouseID, &asOf); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback FIFO v2 target transfer laying: %v", err)) + } } rollbackResult, err := s.FifoStockV2Svc.Rollback(c.Context(), commonSvc.FifoStockV2RollbackRequest{ @@ -1229,9 +1251,6 @@ func (s *transferLayingService) executeApprovedTransferMovement( if s.FifoStockV2Svc == nil { return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available") } - if s.FifoSvc == nil { - return fiber.NewError(fiber.StatusInternalServerError, "FIFO service is not available") - } stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx) targetRepoTx := repository.NewLayingTransferTargetRepository(tx) @@ -1327,29 +1346,22 @@ func (s *transferLayingService) executeApprovedTransferMovement( return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar") } + type targetReflowKey struct { + productWarehouseID uint + } + targetReflow := make(map[targetReflowKey]struct{}) + for _, target := range targets { if target.ProductWarehouseId == nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", transfer.Id)) } - note := fmt.Sprintf("Transfer to Laying #%s", transfer.TransferNumber) - _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ - StockableKey: fifo.StockableKeyTransferToLayingIn, - StockableID: target.Id, - ProductWarehouseID: *target.ProductWarehouseId, - Quantity: target.TotalQty, - Note: ¬e, - Tx: tx, - }) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal replenish stock ke target warehouse: %v", err)) - } - if err := targetRepoTx.PatchOne(ctx, target.Id, map[string]any{ "total_qty": target.TotalQty, }, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal update target total qty") } + targetReflow[targetReflowKey{productWarehouseID: *target.ProductWarehouseId}] = struct{}{} stockLogIncrease := &entity.StockLog{ ProductWarehouseId: *target.ProductWarehouseId, @@ -1376,6 +1388,11 @@ func (s *transferLayingService) executeApprovedTransferMovement( return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk") } } + for key := range targetReflow { + if err := reflowTransferLayingScope(ctx, s.FifoStockV2Svc, tx, key.productWarehouseID, &asOf); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow FIFO v2 target transfer laying: %v", err)) + } + } return nil } @@ -1595,31 +1612,19 @@ func (s *transferLayingService) hasDownstreamRecordingOnTarget( targetProjectFlockKandangID uint, sinceDate time.Time, ) (bool, time.Time, error) { - if targetProjectFlockKandangID == 0 { - return false, time.Time{}, nil - } - - db := s.Repository.DB().WithContext(ctx) + targetRepo := s.LayingTransferTargetRepo if tx != nil { - db = tx.WithContext(ctx) + targetRepo = repository.NewLayingTransferTargetRepository(tx) } - var earliest entity.Recording - query := db.Model(&entity.Recording{}). - Where("project_flock_kandangs_id = ?", targetProjectFlockKandangID). - Where("deleted_at IS NULL") - if !sinceDate.IsZero() { - query = query.Where("record_datetime >= ?", sinceDate) - } - - if err := query.Order("record_datetime ASC").Limit(1).Take(&earliest).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return false, time.Time{}, nil - } + recordDate, err := targetRepo.GetEarliestRecordingDateByTarget(ctx, targetProjectFlockKandangID, sinceDate) + if err != nil { return false, time.Time{}, err } - - return true, normalizeDateOnlyUTC(earliest.RecordDatetime), nil + if recordDate == nil { + return false, time.Time{}, nil + } + return true, normalizeDateOnlyUTC(*recordDate), nil } func (s *transferLayingService) countActiveTransferSourceConsumeAllocations( @@ -1628,81 +1633,45 @@ func (s *transferLayingService) countActiveTransferSourceConsumeAllocations( transferID uint, productWarehouseID uint, ) (int64, error) { - if transferID == 0 || productWarehouseID == 0 { - return 0, nil + targetRepo := s.LayingTransferTargetRepo + if tx != nil { + targetRepo = repository.NewLayingTransferTargetRepository(tx) } - if tx == nil { - return 0, errors.New("transaction is required") - } - - var count int64 - if err := tx.WithContext(ctx). - Model(&entity.StockAllocation{}). - Where("product_warehouse_id = ?", productWarehouseID). - Where("usable_type = ?", fifo.UsableKeyTransferToLayingOut.String()). - Where("usable_id = ?", transferID). - Where("status = ?", entity.StockAllocationStatusActive). - Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). - Count(&count).Error; err != nil { - return 0, err - } - - return count, nil + return targetRepo.CountActiveTransferSourceConsumeAllocations(ctx, transferID, productWarehouseID) } func (s *transferLayingService) resyncPopulationUsageByProjectFlockKandang(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error { - if projectFlockKandangID == 0 { - return nil - } - - db := s.Repository.DB().WithContext(ctx) + targetRepo := s.LayingTransferTargetRepo if tx != nil { - db = tx.WithContext(ctx) + targetRepo = repository.NewLayingTransferTargetRepository(tx) } + return targetRepo.SyncPopulationUsageByProjectFlockKandang(ctx, projectFlockKandangID) +} - var populationIDs []uint - if err := db.Table("project_flock_populations pfp"). - Select("pfp.id"). - Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). - Where("pc.project_flock_kandang_id = ?", projectFlockKandangID). - Pluck("pfp.id", &populationIDs).Error; err != nil { - return err - } - if len(populationIDs) == 0 { +func sortedIDs(input map[uint]struct{}) []uint { + if len(input) == 0 { return nil } - - type usageRow struct { - StockableID uint `gorm:"column:stockable_id"` - Used float64 `gorm:"column:used"` - } - var usageRows []usageRow - if err := db.Table("stock_allocations"). - Select("stockable_id, COALESCE(SUM(qty), 0) AS used"). - Where("stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()). - Where("status = ?", entity.StockAllocationStatusActive). - Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). - Where("stockable_id IN ?", populationIDs). - Group("stockable_id"). - Scan(&usageRows).Error; err != nil { - return err - } - - if err := db.Model(&entity.ProjectFlockPopulation{}). - Where("id IN ?", populationIDs). - Update("total_used_qty", 0).Error; err != nil { - return err - } - - for _, row := range usageRows { - if err := db.Model(&entity.ProjectFlockPopulation{}). - Where("id = ?", row.StockableID). - Update("total_used_qty", row.Used).Error; err != nil { - return err + out := make([]uint, 0, len(input)) + for id := range input { + if id == 0 { + continue } + out = append(out, id) } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} - return nil +func joinUint(values []uint) string { + if len(values) == 0 { + return "-" + } + parts := make([]string, 0, len(values)) + for _, value := range values { + parts = append(parts, fmt.Sprintf("%d", value)) + } + return strings.Join(parts, "|") } func normalizeDateOnlyUTC(value time.Time) time.Time { @@ -1721,3 +1690,84 @@ func isLegacyTransfer(transfer *entity.LayingTransfer) bool { } return false } + +func (s *transferLayingService) ensureNoDownstreamConsumptionForDelete( + ctx context.Context, + tx *gorm.DB, + transferNumber string, + targets []entity.LayingTransferTarget, +) error { + targetIDs := make([]uint, 0, len(targets)) + for _, target := range targets { + if target.Id == 0 { + continue + } + targetIDs = append(targetIDs, target.Id) + } + if len(targetIDs) == 0 { + return nil + } + + targetRepo := s.LayingTransferTargetRepo + if tx != nil { + targetRepo = repository.NewLayingTransferTargetRepository(tx) + } + + rows, err := targetRepo.GetActiveDownstreamConsumptions(ctx, targetIDs) + if err != nil { + s.Log.Errorf("Failed to validate downstream consumption for transfer laying %s: %+v", transferNumber, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transaksi turunan transfer laying") + } + if len(rows) == 0 { + return nil + } + + dependencyMap := make(map[string]map[uint]struct{}) + for _, row := range rows { + label := mapTransferLayingDownstreamUsableLabel(row.UsableType) + if _, ok := dependencyMap[label]; !ok { + dependencyMap[label] = make(map[uint]struct{}) + } + dependencyMap[label][row.UsableID] = struct{}{} + } + + labels := make([]string, 0, len(dependencyMap)) + for label := range dependencyMap { + labels = append(labels, label) + } + sort.Strings(labels) + + details := make([]string, 0, len(labels)) + for _, label := range labels { + details = append(details, fmt.Sprintf("%s=%s", label, joinUint(sortedIDs(dependencyMap[label])))) + } + + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf( + "%s Transfer %s. Dependensi aktif: %s.", + transferLayingDeleteDownstreamGuardMessage, + transferNumber, + strings.Join(details, ", "), + ), + ) +} + +func mapTransferLayingDownstreamUsableLabel(usableType string) string { + switch strings.ToUpper(strings.TrimSpace(usableType)) { + case fifo.UsableKeyRecordingStock.String(), fifo.UsableKeyRecordingDepletion.String(): + return "Recording" + case fifo.UsableKeyProjectChickin.String(): + return "Chickin" + case fifo.UsableKeyMarketingDelivery.String(): + return "Marketing" + case fifo.UsableKeyTransferToLayingOut.String(): + return "TransferToLaying" + case fifo.UsableKeyStockTransferOut.String(): + return "TransferStock" + case fifo.UsableKeyAdjustmentOut.String(): + return "Adjustment" + default: + return strings.ToUpper(strings.TrimSpace(usableType)) + } +}