package service import ( "context" "errors" "fmt" "mime/multipart" "strings" "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) } 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 DocumentSvc commonSvc.DocumentService FifoSvc commonSvc.FifoService ExpenseBridge TransferExpenseBridge } 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, documentSvc commonSvc.DocumentService, fifoSvc commonSvc.FifoService, 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, DocumentSvc: documentSvc, FifoSvc: fifoSvc, 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 } 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) 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) { transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { return s.withRelations(db) }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Transfer dengan ID %d tidak ditemukan", id)) } return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data transfer dengan ID %d", id)) } 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)) } return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengecek stok produk %d di gudang asal", product.ProductID)) } 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 } projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock untuk gudang tujuan") } 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 { 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)) } return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data supplier dengan ID %d", delivery.SupplierID)) } 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 { return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat nomor movement 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) 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)) } return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data product warehouse untuk produk %d di gudang asal", product.ProductID)) } destPW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID( c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), ) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data product warehouse untuk produk %d di gudang tujuan", product.ProductID)) } if errors.Is(err, gorm.ErrRecordNotFound) { ctx := c.Context() projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.DestinationWarehouseID)) if err != nil { return err } // Set ProjectFlockKandangId hanya jika ada kandang 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 { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal membuat product warehouse untuk produk %d di gudang tujuan", product.ProductID)) } } 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 { deliveries = append(deliveries, &entity.StockTransferDelivery{ StockTransferId: entityTransfer.Id, SupplierId: uint64(delivery.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.WithError(err).Errorf("Failed to upload document for delivery %d (delivery_id: %d, filename: %s)", deliveryIdx+1, delivery.Id, file.Filename) return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to upload document for delivery %d: %v", deliveryIdx+1, err)) } } } for _, product := range req.Products { detail := detailMap[uint64(product.ProductID)] consumeResult, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{ UsableKey: fifo.UsableKeyStockTransferOut, UsableID: uint(detail.Id), ProductWarehouseID: uint(*detail.SourceProductWarehouseID), Quantity: product.ProductQty, AllowPending: false, Tx: tx, }) if 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 := tx.Model(&entity.StockTransferDetail{}). Where("id = ?", detail.Id). Updates(map[string]interface{}{ "usage_qty": consumeResult.UsageQuantity, "pending_qty": consumeResult.PendingQuantity, }).Error; err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengupdate tracking usage untuk produk %d", product.ProductID)) } note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber) replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ StockableKey: fifo.StockableKeyStockTransferIn, StockableID: uint(detail.Id), ProductWarehouseID: uint(*detail.DestProductWarehouseID), Quantity: product.ProductQty, Note: ¬e, Tx: tx, }) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal menambah stok untuk produk %d di gudang tujuan. Error: %v", product.ProductID, err)) } if err := tx.Model(&entity.StockTransferDetail{}). Where("id = ?", detail.Id). Updates(map[string]interface{}{ "total_qty": replenishResult.AddedQuantity, }).Error; err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengupdate tracking total untuk produk %d", product.ProductID)) } } if len(req.Deliveries) > 0 { for _, delivery := range req.Deliveries { 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 { return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal memproses transfer. Error: %v", err)) } 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 { return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal sinkronisasi data expense untuk transfer %s. Silakan cek manual di module expense", entityTransfer.MovementNumber)) } } return result, 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)) } return 0, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data gudang dengan ID %d", warehouseID)) } // Jika warehouse tidak punya kandang_id, return 0 tanpa error 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)) } return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock kandang yang aktif") } return uint(projectFlockKandang.Id), nil }