mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 21:41:55 +00:00
1322 lines
42 KiB
Go
1322 lines
42 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
"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"
|
|
authmiddleware "gitlab.com/mbugroup/lti-api.git/internal/middleware"
|
|
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
|
|
rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/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"
|
|
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
|
|
|
|
"github.com/go-playground/validator/v10"
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/sirupsen/logrus"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type PurchaseService interface {
|
|
GetAll(ctx *fiber.Ctx, params *validation.PurchaseQuery) ([]entity.Purchase, int64, error)
|
|
GetOne(ctx *fiber.Ctx, id uint64) (*entity.Purchase, error)
|
|
CreateOne(ctx *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error)
|
|
ApproveStaffPurchase(ctx *fiber.Ctx, id uint64, req *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error)
|
|
ApproveManagerPurchase(ctx *fiber.Ctx, id uint64, req *validation.ApproveManagerPurchaseRequest) (*entity.Purchase, error)
|
|
ReceiveProducts(ctx *fiber.Ctx, id uint64, req *validation.ReceivePurchaseRequest) (*entity.Purchase, error)
|
|
DeleteItems(ctx *fiber.Ctx, id uint64, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error)
|
|
DeletePurchase(ctx *fiber.Ctx, id uint64) error
|
|
}
|
|
|
|
const (
|
|
priceTolerance = 0.0001
|
|
queryDateLayout = "2006-01-02"
|
|
)
|
|
|
|
type purchaseService struct {
|
|
Log *logrus.Logger
|
|
Validate *validator.Validate
|
|
PurchaseRepo rPurchase.PurchaseRepository
|
|
ProductRepo rProduct.ProductRepository
|
|
WarehouseRepo rWarehouse.WarehouseRepository
|
|
SupplierRepo rSupplier.SupplierRepository
|
|
ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository
|
|
ApprovalRepo commonRepo.ApprovalRepository
|
|
ApprovalSvc commonSvc.ApprovalService
|
|
ExpenseBridge PurchaseExpenseBridge
|
|
}
|
|
|
|
type staffAdjustmentPayload struct {
|
|
PricingUpdates []rPurchase.PurchasePricingUpdate
|
|
NewItems []*entity.PurchaseItem
|
|
GrandTotal float64
|
|
}
|
|
|
|
func NewPurchaseService(
|
|
validate *validator.Validate,
|
|
purchaseRepo rPurchase.PurchaseRepository,
|
|
productRepo rProduct.ProductRepository,
|
|
warehouseRepo rWarehouse.WarehouseRepository,
|
|
supplierRepo rSupplier.SupplierRepository,
|
|
productWarehouseRepo rProductWarehouse.ProductWarehouseRepository,
|
|
approvalRepo commonRepo.ApprovalRepository,
|
|
approvalSvc commonSvc.ApprovalService,
|
|
expenseBridge PurchaseExpenseBridge,
|
|
) PurchaseService {
|
|
if expenseBridge == nil {
|
|
expenseBridge = NewNoopPurchaseExpenseBridge()
|
|
}
|
|
return &purchaseService{
|
|
Log: utils.Log,
|
|
Validate: validate,
|
|
PurchaseRepo: purchaseRepo,
|
|
ProductRepo: productRepo,
|
|
WarehouseRepo: warehouseRepo,
|
|
SupplierRepo: supplierRepo,
|
|
ProductWarehouseRepo: productWarehouseRepo,
|
|
ApprovalRepo: approvalRepo,
|
|
ApprovalSvc: approvalSvc,
|
|
ExpenseBridge: expenseBridge,
|
|
}
|
|
}
|
|
|
|
func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.PurchaseQuery) ([]entity.Purchase, int64, error) {
|
|
if err := s.Validate.Struct(params); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
limit := params.Limit
|
|
if limit <= 0 {
|
|
limit = 10
|
|
}
|
|
page := params.Page
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
offset := (page - 1) * limit
|
|
|
|
ctx := c.Context()
|
|
|
|
createdFrom, createdTo, err := parseQueryDates(params.CreatedFrom, params.CreatedTo)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
statusAction, completedOnly, err := parseApprovalAction(params.Status)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
filter := &rPurchase.PurchaseListFilter{
|
|
SupplierID: params.SupplierID,
|
|
Search: params.Search,
|
|
PrNumber: params.PrNumber,
|
|
CreatedFrom: createdFrom,
|
|
CreatedTo: createdTo,
|
|
Status: statusAction,
|
|
CompletedOnly: completedOnly,
|
|
}
|
|
|
|
purchases, total, err := s.PurchaseRepo.GetAllWithFilters(ctx, offset, limit, filter)
|
|
if err != nil {
|
|
s.Log.Errorf("Failed to get purchases: %+v", err)
|
|
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchases")
|
|
}
|
|
|
|
if err := s.attachLatestApprovals(ctx, purchases); err != nil {
|
|
s.Log.Warnf("Unable to attach latest approvals to purchases: %+v", err)
|
|
}
|
|
|
|
return purchases, total, nil
|
|
}
|
|
|
|
func (s *purchaseService) GetOne(c *fiber.Ctx, id uint64) (*entity.Purchase, error) {
|
|
if id == 0 {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id")
|
|
}
|
|
|
|
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 not found")
|
|
}
|
|
s.Log.Errorf("Failed to get purchase: %+v", err)
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase")
|
|
}
|
|
|
|
if err := s.attachLatestApproval(ctx, purchase); err != nil {
|
|
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err)
|
|
}
|
|
|
|
return purchase, nil
|
|
}
|
|
|
|
func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error) {
|
|
if err := s.Validate.Struct(req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
user, ok := authmiddleware.AuthenticatedUser(c)
|
|
if !ok || user == nil || user.Id == 0 {
|
|
return nil, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
|
|
}
|
|
|
|
ctx := c.Context()
|
|
|
|
if _, err := s.SupplierRepo.GetByID(ctx, req.SupplierID, nil); 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")
|
|
}
|
|
|
|
type aggregatedItem struct {
|
|
productId uint64
|
|
warehouseId uint64
|
|
subQty float64
|
|
}
|
|
|
|
if len(req.Items) == 0 {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Items must not be empty")
|
|
}
|
|
|
|
warehouseCache := make(map[uint]*entity.Warehouse)
|
|
productSupplierCache := make(map[uint]bool)
|
|
|
|
getWarehouse := func(id uint) (*entity.Warehouse, error) {
|
|
if warehouse, ok := warehouseCache[id]; ok {
|
|
return warehouse, nil
|
|
}
|
|
|
|
warehouse, err := s.WarehouseRepo.GetDetailByID(ctx, id)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Warehouse %d not found", id))
|
|
}
|
|
s.Log.Errorf("Failed to get warehouse %d: %+v", id, err)
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get warehouse")
|
|
}
|
|
|
|
warehouseCache[id] = warehouse
|
|
return warehouse, nil
|
|
}
|
|
|
|
aggregated := make([]*aggregatedItem, 0, len(req.Items))
|
|
indexMap := make(map[string]int)
|
|
|
|
for _, item := range req.Items {
|
|
if _, err := getWarehouse(item.WarehouseID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if _, checked := productSupplierCache[item.ProductID]; !checked {
|
|
linked, err := s.ProductRepo.IsLinkedToSupplier(ctx, item.ProductID, req.SupplierID)
|
|
if err != nil {
|
|
s.Log.Errorf("Failed to validate product %d for supplier %d: %+v", item.ProductID, req.SupplierID, err)
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product for supplier")
|
|
}
|
|
if !linked {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product %d is not linked to supplier %d", item.ProductID, req.SupplierID))
|
|
}
|
|
productSupplierCache[item.ProductID] = true
|
|
}
|
|
|
|
productId := uint64(item.ProductID)
|
|
warehouseId := uint64(item.WarehouseID)
|
|
|
|
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,
|
|
subQty: item.Quantity,
|
|
}
|
|
aggregated = append(aggregated, entry)
|
|
indexMap[key] = len(aggregated) - 1
|
|
}
|
|
|
|
creditTermValue := req.CreditTerm
|
|
creditTerm := &creditTermValue
|
|
dueDateValue := time.Now().UTC().AddDate(0, 0, creditTermValue)
|
|
dueDate := &dueDateValue
|
|
|
|
purchase := &entity.Purchase{
|
|
SupplierId: uint64(req.SupplierID),
|
|
CreditTerm: creditTerm,
|
|
DueDate: dueDate,
|
|
GrandTotal: 0,
|
|
Notes: req.Notes,
|
|
CreatedBy: uint64(user.Id),
|
|
}
|
|
|
|
items := make([]*entity.PurchaseItem, 0, len(aggregated))
|
|
for _, item := range aggregated {
|
|
items = append(items, &entity.PurchaseItem{
|
|
ProductId: item.productId,
|
|
WarehouseId: item.warehouseId,
|
|
SubQty: item.subQty,
|
|
TotalQty: 0,
|
|
TotalUsed: 0,
|
|
Price: 0,
|
|
TotalPrice: 0,
|
|
})
|
|
}
|
|
|
|
transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
purchaseRepoTx := rPurchase.NewPurchaseRepository(tx)
|
|
|
|
code, err := purchaseRepoTx.NextPrNumber(ctx, tx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
purchase.PrNumber = code
|
|
|
|
if err := purchaseRepoTx.CreateWithItems(ctx, purchase, items); err != nil {
|
|
return err
|
|
}
|
|
|
|
actorID := uint(purchase.CreatedBy)
|
|
if actorID == 0 {
|
|
actorID = 1
|
|
}
|
|
action := entity.ApprovalActionCreated
|
|
if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepPengajuan, action, actorID, nil, false); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if transactionErr != nil {
|
|
s.Log.Errorf("Failed to create purchase: %+v", transactionErr)
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create purchase")
|
|
}
|
|
|
|
created, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id)
|
|
if err != nil {
|
|
s.Log.Errorf("Failed to load created purchase: %+v", err)
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase")
|
|
}
|
|
|
|
if err := s.attachLatestApproval(ctx, created); err != nil {
|
|
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", created.Id, err)
|
|
}
|
|
|
|
s.notifyExpenseItemsCreated(ctx, created.Id, created.Items)
|
|
|
|
return created, nil
|
|
}
|
|
|
|
func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint64, req *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) {
|
|
return s.processStaffPurchaseApproval(c, id, req, false)
|
|
}
|
|
|
|
func (s *purchaseService) processStaffPurchaseApproval(c *fiber.Ctx, id uint64, req *validation.ApproveStaffPurchaseRequest, requireStaffApproval bool) (*entity.Purchase, error) {
|
|
if err := s.Validate.Struct(req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
actorID := uint(1) // TODO: replace with authenticated user id once available
|
|
|
|
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 not found")
|
|
}
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase")
|
|
}
|
|
|
|
if err := s.attachLatestApproval(ctx, purchase); err != nil {
|
|
s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err)
|
|
}
|
|
|
|
var latestStep uint16
|
|
if purchase.LatestApproval != nil {
|
|
latestStep = purchase.LatestApproval.StepNumber
|
|
}
|
|
|
|
if requireStaffApproval && latestStep < uint16(utils.PurchaseStepStaffPurchase) {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase cannot be edited before staff approval")
|
|
}
|
|
|
|
isInitialApproval := latestStep < uint16(utils.PurchaseStepStaffPurchase)
|
|
if isInitialApproval && latestStep != uint16(utils.PurchaseStepPengajuan) {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase is not ready for staff approval")
|
|
}
|
|
|
|
hasReceivingData := false
|
|
for _, item := range purchase.Items {
|
|
if item.TotalQty > 0 || item.TotalUsed > 0 {
|
|
hasReceivingData = true
|
|
break
|
|
}
|
|
}
|
|
|
|
syncReceiving := !isInitialApproval && hasReceivingData
|
|
|
|
payload, err := s.buildStaffAdjustmentPayload(ctx, purchase, req, syncReceiving)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
purchaseRepoTx := rPurchase.NewPurchaseRepository(tx)
|
|
grandTotalUpdated := false
|
|
if len(payload.PricingUpdates) > 0 {
|
|
if err := purchaseRepoTx.UpdatePricing(ctx, purchase.Id, payload.PricingUpdates, payload.GrandTotal); err != nil {
|
|
return err
|
|
}
|
|
grandTotalUpdated = true
|
|
}
|
|
|
|
if len(payload.NewItems) > 0 {
|
|
if err := purchaseRepoTx.CreateItems(ctx, purchase.Id, payload.NewItems); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if !grandTotalUpdated {
|
|
if err := purchaseRepoTx.UpdateGrandTotal(ctx, purchase.Id, payload.GrandTotal); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if isInitialApproval {
|
|
action := entity.ApprovalActionApproved
|
|
if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepStaffPurchase, action, actorID, req.Notes, false); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if len(payload.PricingUpdates) > 0 || len(payload.NewItems) > 0 {
|
|
approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
|
|
if approvalSvc != nil {
|
|
latest, err := approvalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchase.Id), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
shouldRecordStaffUpdate := latest == nil ||
|
|
latest.StepNumber != uint16(utils.PurchaseStepStaffPurchase) ||
|
|
latest.Action == nil ||
|
|
(latest.Action != nil && *latest.Action != entity.ApprovalActionUpdated)
|
|
|
|
if shouldRecordStaffUpdate {
|
|
action := entity.ApprovalActionUpdated
|
|
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")
|
|
}
|
|
if isInitialApproval {
|
|
s.Log.Errorf("Failed to approve purchase %d: %+v", purchase.Id, transactionErr)
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to approve purchase")
|
|
}
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update purchase pricing")
|
|
}
|
|
|
|
updated, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id)
|
|
if err != nil {
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase")
|
|
}
|
|
if err := s.attachLatestApproval(ctx, updated); err != nil {
|
|
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err)
|
|
}
|
|
|
|
if len(payload.NewItems) > 0 {
|
|
newItems := make([]entity.PurchaseItem, len(payload.NewItems))
|
|
for i, item := range payload.NewItems {
|
|
if item == nil {
|
|
continue
|
|
}
|
|
newItems[i] = *item
|
|
}
|
|
s.notifyExpenseItemsCreated(ctx, purchase.Id, newItems)
|
|
}
|
|
|
|
return updated, nil
|
|
}
|
|
|
|
func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint64, req *validation.ApproveManagerPurchaseRequest) (*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 not found")
|
|
}
|
|
s.Log.Errorf("Failed to get purchase: %+v", err)
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase")
|
|
}
|
|
|
|
if err := s.attachLatestApproval(ctx, purchase); err != nil {
|
|
s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err)
|
|
}
|
|
|
|
if purchase.LatestApproval == nil ||
|
|
purchase.LatestApproval.StepNumber < uint16(utils.PurchaseStepStaffPurchase) {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase must reach staff purchase step before manager approval")
|
|
}
|
|
|
|
actorID := uint(1)
|
|
action := entity.ApprovalActionApproved
|
|
now := time.Now().UTC()
|
|
hasExistingPO := purchase.PoNumber != nil && strings.TrimSpace(*purchase.PoNumber) != ""
|
|
var generatedNumber string
|
|
|
|
transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
updateData := map[string]any{}
|
|
if !hasExistingPO {
|
|
repoTx := rPurchase.NewPurchaseRepository(tx)
|
|
code, err := repoTx.NextPoNumber(ctx, tx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
updateData["po_number"] = code
|
|
updateData["po_date"] = now
|
|
generatedNumber = code
|
|
}
|
|
|
|
if len(updateData) > 0 {
|
|
repoTx := rPurchase.NewPurchaseRepository(tx)
|
|
if err := repoTx.PatchOne(ctx, uint(purchase.Id), updateData, nil); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
forceManagerApproval := false
|
|
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
|
|
if approvalSvcTx != nil {
|
|
filterByStep := func(step approvalutils.ApprovalStep) func(*gorm.DB) *gorm.DB {
|
|
return func(db *gorm.DB) *gorm.DB {
|
|
return db.Where("step_number = ?", uint16(step))
|
|
}
|
|
}
|
|
latestStaff, err := approvalSvcTx.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchase.Id), filterByStep(utils.PurchaseStepStaffPurchase))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
latestManager, err := approvalSvcTx.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchase.Id), filterByStep(utils.PurchaseStepManager))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if latestStaff != nil && latestManager != nil && latestStaff.ActionAt.After(latestManager.ActionAt) {
|
|
forceManagerApproval = true
|
|
}
|
|
}
|
|
|
|
if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepManager, action, actorID, req.Notes, forceManagerApproval); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if transactionErr != nil {
|
|
s.Log.Errorf("Failed to approve manager purchase %d: %+v", purchase.Id, transactionErr)
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate purchase order")
|
|
}
|
|
|
|
if generatedNumber != "" {
|
|
purchase.PoNumber = &generatedNumber
|
|
purchase.PoDate = &now
|
|
}
|
|
|
|
updated, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id)
|
|
if err != nil {
|
|
s.Log.Errorf("Failed to load purchase after manager approval: %+v", err)
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase")
|
|
}
|
|
|
|
if err := s.attachLatestApproval(ctx, updated); err != nil {
|
|
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err)
|
|
}
|
|
|
|
return updated, nil
|
|
}
|
|
|
|
func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validation.ReceivePurchaseRequest) (*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 not found")
|
|
}
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase ")
|
|
}
|
|
|
|
if purchase.PoNumber == nil || strings.TrimSpace(*purchase.PoNumber) == "" {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase order has not been generated")
|
|
}
|
|
|
|
if err := s.attachLatestApproval(ctx, purchase); err != nil {
|
|
s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err)
|
|
}
|
|
|
|
if purchase.LatestApproval == nil ||
|
|
purchase.LatestApproval.StepNumber < uint16(utils.PurchaseStepManager) {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase must be approved by manager before receiving products")
|
|
}
|
|
|
|
itemMap := make(map[uint64]*entity.PurchaseItem, len(purchase.Items))
|
|
for i := range purchase.Items {
|
|
itemMap[purchase.Items[i].Id] = &purchase.Items[i]
|
|
}
|
|
|
|
type preparedReceiving struct {
|
|
item *entity.PurchaseItem
|
|
payload validation.ReceivePurchaseItemRequest
|
|
receivedDate time.Time
|
|
warehouseID uint
|
|
overrideWarehouse bool
|
|
receivedQty float64
|
|
}
|
|
|
|
visitedItems := make(map[uint64]struct{}, len(req.Items))
|
|
prepared := make([]preparedReceiving, 0, len(req.Items))
|
|
for _, payload := range req.Items {
|
|
item, exists := itemMap[payload.PurchaseItemID]
|
|
if !exists {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Purchase item %d not found", payload.PurchaseItemID))
|
|
}
|
|
|
|
receivedDate, err := time.Parse("2006-01-02", payload.ReceivedDate)
|
|
if err != nil {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid received_date for item %d", payload.PurchaseItemID))
|
|
}
|
|
receivedDate = receivedDate.UTC()
|
|
|
|
warehouseID := uint(item.WarehouseId)
|
|
overrideWarehouse := false
|
|
if payload.WarehouseID != nil && *payload.WarehouseID != 0 {
|
|
warehouseID = *payload.WarehouseID
|
|
overrideWarehouse = true
|
|
}
|
|
if warehouseID == 0 {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse must be specified for item %d", payload.PurchaseItemID))
|
|
}
|
|
|
|
var receivedQty float64
|
|
if payload.ReceivedQty != nil {
|
|
receivedQty = *payload.ReceivedQty
|
|
} else {
|
|
receivedQty = item.SubQty
|
|
}
|
|
if receivedQty < 0 {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Received quantity for item %d cannot be negative", payload.PurchaseItemID))
|
|
}
|
|
if receivedQty > item.SubQty {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Received quantity for item %d cannot exceed ordered quantity (%.3f)", payload.PurchaseItemID, item.SubQty))
|
|
}
|
|
|
|
if _, dup := visitedItems[payload.PurchaseItemID]; dup {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate receiving data for item %d", payload.PurchaseItemID))
|
|
}
|
|
visitedItems[payload.PurchaseItemID] = struct{}{}
|
|
|
|
prepared = append(prepared, preparedReceiving{
|
|
item: item,
|
|
payload: payload,
|
|
receivedDate: receivedDate,
|
|
warehouseID: warehouseID,
|
|
overrideWarehouse: overrideWarehouse,
|
|
receivedQty: receivedQty,
|
|
})
|
|
}
|
|
|
|
// Require receiving payload to cover all purchase items so that
|
|
// receiving cannot be submitted partially item-by-item.
|
|
if len(visitedItems) != len(itemMap) {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Receiving data must be provided for all purchase items")
|
|
}
|
|
|
|
receivingAction := entity.ApprovalActionApproved
|
|
completedAction := entity.ApprovalActionApproved
|
|
actorID := uint(1)
|
|
|
|
approvalSvc := s.approvalServiceForDB(nil)
|
|
if approvalSvc != nil {
|
|
filterStep := func(step approvalutils.ApprovalStep) func(*gorm.DB) *gorm.DB {
|
|
return func(db *gorm.DB) *gorm.DB {
|
|
return db.Where("step_number = ?", uint16(step))
|
|
}
|
|
}
|
|
|
|
latestReceiving, err := approvalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchase.Id), filterStep(utils.PurchaseStepReceiving))
|
|
if err != nil {
|
|
s.Log.Errorf("Failed to inspect receiving approval for purchase %d: %+v", purchase.Id, err)
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record purchase receiving")
|
|
}
|
|
|
|
if latestReceiving != nil {
|
|
receivingAction = entity.ApprovalActionUpdated
|
|
}
|
|
}
|
|
|
|
transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
repoTx := rPurchase.NewPurchaseRepository(tx)
|
|
pwRepoTx := rProductWarehouse.NewProductWarehouseRepository(tx)
|
|
|
|
deltas := make(map[uint]float64)
|
|
affected := make(map[uint]struct{})
|
|
updates := make([]rPurchase.PurchaseReceivingUpdate, 0, len(prepared))
|
|
|
|
for _, prep := range prepared {
|
|
item := prep.item
|
|
|
|
var oldPWID *uint
|
|
if item.ProductWarehouseId != nil {
|
|
idCopy := uint(*item.ProductWarehouseId)
|
|
oldPWID = &idCopy
|
|
}
|
|
|
|
var newPWID *uint
|
|
clearPW := false
|
|
|
|
if prep.receivedQty > 0 {
|
|
pwID, err := pwRepoTx.EnsureProductWarehouse(ctx, uint(item.ProductId), prep.warehouseID, purchase.CreatedBy)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
newPWID = &pwID
|
|
deltas[pwID] += prep.receivedQty
|
|
affected[pwID] = struct{}{}
|
|
} else {
|
|
clearPW = true
|
|
}
|
|
|
|
if oldPWID != nil {
|
|
deltas[*oldPWID] -= item.TotalQty
|
|
affected[*oldPWID] = struct{}{}
|
|
}
|
|
|
|
dateCopy := prep.receivedDate
|
|
qtyCopy := prep.receivedQty
|
|
update := rPurchase.PurchaseReceivingUpdate{
|
|
ItemID: item.Id,
|
|
ReceivedDate: &dateCopy,
|
|
TravelNumber: prep.payload.TravelNumber,
|
|
TravelDocumentPath: prep.payload.TravelDocumentPath,
|
|
VehicleNumber: prep.payload.VehicleNumber,
|
|
ReceivedQty: &qtyCopy,
|
|
ProductWarehouseID: newPWID,
|
|
ClearProductWarehouse: clearPW,
|
|
}
|
|
|
|
if prep.overrideWarehouse || uint(item.WarehouseId) != prep.warehouseID {
|
|
warehouseCopy := prep.warehouseID
|
|
update.WarehouseID = &warehouseCopy
|
|
}
|
|
|
|
updates = append(updates, update)
|
|
}
|
|
|
|
if err := repoTx.UpdateReceivingDetails(ctx, purchase.Id, updates); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := pwRepoTx.AdjustQuantities(ctx, deltas, nil); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := pwRepoTx.CleanupEmpty(ctx, affected); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepReceiving, receivingAction, actorID, req.Notes, true); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepCompleted, completedAction, actorID, req.Notes, true); 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 for receiving")
|
|
}
|
|
s.Log.Errorf("Failed to save purchase receiving %d: %+v", purchase.Id, transactionErr)
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record purchase receiving")
|
|
}
|
|
|
|
updated, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id)
|
|
if err != nil {
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase ")
|
|
}
|
|
if err := s.attachLatestApproval(ctx, updated); err != nil {
|
|
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err)
|
|
}
|
|
|
|
receivingPayloads := make([]ExpenseReceivingPayload, 0, len(prepared))
|
|
for _, prep := range prepared {
|
|
date := prep.receivedDate
|
|
payload := ExpenseReceivingPayload{
|
|
PurchaseItemID: prep.item.Id,
|
|
ProductID: prep.item.ProductId,
|
|
WarehouseID: uint64(prep.warehouseID),
|
|
ReceivedQty: prep.receivedQty,
|
|
ReceivedDate: &date,
|
|
}
|
|
receivingPayloads = append(receivingPayloads, payload)
|
|
}
|
|
s.notifyExpenseItemsReceived(ctx, purchase.Id, receivingPayloads)
|
|
|
|
return updated, nil
|
|
}
|
|
|
|
func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint64, req *validation.DeletePurchaseItemsRequest) (*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 not found")
|
|
}
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase")
|
|
}
|
|
|
|
if err := s.attachLatestApproval(ctx, purchase); err != nil {
|
|
s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err)
|
|
}
|
|
if purchase.LatestApproval == nil || purchase.LatestApproval.StepNumber == uint16(utils.PurchaseStepPengajuan) {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase cannot delete items before staff purchase approval")
|
|
}
|
|
|
|
if len(purchase.Items) == 0 {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase has no items to delete")
|
|
}
|
|
|
|
requested := make(map[uint64]struct{}, len(req.ItemIDs))
|
|
for _, id := range req.ItemIDs {
|
|
requested[id] = struct{}{}
|
|
}
|
|
|
|
toDelete := make([]uint64, 0, len(req.ItemIDs))
|
|
var remainingTotal float64
|
|
|
|
for _, item := range purchase.Items {
|
|
if _, ok := requested[item.Id]; ok {
|
|
if item.TotalQty > 0 || item.TotalUsed > 0 {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot delete item %d because it already has receiving data", item.Id))
|
|
}
|
|
toDelete = append(toDelete, item.Id)
|
|
} else {
|
|
remainingTotal += item.TotalPrice
|
|
}
|
|
}
|
|
|
|
if len(toDelete) == 0 {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Requested items were not found in this purchase")
|
|
}
|
|
|
|
if len(purchase.Items)-len(toDelete) <= 0 {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase must keep at least one item")
|
|
}
|
|
|
|
transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
repoTx := rPurchase.NewPurchaseRepository(tx)
|
|
|
|
if err := repoTx.DeleteItems(ctx, purchase.Id, toDelete); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := repoTx.UpdateGrandTotal(ctx, purchase.Id, remainingTotal); 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")
|
|
}
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to delete purchase items")
|
|
}
|
|
|
|
if len(toDelete) > 0 {
|
|
s.notifyExpenseItemsDeleted(ctx, purchase.Id, toDelete)
|
|
}
|
|
|
|
updated, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id)
|
|
if err != nil {
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase")
|
|
}
|
|
if err := s.attachLatestApproval(ctx, updated); err != nil {
|
|
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err)
|
|
}
|
|
|
|
return updated, nil
|
|
}
|
|
|
|
func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint64) error {
|
|
if id == 0 {
|
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id")
|
|
}
|
|
|
|
ctx := c.Context()
|
|
purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return fiber.NewError(fiber.StatusNotFound, "Purchase not found")
|
|
}
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase")
|
|
}
|
|
|
|
itemIDs := make([]uint64, 0, len(purchase.Items))
|
|
for _, item := range purchase.Items {
|
|
itemIDs = append(itemIDs, item.Id)
|
|
}
|
|
|
|
transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
approvalRepoTx := commonRepo.NewApprovalRepository(tx)
|
|
if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowPurchase.String(), uint(id)); err != nil {
|
|
return err
|
|
}
|
|
|
|
purchaseRepoTx := rPurchase.NewPurchaseRepository(tx)
|
|
if err := purchaseRepoTx.DeleteOne(ctx, uint(id)); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
if transactionErr != nil {
|
|
if errors.Is(transactionErr, gorm.ErrRecordNotFound) {
|
|
return fiber.NewError(fiber.StatusNotFound, "Purchase not found")
|
|
}
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete purchase")
|
|
}
|
|
|
|
if len(itemIDs) > 0 {
|
|
s.notifyExpenseItemsDeleted(ctx, uint64(id), itemIDs)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *purchaseService) createPurchaseApproval(
|
|
ctx context.Context,
|
|
db *gorm.DB,
|
|
purchaseID uint64,
|
|
step approvalutils.ApprovalStep,
|
|
action entity.ApprovalAction,
|
|
actorID uint,
|
|
notes *string,
|
|
allowDuplicate bool,
|
|
) error {
|
|
if purchaseID == 0 {
|
|
return fiber.NewError(fiber.StatusBadRequest, "Purchase is invalid for approval")
|
|
}
|
|
if actorID == 0 {
|
|
actorID = 1
|
|
}
|
|
|
|
svc := s.approvalServiceForDB(db)
|
|
|
|
modifier := func(db *gorm.DB) *gorm.DB {
|
|
return db.Where("step_number = ?", uint16(step))
|
|
}
|
|
|
|
latest, err := svc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchaseID), modifier)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !allowDuplicate && latest != nil &&
|
|
latest.Action != nil &&
|
|
*latest.Action == action {
|
|
return nil
|
|
}
|
|
|
|
actionCopy := action
|
|
_, err = svc.CreateApproval(ctx, utils.ApprovalWorkflowPurchase, uint(purchaseID), step, &actionCopy, actorID, notes)
|
|
return err
|
|
}
|
|
|
|
func (s *purchaseService) approvalServiceForDB(db *gorm.DB) commonSvc.ApprovalService {
|
|
if db != nil {
|
|
return commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(db))
|
|
}
|
|
if s.ApprovalSvc != nil {
|
|
return s.ApprovalSvc
|
|
}
|
|
return commonSvc.NewApprovalService(s.ApprovalRepo)
|
|
}
|
|
|
|
func (s *purchaseService) attachLatestApprovals(ctx context.Context, items []entity.Purchase) error {
|
|
if len(items) == 0 || s.ApprovalSvc == nil {
|
|
return nil
|
|
}
|
|
|
|
ids := make([]uint, 0, len(items))
|
|
visited := make(map[uint64]struct{}, len(items))
|
|
for _, item := range items {
|
|
if item.Id == 0 {
|
|
continue
|
|
}
|
|
if _, ok := visited[item.Id]; ok {
|
|
continue
|
|
}
|
|
visited[item.Id] = struct{}{}
|
|
ids = append(ids, uint(item.Id))
|
|
}
|
|
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
|
|
latestMap, err := s.ApprovalSvc.LatestByTargets(ctx, utils.ApprovalWorkflowPurchase, ids, func(db *gorm.DB) *gorm.DB {
|
|
return db.Preload("ActionUser")
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for i := range items {
|
|
if items[i].Id == 0 {
|
|
continue
|
|
}
|
|
if approval, ok := latestMap[uint(items[i].Id)]; ok {
|
|
items[i].LatestApproval = approval
|
|
} else {
|
|
items[i].LatestApproval = nil
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *purchaseService) notifyExpenseItemsCreated(ctx context.Context, purchaseID uint64, items []entity.PurchaseItem) {
|
|
if s.ExpenseBridge == nil || purchaseID == 0 || len(items) == 0 {
|
|
return
|
|
}
|
|
if err := s.ExpenseBridge.OnItemsCreated(ctx, purchaseID, items); err != nil {
|
|
s.Log.Warnf("Failed to notify expense bridge for created purchase %d: %+v", purchaseID, err)
|
|
}
|
|
}
|
|
|
|
func (s *purchaseService) notifyExpenseItemsReceived(ctx context.Context, purchaseID uint64, payloads []ExpenseReceivingPayload) {
|
|
if s.ExpenseBridge == nil || purchaseID == 0 || len(payloads) == 0 {
|
|
return
|
|
}
|
|
if err := s.ExpenseBridge.OnItemsReceived(ctx, purchaseID, payloads); err != nil {
|
|
s.Log.Warnf("Failed to notify expense bridge for received purchase %d: %+v", purchaseID, err)
|
|
}
|
|
}
|
|
|
|
func (s *purchaseService) notifyExpenseItemsDeleted(ctx context.Context, purchaseID uint64, itemIDs []uint64) {
|
|
if s.ExpenseBridge == nil || purchaseID == 0 || len(itemIDs) == 0 {
|
|
return
|
|
}
|
|
if err := s.ExpenseBridge.OnItemsDeleted(ctx, purchaseID, itemIDs); err != nil {
|
|
s.Log.Warnf("Failed to notify expense bridge for deleted purchase %d: %+v", purchaseID, err)
|
|
}
|
|
}
|
|
|
|
func (s *purchaseService) buildStaffAdjustmentPayload(
|
|
ctx context.Context,
|
|
purchase *entity.Purchase,
|
|
req *validation.ApproveStaffPurchaseRequest,
|
|
syncReceiving bool,
|
|
) (*staffAdjustmentPayload, error) {
|
|
if len(req.Items) == 0 {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Items must not be empty")
|
|
}
|
|
|
|
requestItems := make(map[uint64]validation.StaffPurchaseApprovalItem, len(req.Items))
|
|
newPayloads := make([]validation.StaffPurchaseApprovalItem, 0)
|
|
|
|
for _, item := range req.Items {
|
|
if item.PurchaseItemID == 0 {
|
|
newPayloads = append(newPayloads, item)
|
|
continue
|
|
}
|
|
if _, exists := requestItems[item.PurchaseItemID]; exists {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate pricing data for item %d", item.PurchaseItemID))
|
|
}
|
|
requestItems[item.PurchaseItemID] = item
|
|
}
|
|
|
|
updates := make([]rPurchase.PurchasePricingUpdate, 0, len(purchase.Items))
|
|
var grandTotal float64
|
|
|
|
existingCombos := make(map[string]struct{}, len(purchase.Items)+len(newPayloads))
|
|
for _, item := range purchase.Items {
|
|
key := fmt.Sprintf("%d:%d", item.ProductId, item.WarehouseId)
|
|
existingCombos[key] = struct{}{}
|
|
}
|
|
|
|
allowedWarehouses := make(map[uint64]struct{}, len(purchase.Items))
|
|
for _, item := range purchase.Items {
|
|
allowedWarehouses[item.WarehouseId] = struct{}{}
|
|
}
|
|
if len(allowedWarehouses) == 0 && len(newPayloads) > 0 {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "No available warehouses for this purchase")
|
|
}
|
|
|
|
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))
|
|
}
|
|
if data.ProductID != 0 && data.ProductID != item.ProductId {
|
|
return nil, fiber.NewError(
|
|
fiber.StatusBadRequest,
|
|
fmt.Sprintf("Cannot change product for item %d. Delete the item and create a new one instead", item.Id),
|
|
)
|
|
}
|
|
if data.WarehouseID != 0 && data.WarehouseID != item.WarehouseId {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse mismatch for item %d", item.Id))
|
|
}
|
|
|
|
effectiveQty := item.SubQty
|
|
if data.Qty != nil {
|
|
if *data.Qty <= 0 {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity for item %d must be greater than 0", item.Id))
|
|
}
|
|
if item.TotalUsed > 0 && *data.Qty < item.TotalUsed {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity for item %d cannot be lower than used amount (%.3f)", item.Id, item.TotalUsed))
|
|
}
|
|
if (item.TotalQty > 0 || item.TotalUsed > 0) && !syncReceiving {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot change quantity for item %d because it already has receiving data", item.Id))
|
|
}
|
|
effectiveQty = *data.Qty
|
|
}
|
|
|
|
totalPriceInput := data.TotalPrice
|
|
totalPrice, err := calculateTotalPrice(effectiveQty, data.Price, &totalPriceInput, fmt.Sprintf("item %d", item.Id))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
update := rPurchase.PurchasePricingUpdate{
|
|
ItemID: item.Id,
|
|
Price: data.Price,
|
|
TotalPrice: totalPrice,
|
|
}
|
|
if data.Qty != nil {
|
|
qtyCopy := effectiveQty
|
|
update.Quantity = &qtyCopy
|
|
}
|
|
if syncReceiving {
|
|
qtyCopy := effectiveQty
|
|
update.TotalQty = &qtyCopy
|
|
}
|
|
|
|
updates = append(updates, update)
|
|
grandTotal += totalPrice
|
|
delete(requestItems, item.Id)
|
|
}
|
|
if len(requestItems) > 0 {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Found pricing data for items that do not belong to this purchase")
|
|
}
|
|
|
|
productSupplierCache := make(map[uint64]bool)
|
|
newItems := make([]*entity.PurchaseItem, 0, len(newPayloads))
|
|
|
|
for _, payload := range newPayloads {
|
|
if payload.ProductID == 0 || payload.WarehouseID == 0 {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Product and warehouse must be provided for new items")
|
|
}
|
|
if payload.Qty == nil || *payload.Qty <= 0 {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity must be greater than 0 for product %d", payload.ProductID))
|
|
}
|
|
if _, ok := allowedWarehouses[payload.WarehouseID]; !ok {
|
|
return nil, fiber.NewError(
|
|
fiber.StatusBadRequest,
|
|
fmt.Sprintf("Warehouse %d is not available for this purchase", payload.WarehouseID),
|
|
)
|
|
}
|
|
|
|
key := fmt.Sprintf("%d:%d", payload.ProductID, payload.WarehouseID)
|
|
if _, exists := existingCombos[key]; exists {
|
|
return nil, fiber.NewError(
|
|
fiber.StatusBadRequest,
|
|
fmt.Sprintf("Product %d in warehouse %d already exists in this purchase", payload.ProductID, payload.WarehouseID),
|
|
)
|
|
}
|
|
|
|
if _, checked := productSupplierCache[payload.ProductID]; !checked {
|
|
linked, err := s.ProductRepo.IsLinkedToSupplier(ctx, uint(payload.ProductID), uint(purchase.SupplierId))
|
|
if err != nil {
|
|
s.Log.Errorf("Failed to validate product %d for supplier %d: %+v", payload.ProductID, purchase.SupplierId, err)
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product for supplier")
|
|
}
|
|
if !linked {
|
|
return nil, fiber.NewError(
|
|
fiber.StatusBadRequest,
|
|
fmt.Sprintf("Product %d is not linked to supplier %d", payload.ProductID, purchase.SupplierId),
|
|
)
|
|
}
|
|
productSupplierCache[payload.ProductID] = true
|
|
}
|
|
|
|
qty := *payload.Qty
|
|
totalPriceInput := payload.TotalPrice
|
|
totalPrice, err := calculateTotalPrice(qty, payload.Price, &totalPriceInput, fmt.Sprintf("product %d in warehouse %d", payload.ProductID, payload.WarehouseID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
newItem := &entity.PurchaseItem{
|
|
PurchaseId: purchase.Id,
|
|
ProductId: payload.ProductID,
|
|
WarehouseId: payload.WarehouseID,
|
|
SubQty: qty,
|
|
TotalQty: 0,
|
|
TotalUsed: 0,
|
|
Price: payload.Price,
|
|
TotalPrice: totalPrice,
|
|
}
|
|
newItems = append(newItems, newItem)
|
|
existingCombos[key] = struct{}{}
|
|
grandTotal += totalPrice
|
|
}
|
|
|
|
if len(updates) == 0 && len(newItems) == 0 {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase has no items to process")
|
|
}
|
|
|
|
return &staffAdjustmentPayload{
|
|
PricingUpdates: updates,
|
|
NewItems: newItems,
|
|
GrandTotal: grandTotal,
|
|
}, nil
|
|
}
|
|
|
|
func calculateTotalPrice(quantity float64, price float64, provided *float64, ref string) (float64, error) {
|
|
if quantity <= 0 {
|
|
return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity for %s must be greater than 0", ref))
|
|
}
|
|
if price <= 0 {
|
|
return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Price for %s must be greater than 0", ref))
|
|
}
|
|
|
|
fmt.Println(price, quantity)
|
|
expectedTotal := price * quantity
|
|
|
|
if provided == nil {
|
|
return expectedTotal, nil
|
|
}
|
|
if *provided <= 0 {
|
|
return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Total price for %s must be greater than 0", ref))
|
|
}
|
|
if math.Abs(*provided-expectedTotal) > priceTolerance {
|
|
return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Total price for %s must equal quantity x price", ref))
|
|
}
|
|
return *provided, nil
|
|
}
|
|
|
|
func (s *purchaseService) attachLatestApproval(ctx context.Context, item *entity.Purchase) error {
|
|
if item == nil || item.Id == 0 || s.ApprovalSvc == nil {
|
|
return nil
|
|
}
|
|
|
|
latest, err := s.ApprovalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(item.Id), func(db *gorm.DB) *gorm.DB {
|
|
return db.Preload("ActionUser")
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
item.LatestApproval = latest
|
|
return nil
|
|
}
|
|
|
|
func parseQueryDates(fromStr, toStr string) (*time.Time, *time.Time, error) {
|
|
var fromPtr *time.Time
|
|
var toPtr *time.Time
|
|
|
|
if strings.TrimSpace(fromStr) != "" {
|
|
parsed, err := time.Parse(queryDateLayout, fromStr)
|
|
if err != nil {
|
|
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "created_from must use format YYYY-MM-DD")
|
|
}
|
|
fromValue := parsed
|
|
fromPtr = &fromValue
|
|
}
|
|
|
|
if strings.TrimSpace(toStr) != "" {
|
|
parsed, err := time.Parse(queryDateLayout, toStr)
|
|
if err != nil {
|
|
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "created_to must use format YYYY-MM-DD")
|
|
}
|
|
toValue := parsed.AddDate(0, 0, 1)
|
|
toPtr = &toValue
|
|
}
|
|
|
|
if fromPtr != nil && toPtr != nil && fromPtr.After(*toPtr) {
|
|
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "created_from must be earlier than created_to")
|
|
}
|
|
|
|
return fromPtr, toPtr, nil
|
|
}
|
|
|
|
func parseApprovalAction(status string) (*entity.ApprovalAction, bool, error) {
|
|
value := strings.TrimSpace(strings.ToUpper(status))
|
|
if value == "" {
|
|
return nil, false, nil
|
|
}
|
|
|
|
if value == "COMPLETED" {
|
|
return nil, true, nil
|
|
}
|
|
|
|
action := entity.ApprovalAction(value)
|
|
switch action {
|
|
case entity.ApprovalActionApproved,
|
|
entity.ApprovalActionRejected,
|
|
entity.ApprovalActionCreated,
|
|
entity.ApprovalActionUpdated:
|
|
return &action, false, nil
|
|
default:
|
|
return nil, false, fiber.NewError(fiber.StatusBadRequest, "Invalid status filter")
|
|
}
|
|
}
|