Files
lti-api/internal/modules/inventory/transfers/services/transfer.service.go
T
2026-04-25 12:15:55 +07:00

806 lines
29 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"mime/multipart"
"sort"
"strings"
"time"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/validations"
rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories"
warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
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"
)
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
CreateSystemTransfer(ctx context.Context, req *SystemTransferRequest) (*entity.StockTransfer, error)
DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error
}
type transferService struct {
Log *logrus.Logger
Validate *validator.Validate
StockTransferRepo rStockTransfer.StockTransferRepository
StockTransferDetailRepo rStockTransfer.StockTransferDetailRepository
StockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository
StockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository
StockLogsRepository 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
}
const transferDeleteDownstreamGuardMessage = "Transfer stock tidak dapat dihapus karena stok transfer sudah dipakai transaksi turunan. Hapus dependensi terkait secara manual terlebih dahulu."
type downstreamDependency struct {
UsableType string `gorm:"column:usable_type"`
UsableID uint64 `gorm:"column:usable_id"`
FunctionCode string `gorm:"column:function_code"`
FlagGroupCode string `gorm:"column:flag_group_code"`
}
type SystemTransferProduct struct {
ProductID uint
ProductQty float64
}
type SystemTransferRequest struct {
TransferReason string
TransferDate time.Time
SourceWarehouseID uint
DestinationWarehouseID uint
Products []SystemTransferProduct
ActorID uint
MovementNumber string
StockLogNotes string
}
type transferMovementResult struct {
Transfer *entity.StockTransfer
DetailByPID map[uint64]*entity.StockTransferDetail
}
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,
Validate: validate,
StockTransferRepo: stockTransferRepo,
StockTransferDetailRepo: stockTransferDetailRepo,
StockTransferDeliveryRepo: stockTransferDeliveryRepo,
StockTransferDeliveryItemRepo: stockTransferDeliveryItemRepo,
StockLogsRepository: stockLogsRepo,
ProductWarehouseRepo: productWarehouseRepo,
SupplierRepo: supplierRepo,
WarehouseRepo: warehouseRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo,
ProjectFlockPopulationRepo: projectFlockPopulationRepo,
DocumentSvc: documentSvc,
FifoStockV2Svc: fifoStockV2Svc,
ExpenseBridge: expenseBridge,
}
}
func (s transferService) withRelations(db *gorm.DB) *gorm.DB {
return db.
Preload("CreatedUser").
Preload("FromWarehouse").
Preload("FromWarehouse.Location").
Preload("FromWarehouse.Area").
Preload("ToWarehouse").
Preload("ToWarehouse.Location").
Preload("ToWarehouse.Area").
Preload("Details").
Preload("Details.Product").
Preload("Details.ExpenseNonstock").
Preload("Details.ExpenseNonstock.Expense").
Preload("Details.ExpenseNonstock.Expense.Supplier").
Preload("Deliveries.Items").
Preload("Deliveries.Supplier").
Preload("Deliveries.Documents", func(db *gorm.DB) *gorm.DB {
return db.Where("documentable_type = ?", string(utils.DocumentableTypeTransfer))
})
}
func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
}
scope, err := m.ResolveLocationScope(c, s.StockTransferRepo.DB())
if err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit
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")
}
db = db.
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("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs)
}
if params.Search != "" {
searchTerm := "%" + strings.TrimSpace(params.Search) + "%"
db = db.Joins("LEFT JOIN warehouses AS from_warehouses ON from_warehouses.id = stock_transfers.from_warehouse_id").
Joins("LEFT JOIN warehouses AS to_warehouses ON to_warehouses.id = stock_transfers.to_warehouse_id").
Where("movement_number ILIKE ? OR from_warehouses.name ILIKE ? OR to_warehouses.name ILIKE ?",
searchTerm, searchTerm, searchTerm)
}
if params.ProductID > 0 {
db = db.Joins("JOIN stock_transfer_details AS filter_std ON filter_std.stock_transfer_id = stock_transfers.id AND filter_std.deleted_at IS NULL AND filter_std.product_id = ?", params.ProductID)
}
if params.WarehouseID > 0 {
db = db.Where("stock_transfers.from_warehouse_id = ? OR stock_transfers.to_warehouse_id = ?", params.WarehouseID, params.WarehouseID)
}
return db.Order("created_at DESC").Order("updated_at DESC")
})
if err != nil {
return nil, 0, err
}
return transfers, total, nil
}
func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) {
scope, err := m.ResolveLocationScope(c, s.StockTransferRepo.DB())
if err != nil {
return nil, err
}
if scope.Restrict {
if len(scope.IDs) == 0 {
return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found")
}
var count int64
if err := s.StockTransferRepo.DB().WithContext(c.Context()).
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 nil, err
}
if count == 0 {
return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found")
}
}
transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return s.withRelations(db).Where("stock_transfers.deleted_at IS NULL")
})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Transfer dengan ID %d tidak ditemukan", id))
}
s.Log.Errorf("Failed to fetch transfer by ID %d: %+v", id, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data transfer")
}
return transferPtr, nil
}
func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) {
products := make([]SystemTransferProduct, 0, len(req.Products))
for _, product := range req.Products {
products = append(products, SystemTransferProduct{
ProductID: uint(product.ProductID),
ProductQty: product.ProductQty,
})
}
if err := s.validateTransferWarehousesAndProducts(c.Context(), uint(req.SourceWarehouseID), uint(req.DestinationWarehouseID), products); err != nil {
return nil, err
}
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
deliveryQtyMap := make(map[uint]float64)
for _, delivery := range req.Deliveries {
for _, prod := range delivery.Products {
deliveryQtyMap[prod.ProductID] += prod.ProductQty
}
}
for _, product := range req.Products {
if deliveryQtyMap[product.ProductID] > product.ProductQty {
return nil, fiber.NewError(fiber.StatusBadRequest,
fmt.Sprintf("Total qty delivery untuk produk %d (%v) melebihi qty transfer (%v)", product.ProductID, deliveryQtyMap[product.ProductID], product.ProductQty))
}
}
for _, delivery := range req.Deliveries {
if delivery.SupplierID == 0 {
continue
}
if delivery.VehiclePlate == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "Vehicle plate wajib diisi ketika supplier dipilih")
}
if delivery.DriverName == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "Driver name wajib diisi ketika supplier dipilih")
}
if delivery.DeliveryCost <= 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Delivery cost harus lebih dari 0 ketika supplier dipilih")
}
if delivery.DeliveryCostPerItem <= 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Delivery cost per item harus lebih dari 0 ketika supplier dipilih")
}
supplier, err := s.SupplierRepo.GetByID(c.Context(), uint(delivery.SupplierID), nil)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier dengan ID %d tidak ditemukan", delivery.SupplierID))
}
s.Log.Errorf("Failed to fetch supplier by ID %d: %+v", delivery.SupplierID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data supplier")
}
if supplier.Category != string(utils.SupplierCategoryBOP) {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier '%s' (ID: %d) bukan kategori BOP. Kategori saat ini: %s", supplier.Name, delivery.SupplierID, supplier.Category))
}
}
transferDate, _ := utils.ParseDateString(req.TransferDate)
expensePayloads := make([]TransferExpenseReceivingPayload, 0)
var detailMap map[uint64]*entity.StockTransferDetail
var createdTransfer *entity.StockTransfer
err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
stockTransferDeliveryRepoTX := s.StockTransferDeliveryRepo.WithTx(tx)
stockTransferDeliveryItemRepoTX := s.StockTransferDeliveryItemRepo.WithTx(tx)
movementResult, err := s.createTransferMovement(c.Context(), tx, &SystemTransferRequest{
TransferReason: req.TransferReason,
TransferDate: transferDate,
SourceWarehouseID: uint(req.SourceWarehouseID),
DestinationWarehouseID: uint(req.DestinationWarehouseID),
Products: products,
ActorID: actorID,
})
if err != nil {
return err
}
detailMap = movementResult.DetailByPID
createdTransfer = movementResult.Transfer
var deliveries []*entity.StockTransferDelivery
for _, delivery := range req.Deliveries {
supplierId := func() *uint64 {
if delivery.SupplierID > 0 {
id := uint64(delivery.SupplierID)
return &id
}
return nil
}()
deliveries = append(deliveries, &entity.StockTransferDelivery{
StockTransferId: createdTransfer.Id,
SupplierId: supplierId,
VehiclePlate: delivery.VehiclePlate,
DriverName: delivery.DriverName,
ShippingCostItem: delivery.DeliveryCostPerItem,
ShippingCostTotal: delivery.DeliveryCost,
})
}
if err := stockTransferDeliveryRepoTX.CreateMany(c.Context(), deliveries, nil); err != nil {
return err
}
var deliveryItems []*entity.StockTransferDeliveryItem
for i, delivery := range deliveries {
item := req.Deliveries[i]
for _, prod := range item.Products {
detail, ok := detailMap[uint64(prod.ProductID)]
if !ok {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk %d tidak ditemukan dalam daftar transfer untuk delivery #%d", prod.ProductID, i+1))
}
deliveryItems = append(deliveryItems, &entity.StockTransferDeliveryItem{
StockTransferDeliveryId: delivery.Id,
StockTransferDetailId: detail.Id,
Quantity: prod.ProductQty,
})
}
}
if err := stockTransferDeliveryItemRepoTX.CreateMany(c.Context(), deliveryItems, nil); err != nil {
return err
}
if s.DocumentSvc != nil && len(files) > 0 {
for deliveryIdx, delivery := range deliveries {
reqDelivery := req.Deliveries[deliveryIdx]
if reqDelivery.DocumentIndex < 0 {
continue
}
if reqDelivery.DocumentIndex >= len(files) {
return fiber.NewError(fiber.StatusBadRequest,
fmt.Sprintf("DocumentIndex %d untuk delivery %d melebihi jumlah file yang diupload (%d)",
reqDelivery.DocumentIndex, deliveryIdx+1, len(files)))
}
file := files[reqDelivery.DocumentIndex]
documentFiles := []commonSvc.DocumentFile{{
File: file,
Type: string(utils.DocumentTypeTransfer),
Index: &reqDelivery.DocumentIndex,
}}
_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
DocumentableType: string(utils.DocumentableTypeTransfer),
DocumentableID: delivery.Id,
CreatedBy: &actorID,
Files: documentFiles,
})
if err != nil {
s.Log.Errorf("Failed to upload document for delivery %d (delivery_id=%d, filename=%s): %+v",
deliveryIdx+1, delivery.Id, file.Filename, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengunggah dokumen")
}
}
}
for _, delivery := range req.Deliveries {
if delivery.SupplierID == 0 {
continue
}
for _, prod := range delivery.Products {
detail := detailMap[uint64(prod.ProductID)]
if detail == nil {
continue
}
warehouseID := uint(req.DestinationWarehouseID)
supplierID := uint(delivery.SupplierID)
deliveredDate := transferDate
expensePayloads = append(expensePayloads, TransferExpenseReceivingPayload{
TransferDetailID: detail.Id,
ProductID: uint64(prod.ProductID),
WarehouseID: uint64(warehouseID),
SupplierID: uint64(supplierID),
DeliveredQty: prod.ProductQty,
DeliveredDate: &deliveredDate,
})
}
}
return nil
})
if err != nil {
if fiberErr, ok := err.(*fiber.Error); ok {
return nil, fiberErr
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Internal server error")
}
result, err := s.GetOne(c, uint(createdTransfer.Id))
if err != nil {
return nil, err
}
if len(expensePayloads) > 0 {
if err := s.notifyExpenseItemsDelivered(c, createdTransfer.Id, expensePayloads); err != nil {
s.Log.Errorf("Failed to sync expense for transfer_id=%d, movement_number=%s: %+v", createdTransfer.Id, createdTransfer.MovementNumber, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal sinkronisasi data expense. Silakan cek manual di module expense")
}
}
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 {
var err error
deletedDetails, err = s.deleteTransferCore(c.Context(), tx, uint64(id), actorID)
return err
})
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,
productID uint,
) (string, error) {
if productID == 0 {
return "", fmt.Errorf("product id is required")
}
type row struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
}
var selected row
err := tx.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 = ?", "USABLE").
Where("rr.function_code = ?", "STOCK_TRANSFER_OUT").
Where("rr.source_table = ?", "stock_transfer_details").
Where(`
EXISTS (
SELECT 1
FROM products p
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
WHERE p.id = ?
AND (
EXISTS (
SELECT 1
FROM flags f
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
WHERE f.flagable_type = ?
AND f.flagable_id = p.id
AND fm.flag_group_code = rr.flag_group_code
)
OR (
NOT EXISTS (
SELECT 1
FROM flags f_any
WHERE f_any.flagable_type = ?
AND f_any.flagable_id = p.id
)
AND rr.flag_group_code = ?
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
)
)
`, productID, entity.FlagableTypeProduct, entity.FlagableTypeProduct, utils.LegacyFlagGroupCodeByProductCategoryCode("EGG")).
Order("rr.id ASC").
Limit(1).
Take(&selected).Error
if err != nil {
return "", err
}
return strings.TrimSpace(selected.FlagGroupCode), nil
}
func (s *transferService) notifyExpenseItemsDelivered(c *fiber.Ctx, transferID uint64, payloads []TransferExpenseReceivingPayload) error {
if s.ExpenseBridge == nil || transferID == 0 || len(payloads) == 0 {
return nil
}
return s.ExpenseBridge.OnItemsDelivered(c, transferID, payloads)
}
func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) {
warehouse, err := s.WarehouseRepo.GetByID(ctx, warehouseID, nil)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Gudang dengan ID %d tidak ditemukan", warehouseID))
}
s.Log.Errorf("Failed to fetch warehouse by ID %d: %+v", warehouseID, err)
return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data gudang")
}
if warehouse.KandangId == nil || *warehouse.KandangId == 0 {
return 0, nil
}
projectFlockKandang, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*warehouse.KandangId))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Tidak ada project flock aktif untuk kandang %d", *warehouse.KandangId))
}
s.Log.Errorf("Failed to fetch active project flock kandang for kandang_id=%d: %+v", *warehouse.KandangId, err)
return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock")
}
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) ensureDeletePolicyForDownstreamConsumption(ctx context.Context, tx *gorm.DB, detailIDs []uint64) error {
dependencies, err := s.loadActiveTransferDownstreamDependencies(ctx, tx, detailIDs)
if err != nil {
s.Log.Errorf("Failed to load downstream stock transfer consumption: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transaksi turunan transfer stock")
}
if len(dependencies) == 0 {
return nil
}
ayamDependency, err := s.hasAyamDownstreamConsumption(ctx, tx, detailIDs)
if err != nil {
s.Log.Errorf("Failed to validate AYAM downstream dependency for transfer delete: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi dependensi AYAM pada transfer stock")
}
if ayamDependency {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"%s Dependensi aktif: %s. Alasan block: produk AYAM yang sudah terpakai tidak dapat dihapus.",
transferDeleteDownstreamGuardMessage,
formatDownstreamDependencySummary(dependencies),
),
)
}
denyReason := ""
for _, dep := range dependencies {
policy, policyErr := commonSvc.ResolveFifoPendingPolicy(ctx, tx, commonSvc.FifoPendingPolicyInput{
Lane: "USABLE",
FlagGroupCode: dep.FlagGroupCode,
FunctionCode: dep.FunctionCode,
LegacyTypeKey: dep.UsableType,
})
if policyErr != nil {
s.Log.Errorf("Failed to resolve FIFO pending policy for transfer dependency: %+v", policyErr)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membaca konfigurasi FIFO v2")
}
if !policy.Found || !policy.AllowPending {
denyReason = "pending disabled by config"
break
}
}
if denyReason == "" {
return nil
}
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"%s Dependensi aktif: %s. Alasan block: %s.",
transferDeleteDownstreamGuardMessage,
formatDownstreamDependencySummary(dependencies),
denyReason,
),
)
}
func (s *transferService) loadActiveTransferDownstreamDependencies(
ctx context.Context,
tx *gorm.DB,
detailIDs []uint64,
) ([]downstreamDependency, error) {
if len(detailIDs) == 0 {
return nil, nil
}
db := s.StockTransferRepo.DB().WithContext(ctx)
if tx != nil {
db = tx.WithContext(ctx)
}
var rows []downstreamDependency
err := db.Table("stock_allocations").
Select("usable_type, usable_id, COALESCE(function_code,'') AS function_code, COALESCE(flag_group_code,'') AS flag_group_code").
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, function_code, flag_group_code").
Scan(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
func formatDownstreamDependencySummary(rows []downstreamDependency) string {
if len(rows) == 0 {
return "-"
}
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 strings.Join(details, ", ")
}
func (s *transferService) hasAyamDownstreamConsumption(ctx context.Context, tx *gorm.DB, detailIDs []uint64) (bool, error) {
if len(detailIDs) == 0 {
return false, nil
}
db := s.StockTransferRepo.DB().WithContext(ctx)
if tx != nil {
db = tx.WithContext(ctx)
}
var found int64
err := db.Table("stock_allocations sa").
Joins("JOIN stock_transfer_details std ON std.id = sa.stockable_id AND std.deleted_at IS NULL").
Joins("JOIN flags f ON f.flagable_type = ? AND f.flagable_id = std.product_id", entity.FlagableTypeProduct).
Joins("JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.flag_group_code = ? AND fm.is_active = TRUE", "AYAM").
Where("sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()).
Where("sa.stockable_id IN ?", detailIDs).
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("sa.deleted_at IS NULL").
Count(&found).Error
if err != nil {
return false, err
}
return found > 0, nil
}
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
}