mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
feat: bulk approve endpoint for marketings and expenses
This commit is contained in:
@@ -37,6 +37,7 @@ type ExpenseService interface {
|
||||
UpdateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error)
|
||||
DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error
|
||||
Approval(ctx *fiber.Ctx, req *validation.ApprovalRequest, approvalType string) ([]expenseDto.ExpenseDetailDTO, error)
|
||||
BulkApproveToStatus(ctx *fiber.Ctx, req *validation.BulkApprovalRequest, target approvalutils.ApprovalStep) ([]expenseDto.ExpenseDetailDTO, error)
|
||||
}
|
||||
|
||||
type expenseService struct {
|
||||
@@ -742,8 +743,12 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
||||
actorID, err := middleware.ActorIDFromContext(c)
|
||||
if err != nil {
|
||||
return nil, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
|
||||
}
|
||||
|
||||
if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
||||
approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
|
||||
realizationRepoTx := repository.NewExpenseRealizationRepository(tx)
|
||||
expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(tx)
|
||||
@@ -780,12 +785,6 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
|
||||
}
|
||||
}
|
||||
|
||||
if err := expenseRepoTx.PatchOne(c.Context(), expenseID, map[string]interface{}{
|
||||
"realization_date": realizationDate,
|
||||
}, nil); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date")
|
||||
}
|
||||
|
||||
if s.DocumentSvc != nil && len(req.Documents) > 0 {
|
||||
documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents))
|
||||
for idx, file := range req.Documents {
|
||||
@@ -795,7 +794,6 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
|
||||
Index: &idx,
|
||||
})
|
||||
}
|
||||
actorID := uint(1) // TODO: replace with authenticated user id
|
||||
_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
|
||||
DocumentableType: string(utils.DocumentableTypeExpenseRealization),
|
||||
DocumentableID: uint64(expenseID),
|
||||
@@ -807,6 +805,12 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
|
||||
}
|
||||
}
|
||||
|
||||
if err := expenseRepoTx.PatchOne(c.Context(), expenseID, map[string]interface{}{
|
||||
"realization_date": realizationDate,
|
||||
}, nil); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date")
|
||||
}
|
||||
|
||||
approvalAction := entity.ApprovalActionCreated
|
||||
if _, err := approvalSvc.CreateApproval(
|
||||
c.Context(),
|
||||
@@ -814,9 +818,9 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
|
||||
expenseID,
|
||||
utils.ExpenseStepRealisasi,
|
||||
&approvalAction,
|
||||
uint(1), // TODO: replace with authenticated user id
|
||||
nil); err != nil {
|
||||
|
||||
actorID,
|
||||
nil,
|
||||
); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization approval")
|
||||
}
|
||||
|
||||
@@ -834,6 +838,205 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
|
||||
return responseDTO, nil
|
||||
}
|
||||
|
||||
func (s *expenseService) BulkApproveToStatus(c *fiber.Ctx, req *validation.BulkApprovalRequest, target approvalutils.ApprovalStep) ([]expenseDto.ExpenseDetailDTO, error) {
|
||||
if err := s.Validate.Struct(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
approvableIDs := utils.UniqueUintSlice(req.ApprovableIds)
|
||||
if len(approvableIDs) == 0 {
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id")
|
||||
}
|
||||
|
||||
actorID, err := middleware.ActorIDFromContext(c)
|
||||
if err != nil {
|
||||
return nil, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
|
||||
}
|
||||
|
||||
var realizationDate time.Time
|
||||
if req.RequiresDate(target) {
|
||||
realizationDate, err = utils.ParseDateString(req.Date)
|
||||
if err != nil {
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid date format")
|
||||
}
|
||||
}
|
||||
|
||||
invalidateFromDateByExpenseID := make(map[uint]time.Time, len(approvableIDs))
|
||||
|
||||
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
||||
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
|
||||
expenseRepoTx := repository.NewExpenseRepository(tx)
|
||||
|
||||
for _, id := range approvableIDs {
|
||||
if err := commonSvc.EnsureRelations(c.Context(),
|
||||
commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: expenseRepoTx.IdExists},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
expense, err := expenseRepoTx.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
|
||||
return db.Preload("Nonstocks")
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Expense not found")
|
||||
}
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to load expense")
|
||||
}
|
||||
|
||||
latestApproval, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow")
|
||||
}
|
||||
if latestApproval == nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No approval found for Expense %d", id))
|
||||
}
|
||||
if latestApproval.Action != nil && *latestApproval.Action == entity.ApprovalActionRejected {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Expense %d is rejected and cannot be bulk approved", id))
|
||||
}
|
||||
|
||||
currentStep := approvalutils.ApprovalStep(latestApproval.StepNumber)
|
||||
if currentStep >= target {
|
||||
currentStepName := utils.ExpenseApprovalSteps[currentStep]
|
||||
targetStepName := utils.ExpenseApprovalSteps[target]
|
||||
if currentStep == target {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Expense %d is already at %s step", id, targetStepName))
|
||||
}
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Expense %d is already beyond %s step (current step: %s)", id, targetStepName, currentStepName))
|
||||
}
|
||||
|
||||
invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, expense.RealizationDate)
|
||||
|
||||
for step := currentStep + 1; step <= target; step++ {
|
||||
if step == utils.ExpenseStepRealisasi {
|
||||
if err := s.createRealizationFromExpenseLines(c.Context(), tx, expense, realizationDate, actorID, req.Notes); err != nil {
|
||||
return err
|
||||
}
|
||||
invalidateFromDate = commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, expense.RealizationDate, realizationDate)
|
||||
break
|
||||
}
|
||||
|
||||
approvalAction := entity.ApprovalActionApproved
|
||||
if _, err := approvalSvcTx.CreateApproval(
|
||||
c.Context(),
|
||||
utils.ApprovalWorkflowExpense,
|
||||
id,
|
||||
step,
|
||||
&approvalAction,
|
||||
actorID,
|
||||
req.Notes,
|
||||
); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create approval")
|
||||
}
|
||||
|
||||
if step == utils.ExpenseStepFinance && expense.PoNumber == "" {
|
||||
poNumber, err := s.generatePoNumber(tx, id)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate PO number")
|
||||
}
|
||||
if err := expenseRepoTx.PatchOne(c.Context(), id, map[string]interface{}{"po_number": poNumber}, nil); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update PO number")
|
||||
}
|
||||
expense.PoNumber = poNumber
|
||||
}
|
||||
}
|
||||
|
||||
invalidateFromDateByExpenseID[id] = invalidateFromDate
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if fiberErr, ok := err.(*fiber.Error); ok {
|
||||
return nil, fiberErr
|
||||
}
|
||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to bulk approve expenses")
|
||||
}
|
||||
|
||||
results := make([]expenseDto.ExpenseDetailDTO, 0, len(approvableIDs))
|
||||
for _, id := range approvableIDs {
|
||||
responseDTO, err := s.GetOne(c, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, *responseDTO)
|
||||
}
|
||||
|
||||
for expenseID, invalidateFromDate := range invalidateFromDateByExpenseID {
|
||||
s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *expenseService) createRealizationFromExpenseLines(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
expense *entity.Expense,
|
||||
realizationDate time.Time,
|
||||
actorID uint,
|
||||
notes *string,
|
||||
) error {
|
||||
if expense == nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Expense not found")
|
||||
}
|
||||
if tx == nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Transaction is required")
|
||||
}
|
||||
if err := s.ensureProjectFlockNotClosedForExpense(ctx, expense); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
realizationRepoTx := repository.NewExpenseRealizationRepository(tx)
|
||||
expenseRepoTx := repository.NewExpenseRepository(tx)
|
||||
approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
|
||||
|
||||
for _, expenseNonstock := range expense.Nonstocks {
|
||||
expenseNonstockID := expenseNonstock.Id
|
||||
|
||||
_, err := realizationRepoTx.GetByExpenseNonstockID(ctx, expenseNonstockID)
|
||||
if err == nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Realization already exists for expense nonstock %d", expenseNonstockID))
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to check existing realization")
|
||||
}
|
||||
|
||||
realization := &entity.ExpenseRealization{
|
||||
ExpenseNonstockId: &expenseNonstockID,
|
||||
Qty: expenseNonstock.Qty,
|
||||
Price: expenseNonstock.Price,
|
||||
Notes: expenseNonstock.Notes,
|
||||
}
|
||||
|
||||
if err := realizationRepoTx.CreateOne(ctx, realization, nil); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization")
|
||||
}
|
||||
}
|
||||
|
||||
if err := expenseRepoTx.PatchOne(ctx, uint(expense.Id), map[string]interface{}{
|
||||
"realization_date": realizationDate,
|
||||
}, nil); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date")
|
||||
}
|
||||
|
||||
approvalAction := entity.ApprovalActionCreated
|
||||
if _, err := approvalSvc.CreateApproval(
|
||||
ctx,
|
||||
utils.ApprovalWorkflowExpense,
|
||||
uint(expense.Id),
|
||||
utils.ExpenseStepRealisasi,
|
||||
&approvalAction,
|
||||
actorID,
|
||||
notes,
|
||||
); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization approval")
|
||||
}
|
||||
|
||||
expense.RealizationDate = realizationDate
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) (*expenseDto.ExpenseDetailDTO, error) {
|
||||
|
||||
if err := commonSvc.EnsureRelations(c.Context(),
|
||||
|
||||
Reference in New Issue
Block a user