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" "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 { 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"` } 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) } 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) { pwIDs := make([]uint, 0, len(req.Products)) for _, product := range req.Products { sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID), ) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk dengan ID %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID)) } s.Log.Errorf("Failed to fetch product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.SourceWarehouseID, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengecek stok produk") } if sourcePW.Quantity < product.ProductQty { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d di gudang asal tidak mencukupi. Tersedia: %.2f, Diminta: %.2f", product.ProductID, sourcePW.Quantity, product.ProductQty)) } pwIDs = append(pwIDs, sourcePW.Id) } if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses( c.Context(), s.StockTransferRepo.DB(), pwIDs, ); err != nil { return nil, err } destPfkID, err := s.getActiveProjectFlockKandangID(c.Context(), uint(req.DestinationWarehouseID)) if err != nil { return nil, err } if destPfkID > 0 { projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID) if err != nil { s.Log.Errorf("Failed to fetch project flock kandang by ID %d: %+v", destPfkID, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock") } if projectFlockKandang.ClosedAt != nil { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Project flock untuk gudang tujuan sudah ditutup (closing) pada %s", projectFlockKandang.ClosedAt.Format("2006-01-02"))) } } 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)) } } movementNumber, err := s.StockTransferRepo.GenerateMovementNumber(c.Context()) if err != nil { s.Log.Errorf("Failed to generate movement number: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat nomor transfer") } transferDate, _ := utils.ParseDateString(req.TransferDate) entityTransfer := &entity.StockTransfer{ FromWarehouseId: uint64(req.SourceWarehouseID), ToWarehouseId: uint64(req.DestinationWarehouseID), Reason: req.TransferReason, TransferDate: transferDate, MovementNumber: movementNumber, CreatedBy: uint64(actorID), } expensePayloads := make([]TransferExpenseReceivingPayload, 0) err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { stockTransferRepoTX := s.StockTransferRepo.WithTx(tx) stockTransferDetailRepoTX := s.StockTransferDetailRepo.WithTx(tx) stockTransferDeliveryRepoTX := s.StockTransferDeliveryRepo.WithTx(tx) stockTransferDeliveryItemRepoTX := s.StockTransferDeliveryItemRepo.WithTx(tx) productWarehouseRepoTX := rProductWarehouse.NewProductWarehouseRepository(tx) stocklogsRepoTx := s.StockLogsRepository.WithTx(tx) if err := stockTransferRepoTX.CreateOne(c.Context(), entityTransfer, nil); err != nil { return err } details := make([]*entity.StockTransferDetail, 0, len(req.Products)) detailMap := make(map[uint64]*entity.StockTransferDetail) for _, product := range req.Products { sourcePW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID( c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID), ) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID)) } s.Log.Errorf("Failed to fetch source product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.SourceWarehouseID, err) return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang asal") } destPW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID( c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), ) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { s.Log.Errorf("Failed to fetch dest product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err) return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang tujuan") } if errors.Is(err, gorm.ErrRecordNotFound) { ctx := c.Context() projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.DestinationWarehouseID)) if err != nil { return err } var pfkID *uint if projectFlockKandangID > 0 { pfkID = &projectFlockKandangID } destPW = &entity.ProductWarehouse{ ProductId: uint(product.ProductID), WarehouseId: uint(req.DestinationWarehouseID), Quantity: 0, ProjectFlockKandangId: pfkID, } if err := productWarehouseRepoTX.CreateOne(c.Context(), destPW, nil); err != nil { s.Log.Errorf("Failed to create product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err) return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat data stok gudang tujuan") } } detail := &entity.StockTransferDetail{ StockTransferId: entityTransfer.Id, ProductId: uint64(product.ProductID), SourceProductWarehouseID: func() *uint64 { id := uint64(sourcePW.Id); return &id }(), UsageQty: 0, PendingQty: 0, DestProductWarehouseID: func() *uint64 { id := uint64(destPW.Id); return &id }(), TotalQty: 0, TotalUsed: 0, } details = append(details, detail) detailMap[uint64(product.ProductID)] = detail } if err := stockTransferDetailRepoTX.CreateMany(c.Context(), details, nil); err != nil { return err } 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: entityTransfer.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") } } } if s.FifoStockV2Svc == nil { return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available") } flagGroupByProduct := make(map[uint]string, len(req.Products)) for _, product := range req.Products { detail := detailMap[uint64(product.ProductID)] if detail == nil || detail.SourceProductWarehouseID == nil || detail.DestProductWarehouseID == nil { return fiber.NewError(fiber.StatusInternalServerError, "Data transfer detail tidak valid") } flagGroupCode, ok := flagGroupByProduct[uint(product.ProductID)] if !ok { flagGroupCode, err = s.resolveTransferFlagGroup(c.Context(), tx, uint(product.ProductID)) if err != nil { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("FIFO v2 route tidak ditemukan untuk produk %d: %v", product.ProductID, err)) } flagGroupByProduct[uint(product.ProductID)] = flagGroupCode } if err := tx.Model(&entity.StockTransferDetail{}). Where("id = ?", detail.Id). Updates(map[string]interface{}{ "usage_qty": product.ProductQty, "pending_qty": 0, "total_qty": product.ProductQty, }).Error; err != nil { s.Log.Errorf("Failed to update transfer detail seed fields for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err) return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking") } asOf := transferDate if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{ FlagGroupCode: flagGroupCode, ProductWarehouseID: uint(*detail.SourceProductWarehouseID), AsOf: &asOf, Tx: tx, }); err != nil { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal. Error: %v", product.ProductID, err)) } if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{ FlagGroupCode: flagGroupCode, ProductWarehouseID: uint(*detail.DestProductWarehouseID), AsOf: &asOf, Tx: tx, }); err != nil { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow stok tujuan untuk produk %d. Error: %v", product.ProductID, err)) } type usageSnapshot struct { UsageQty float64 `gorm:"column:usage_qty"` PendingQty float64 `gorm:"column:pending_qty"` } var usage usageSnapshot if err := tx.WithContext(c.Context()). Table("stock_transfer_details"). Select("usage_qty, pending_qty"). Where("id = ?", detail.Id). Take(&usage).Error; err != nil { s.Log.Errorf("Failed to read transfer usage snapshot detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err) return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data tracking") } outUsageQty := usage.UsageQty outPendingQty := usage.PendingQty if outPendingQty > 1e-6 { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal", product.ProductID)) } stockLogDecrease := &entity.StockLog{ ProductWarehouseId: uint(*detail.SourceProductWarehouseID), CreatedBy: uint(actorID), Increase: 0, Decrease: outUsageQty, LoggableType: string(utils.StockLogTypeTransfer), LoggableId: uint(detail.Id), Notes: "", } stockLogs, err := s.StockLogsRepository.GetByProductWarehouse(c.Context(), uint(*detail.SourceProductWarehouseID), 1) if err != nil { s.Log.Errorf("Failed to get stock logs: %+v", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") } if len(stockLogs) > 0 { latestStockLog := stockLogs[0] stockLogDecrease.Stock = latestStockLog.Stock - stockLogDecrease.Decrease } else { stockLogDecrease.Stock -= stockLogDecrease.Decrease } if err := stocklogsRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar") } inAddedQty := outUsageQty stockLogIncrease := &entity.StockLog{ ProductWarehouseId: uint(*detail.DestProductWarehouseID), CreatedBy: uint(actorID), Increase: inAddedQty, Decrease: 0, LoggableType: string(utils.StockLogTypeTransfer), LoggableId: uint(detail.Id), Notes: "", } stockLogs, err = s.StockLogsRepository.GetByProductWarehouse(c.Context(), uint(*detail.DestProductWarehouseID), 1) if err != nil { s.Log.Errorf("Failed to get stock logs: %+v", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") } if len(stockLogs) > 0 { latestStockLog := stockLogs[0] stockLogIncrease.Stock = latestStockLog.Stock + stockLogIncrease.Increase } else { stockLogIncrease.Stock += stockLogIncrease.Increase } if err := stocklogsRepoTx.CreateOne(c.Context(), stockLogIncrease, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk") } } if len(req.Deliveries) > 0 { for _, delivery := range req.Deliveries { // Skip adding to expensePayloads if SupplierID is 0 (optional) 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 deliveredQty := prod.ProductQty payload := TransferExpenseReceivingPayload{ TransferDetailID: detail.Id, ProductID: uint64(prod.ProductID), WarehouseID: uint64(warehouseID), SupplierID: uint64(supplierID), DeliveredQty: deliveredQty, DeliveredDate: &deliveredDate, } expensePayloads = append(expensePayloads, payload) } } } 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(entityTransfer.Id)) if err != nil { return nil, err } if len(expensePayloads) > 0 { if err := s.notifyExpenseItemsDelivered(c, entityTransfer.Id, expensePayloads); err != nil { s.Log.Errorf("Failed to sync expense for transfer_id=%d, movement_number=%s: %+v", entityTransfer.Id, entityTransfer.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 { 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.ensureDeletePolicyForDownstreamConsumption(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, 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 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 = ? AND fm.flag_group_code = rr.flag_group_code ) `, entity.FlagableTypeProduct, productID). 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 }