package service import ( "errors" "fmt" "time" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/google/uuid" "github.com/sirupsen/logrus" "gorm.io/gorm" ) type PurchaseService interface { CreateOne(ctx *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error) ApproveStaffPurchase(ctx *fiber.Ctx, id uint64, req *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) } type purchaseService struct { Log *logrus.Logger Validate *validator.Validate PurchaseRepo rPurchase.PurchaseRepository ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository WarehouseRepo rWarehouse.WarehouseRepository SupplierRepo rSupplier.SupplierRepository ApprovalRepo commonRepo.ApprovalRepository } func NewPurchaseService( validate *validator.Validate, purchaseRepo rPurchase.PurchaseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, warehouseRepo rWarehouse.WarehouseRepository, supplierRepo rSupplier.SupplierRepository, approvalRepo commonRepo.ApprovalRepository, ) PurchaseService { return &purchaseService{ Log: utils.Log, Validate: validate, PurchaseRepo: purchaseRepo, ProductWarehouseRepo: productWarehouseRepo, WarehouseRepo: warehouseRepo, SupplierRepo: supplierRepo, ApprovalRepo: approvalRepo, } } func uint64Ptr(v uint64) *uint64 { return &v } func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } supplier, err := s.SupplierRepo.GetByID(c.Context(), req.SupplierID, nil) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Supplier not found") } s.Log.Errorf("Failed to get supplier: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get supplier") } warehouse, err := s.WarehouseRepo.GetDetailByID(c.Context(), req.WarehouseID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") } s.Log.Errorf("Failed to get warehouse: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get warehouse") } if warehouse.AreaId != req.AreaID { return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse does not belong to the provided area") } if warehouse.LocationId == nil || *warehouse.LocationId != req.LocationID { return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse does not belong to the provided location") } type aggregatedItem struct { productId uint64 warehouseId uint64 productWarehouseId *uint64 subQty float64 } if len(req.Items) == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Items must not be empty") } aggregated := make([]*aggregatedItem, 0, len(req.Items)) indexMap := make(map[string]int) for _, item := range req.Items { var ( productId = uint64(item.ProductID) warehouseId = uint64(req.WarehouseID) productWarehouseId *uint64 ) if item.ProductWarehouseID != nil { productWarehouse, err := s.ProductWarehouseRepo.GetDetailByID(c.Context(), *item.ProductWarehouseID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Product warehouse %d not found", *item.ProductWarehouseID)) } s.Log.Errorf("Failed to get product warehouse %d: %+v", *item.ProductWarehouseID, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") } if productWarehouse.WarehouseId != req.WarehouseID { return nil, fiber.NewError(fiber.StatusBadRequest, "Product warehouse does not match selected warehouse") } productId = uint64(productWarehouse.ProductId) warehouseId = uint64(productWarehouse.WarehouseId) idCopy := uint64(productWarehouse.Id) productWarehouseId = &idCopy } else { productWarehouse, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(c.Context(), item.ProductID, req.WarehouseID) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { s.Log.Errorf("Failed to get product warehouse for product %d and warehouse %d: %+v", item.ProductID, req.WarehouseID, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") } if err == nil { idCopy := uint64(productWarehouse.Id) productWarehouseId = &idCopy } } key := fmt.Sprintf("%d:%d", productId, warehouseId) if idx, ok := indexMap[key]; ok { aggregated[idx].subQty += item.Quantity continue } entry := &aggregatedItem{ productId: productId, warehouseId: warehouseId, productWarehouseId: productWarehouseId, subQty: item.Quantity, } aggregated = append(aggregated, entry) indexMap[key] = len(aggregated) - 1 } prNumber := fmt.Sprintf("PR-%s-%s", time.Now().Format("20060102"), uuid.NewString()[:8]) var creditTerm *int var dueDate *time.Time if supplier.DueDate > 0 { ct := supplier.DueDate creditTerm = &ct d := time.Now().UTC().AddDate(0, 0, ct) dueDate = &d } purchase := &entity.Purchase{ PrNumber: prNumber, SupplierId: uint64(req.SupplierID), CreditTerm: creditTerm, DueDate: dueDate, GrandTotal: 0, Notes: req.Notes, CreatedBy: 1, // TODO: replace with authenticated user id once available } items := make([]*entity.PurchaseItem, 0, len(aggregated)) for _, item := range aggregated { items = append(items, &entity.PurchaseItem{ ProductId: item.productId, WarehouseId: item.warehouseId, ProductWarehouseId: item.productWarehouseId, SubQty: item.subQty, TotalQty: item.subQty, TotalUsed: 0, Price: 0, TotalPrice: 0, }) } ctx := c.Context() transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { purchaseRepoTx := rPurchase.NewPurchaseRepository(tx) if err := purchaseRepoTx.CreateWithItems(ctx, purchase, items); err != nil { return err } actorID := uint(purchase.CreatedBy) if actorID == 0 { actorID = 1 } action := entity.ApprovalActionCreated approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) if _, err := approvalSvc.CreateApproval( ctx, utils.ApprovalWorkflowPurchase, uint(purchase.Id), utils.PurchaseStepPengajuan, &action, actorID, nil, ); err != nil { return err } return nil }) if transactionErr != nil { s.Log.Errorf("Failed to create purchase requisition: %+v", transactionErr) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create purchase requisition") } created, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) if err != nil { s.Log.Errorf("Failed to load created purchase requisition: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase requisition") } return created, nil } func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint64, req *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } ctx := c.Context() purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Purchase requisition not found") } s.Log.Errorf("Failed to get purchase requisition: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase requisition") } if len(purchase.Items) == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase requisition has no items to approve") } requestItems := make(map[uint64]validation.StaffPurchaseApprovalItem, len(req.Items)) for _, item := range req.Items { requestItems[item.PurchaseItemID] = item } updates := make([]rPurchase.PurchasePricingUpdate, 0, len(purchase.Items)) var grandTotal float64 for _, item := range purchase.Items { data, ok := requestItems[item.Id] if !ok { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Missing pricing data for item %d", item.Id)) } delete(requestItems, item.Id) if data.Price <= 0 { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Price for item %d must be greater than 0", item.Id)) } totalPrice := data.TotalPrice if totalPrice == nil { calculated := data.Price * item.TotalQty totalPrice = &calculated } if *totalPrice <= 0 { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Total price for item %d must be greater than 0", item.Id)) } updates = append(updates, rPurchase.PurchasePricingUpdate{ ItemID: item.Id, Price: data.Price, TotalPrice: *totalPrice, }) grandTotal += *totalPrice } if len(requestItems) > 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Found pricing data for items that do not belong to this purchase requisition") } action := entity.ApprovalActionApproved actorID := uint(1) // TODO: replace with authenticated user id once available transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { purchaseRepoTx := rPurchase.NewPurchaseRepository(tx) if err := purchaseRepoTx.UpdatePricing(ctx, purchase.Id, updates, grandTotal); err != nil { return err } approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) if _, err := approvalSvc.CreateApproval( ctx, utils.ApprovalWorkflowPurchase, uint(purchase.Id), utils.PurchaseStepStaffPurchase, &action, actorID, req.Notes, ); err != nil { return err } return nil }) if transactionErr != nil { if errors.Is(transactionErr, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Purchase item not found") } s.Log.Errorf("Failed to approve purchase requisition %d: %+v", purchase.Id, transactionErr) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to approve purchase requisition") } updated, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) if err != nil { s.Log.Errorf("Failed to load purchase requisition after approval: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase requisition") } return updated, nil }