package service import ( "context" "errors" "fmt" "strings" 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" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" "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) (*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 } 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) 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, } } 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("Deliveries.Items"). Preload("Deliveries.Supplier") } 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 != "" { db = db.Where("movement_number LIKE ?", "%"+strings.TrimSpace(params.Search)+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) if err != nil { return nil, 0, err } s.Log.Infof("Retrieved %d transfers", len(transfers)) return transfers, total, nil } func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) { var transfer entity.StockTransfer // gunakan repo secara langsung 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, "Transfer not found") } s.Log.Errorf("Failed to get transfer by ID: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer") } s.Log.Infof("Retrieved transfer: %+v", transfer) return transferPtr, nil } func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest) (*entity.StockTransfer, error) { // Validasi stok di gudang asal harus exist dan mencukupi 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("Stok produk %d tidak tersedia di gudang asal", product.ProductID)) } return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal cek stok produk di gudang asal") } if sourcePW.Quantity < product.ProductQty { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d di gudang asal tidak cukup", product.ProductID)) } } actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err } // validasi total qty harus lebih besar dari atau sama dengan total qty di delivery compare berdasarkan productid deliveryQtyMap := make(map[uint]float64) for _, delivery := range req.Deliveries { for _, prod := range delivery.Products { deliveryQtyMap[prod.ProductID] += prod.ProductQty } } // Cek: qty delivery tidak boleh melebihi qty di root 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)) } } // cek suplier id caegory BOP cek by id 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, "Gagal cek data supplier") } if supplier.Category != "BOP" { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier dengan ID %d bukan kategori BOP", delivery.SupplierID)) } } // Generate movement number // Format: PND-MBU-00001 seqNum, err := s.StockTransferRepo.GetNextMovementNumber(c.Context()) if err != nil { s.Log.Errorf("Failed to get next movement number: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate movement number") } movementNumber := fmt.Sprintf("PND-MBU-%05d", seqNum) 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), } // Save the transfer entity to the database err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { // Insert header if err := s.StockTransferRepo.WithTx(tx).CreateOne(c.Context(), entityTransfer, nil); err != nil { s.Log.Errorf("Failed to create stock transfer: %+v", err) return err } s.Log.Infof("Stock transfer created: %+v", entityTransfer.Id) // insert ke details var details []*entity.StockTransferDetail for _, product := range req.Products { details = append(details, &entity.StockTransferDetail{ StockTransferId: entityTransfer.Id, ProductId: uint64(product.ProductID), Quantity: product.ProductQty, }) } if err := s.StockTransferDetailRepo.WithTx(tx).CreateMany(c.Context(), details, nil); err != nil { s.Log.Errorf("Failed to create stock transfer details: %+v", err) return err } s.Log.Infof("Stock transfer details created for transfer ID: %+v", entityTransfer.Id) // Tambahkan proses insert delivery 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, DocumentPath: "https://tourism.gov.in/sites/default/files/2019-04/dummy-pdf_2.pdf", // todo: tunggu ada aws baru proses ShippingCostItem: delivery.DeliveryCostPerItem, ShippingCostTotal: delivery.DeliveryCost, }) } if err := s.StockTransferDeliveryRepo.WithTx(tx).CreateMany(c.Context(), deliveries, nil); err != nil { s.Log.Errorf("Failed to create stock transfer deliveries: %+v", err) return err } // tambahkan insert ke delivery items sebagai pivot detailMap := make(map[uint64]uint64) for _, d := range details { detailMap[d.ProductId] = d.Id } var deliveryItems []*entity.StockTransferDeliveryItem for i, delivery := range deliveries { item := req.Deliveries[i] for _, prod := range item.Products { detailID, ok := detailMap[uint64(prod.ProductID)] if !ok { return fmt.Errorf("produk %d tidak ditemukan di detail", prod.ProductID) } deliveryItems = append(deliveryItems, &entity.StockTransferDeliveryItem{ StockTransferDeliveryId: delivery.Id, StockTransferDetailId: detailID, Quantity: prod.ProductQty, }) } } if err := s.StockTransferDeliveryItemRepo.WithTx(tx).CreateMany(c.Context(), deliveryItems, nil); err != nil { s.Log.Errorf("Failed to create stock transfer delivery items: %+v", err) return err } s.Log.Infof("Stock transfer delivery items created for transfer ID: %+v", entityTransfer.Id) // Proses pengurangan stok di gudang asal dan penambahan stok di gudang tujuan for _, product := range req.Products { // Kurangi stok di gudang asal sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID)) if err != nil { s.Log.Errorf("Failed to get source product warehouse: %+v", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to get source product warehouse") } if sourcePW.Quantity < product.ProductQty { s.Log.Errorf("Insufficient stock in source warehouse for product ID: %+v", product.ProductID) return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock in source warehouse for product ID: %d", product.ProductID)) } sourcePW.Quantity -= product.ProductQty if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), sourcePW.Id, sourcePW, nil); err != nil { s.Log.Errorf("Failed to update source product warehouse: %+v", err) return err } s.Log.Infof("Source product warehouse updated: %+v", sourcePW.Id) // create stock log for decrease (source) // beforeQty := sourcePW.Quantity + product.ProductQty // sourcePW already decreased decreaseLog := &entity.StockLog{ // TransactionType: entity.TransactionTypeDecrease, // Quantity: product.ProductQty, // BeforeQuantity: beforeQty, // AfterQuantity: sourcePW.Qty, // LogType: entity.LogTypeTransfer, // LogId: uint(entityTransfer.Id), Decrease: product.ProductQty, Notes: "", LoggableType: entity.LogTypeTransfer, LoggableId: uint(entityTransfer.Id), ProductWarehouseId: sourcePW.Id, CreatedBy: actorID, } if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), decreaseLog, nil); err != nil { s.Log.Errorf("Failed to create stock log decrease: %+v", err) return err } // Tambah stok di gudang tujuan destPW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), ) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { s.Log.Errorf("Failed to get destination product warehouse: %+v", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to get destination product warehouse") } if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { // Jika belum ada record untuk produk di gudang tujuan, buat baru ctx := c.Context() projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.DestinationWarehouseID)) if err != nil { return err } destPW = &entity.ProductWarehouse{ ProductId: uint(product.ProductID), WarehouseId: uint(req.DestinationWarehouseID), Quantity: 0, ProjectFlockKandangId: &projectFlockKandangID, // CreatedBy: 1, // TODO: should Get from auth middleware } if err := s.ProductWarehouseRepo.WithTx(tx).CreateOne(c.Context(), destPW, nil); err != nil { s.Log.Errorf("Failed to create destination product warehouse: %+v", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to create destination product warehouse") } s.Log.Infof("Destination product warehouse created: %+v", destPW.Id) } // Update stok di gudang tujuan destPW.Quantity += product.ProductQty if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), destPW.Id, destPW, nil); err != nil { s.Log.Errorf("Failed to update destination product warehouse: %+v", err) return err } s.Log.Infof("Destination product warehouse updated: %+v", destPW.Id) // create stock log for increase (destination) // beforeDestQty := destPW.Quantity - product.ProductQty increaseLog := &entity.StockLog{ // TransactionType: entity.TransactionTypeIncrease, // Quantity: product.ProductQty, // BeforeQuantity: beforeDestQty, // AfterQuantity: destPW.Qty, Increase: product.ProductQty, LoggableType: entity.LogTypeTransfer, LoggableId: uint(entityTransfer.Id), Notes: "", ProductWarehouseId: destPW.Id, CreatedBy: actorID, } if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), increaseLog, nil); err != nil { s.Log.Errorf("Failed to create stock log increase: %+v", err) return err } } return nil }) if err != nil { s.Log.Errorf("Transaction failed in CreateOne: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to process transfer transaction") } // Ambil data lengkap hasil create dengan GetOne (agar preload relasi sama dengan GetOne) result, err := s.GetOne(c, uint(entityTransfer.Id)) if err != nil { return nil, err } return result, nil } 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 get warehouse %d: %+v", warehouseID, err) return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data gudang") } if warehouse.KandangId == nil || *warehouse.KandangId == 0 { return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gudang %d belum terhubung ke kandang", warehouseID)) } 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("Kandang %d belum memiliki project flock aktif", *warehouse.KandangId)) } s.Log.Errorf("Failed to get active project flock for kandang %d: %+v", *warehouse.KandangId, err) return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil project flock kandang") } return uint(projectFlockKandang.Id), nil }