unfinished purchase

This commit is contained in:
ragilap
2025-11-05 18:58:06 +07:00
parent 4aed480662
commit 8f74391f1e
23 changed files with 1155 additions and 52 deletions
@@ -0,0 +1,337 @@
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
}