From 0d04397bd5dc9d97535fe2df6c0c52ad301ff770 Mon Sep 17 00:00:00 2001 From: Adnan Zahir Date: Tue, 21 Apr 2026 20:06:37 +0700 Subject: [PATCH] feat: bulk approve endpoint for marketings and expenses --- .gitignore | 1 + .../controllers/expense.controller.go | 73 ++++++ internal/modules/expenses/route.go | 1 + .../expenses/services/expense.service.go | 225 +++++++++++++++++- .../validations/expense.validation.go | 33 +++ .../controllers/deliveryorder.controller.go | 64 +++++ internal/modules/marketing/route.go | 1 + .../services/deliveryorder.service.go | 188 +++++++++++++++ .../validations/salesorder.validation.go | 32 +++ 9 files changed, 607 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 3522e1f6..6d0df61a 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ coverage/ .idea/ *.swp .DS_Store +.gemini/ diff --git a/internal/modules/expenses/controllers/expense.controller.go b/internal/modules/expenses/controllers/expense.controller.go index 49c8f356..365c46ce 100644 --- a/internal/modules/expenses/controllers/expense.controller.go +++ b/internal/modules/expenses/controllers/expense.controller.go @@ -7,10 +7,13 @@ import ( "strconv" "strings" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/validations" "gitlab.com/mbugroup/lti-api.git/internal/response" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" "github.com/gofiber/fiber/v2" ) @@ -264,6 +267,51 @@ func (u *ExpenseController) Approval(c *fiber.Ctx) error { }) } +func (u *ExpenseController) BulkApproveToStatus(c *fiber.Ctx) error { + req := new(validation.BulkApprovalRequest) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + targetStep, err := req.ResolveTarget() + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if req.RequiresDate(targetStep) && strings.TrimSpace(req.Date) == "" { + return fiber.NewError(fiber.StatusBadRequest, "date is required for REALISASI bulk approval") + } + + if err := ensureExpenseBulkApprovalPermission(c, targetStep); err != nil { + return err + } + + results, err := u.ExpenseService.BulkApproveToStatus(c, req, targetStep) + if err != nil { + return err + } + + var ( + data interface{} + message = "Bulk approve expense successfully" + ) + if len(results) == 1 { + data = results[0] + } else { + message = "Bulk approve expenses successfully" + data = results + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: message, + Data: data, + }) +} + func (u *ExpenseController) CreateRealization(c *fiber.Ctx) error { expenseID := c.Params("id") id, err := strconv.Atoi(expenseID) @@ -366,6 +414,31 @@ func (u *ExpenseController) CompleteExpense(c *fiber.Ctx) error { }) } +func ensureExpenseBulkApprovalPermission(c *fiber.Ctx, targetStep approvalutils.ApprovalStep) error { + requiredPerms := []string{} + + switch targetStep { + case utils.ExpenseStepHeadArea: + requiredPerms = []string{m.P_ExpenseApprovalHeadArea} + case utils.ExpenseStepUnitVicePresident: + requiredPerms = []string{m.P_ExpenseApprovalUnitVicePresident} + case utils.ExpenseStepFinance: + requiredPerms = []string{m.P_ExpenseApprovalFinance} + case utils.ExpenseStepRealisasi: + requiredPerms = []string{m.P_ExpenseApprovalFinance, m.P_ExpenseCreateRealizations} + default: + return fiber.NewError(fiber.StatusBadRequest, "Invalid approval target") + } + + for _, perm := range requiredPerms { + if !m.HasPermission(c, perm) { + return fiber.NewError(fiber.StatusForbidden, "Insufficient permission") + } + } + + return nil +} + func (u *ExpenseController) DeleteDocument(c *fiber.Ctx) error { expenseID, err := strconv.Atoi(c.Params("id")) if err != nil { diff --git a/internal/modules/expenses/route.go b/internal/modules/expenses/route.go index 6ddceb14..f3815e7a 100644 --- a/internal/modules/expenses/route.go +++ b/internal/modules/expenses/route.go @@ -31,6 +31,7 @@ func ExpenseRoutes(v1 fiber.Router, u user.UserService, s expense.ExpenseService route.Post("/approvals/head-area", m.RequirePermissions(m.P_ExpenseApprovalHeadArea), ctrl.Approval) route.Post("/approvals/finance", m.RequirePermissions(m.P_ExpenseApprovalFinance), ctrl.Approval) route.Post("/approvals/unit-vice-president", m.RequirePermissions(m.P_ExpenseApprovalUnitVicePresident), ctrl.Approval) + route.Post("/approvals/bulk", ctrl.BulkApproveToStatus) route.Post("/:id/realizations", m.RequirePermissions(m.P_ExpenseCreateRealizations), ctrl.CreateRealization) route.Patch("/:id/realizations", m.RequirePermissions(m.P_ExpenseUpdateRealizations), ctrl.UpdateRealization) diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 57593e59..082e2dd6 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -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(), diff --git a/internal/modules/expenses/validations/expense.validation.go b/internal/modules/expenses/validations/expense.validation.go index 3fb9ccd5..d8107e7c 100644 --- a/internal/modules/expenses/validations/expense.validation.go +++ b/internal/modules/expenses/validations/expense.validation.go @@ -1,7 +1,12 @@ package validation import ( + "errors" "mime/multipart" + "strings" + + "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" ) type Create struct { @@ -66,3 +71,31 @@ type ApprovalRequest struct { ApprovableIds []uint `json:"approvable_ids" validate:"required,min=1,dive,gt=0"` Notes *string `json:"notes" form:"notes"` } + +type BulkApprovalRequest struct { + ApprovableIds []uint `json:"approvable_ids" validate:"required,min=1,dive,gt=0"` + Status string `json:"status" validate:"required,max=100"` + Date string `json:"date,omitempty" validate:"omitempty,datetime=2006-01-02"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` +} + +func (r *BulkApprovalRequest) ResolveTarget() (approvalutils.ApprovalStep, error) { + status := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(r.Status), " ", "_")) + + switch status { + case "HEAD_AREA", "APPROVAL_HEAD_AREA": + return utils.ExpenseStepHeadArea, nil + case "UNIT_VICE_PRESIDENT", "APPROVAL_UNIT_VICE_PRESIDENT", "BUSINESS_UNIT_VICE_PRESIDENT", "APPROVAL_BUSINESS_UNIT_VICE_PRESIDENT": + return utils.ExpenseStepUnitVicePresident, nil + case "FINANCE", "APPROVAL_FINANCE": + return utils.ExpenseStepFinance, nil + case "REALISASI": + return utils.ExpenseStepRealisasi, nil + default: + return 0, errors.New("status must be one of HEAD_AREA, UNIT_VICE_PRESIDENT, FINANCE, or REALISASI") + } +} + +func (r *BulkApprovalRequest) RequiresDate(target approvalutils.ApprovalStep) bool { + return target == utils.ExpenseStepRealisasi +} diff --git a/internal/modules/marketing/controllers/deliveryorder.controller.go b/internal/modules/marketing/controllers/deliveryorder.controller.go index 3a6ca49b..6fb5c8fc 100644 --- a/internal/modules/marketing/controllers/deliveryorder.controller.go +++ b/internal/modules/marketing/controllers/deliveryorder.controller.go @@ -5,10 +5,13 @@ import ( "strconv" "strings" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" "gitlab.com/mbugroup/lti-api.git/internal/response" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" "github.com/gofiber/fiber/v2" ) @@ -152,3 +155,64 @@ func (u *DeliveryOrdersController) UpdateOne(c *fiber.Ctx) error { Data: result, }) } + +func (u *DeliveryOrdersController) BulkApproveToStatus(c *fiber.Ctx) error { + req := new(validation.BulkApprovalRequest) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + targetStep, err := req.ResolveTarget() + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if req.RequiresDate(targetStep) && strings.TrimSpace(req.Date) == "" { + return fiber.NewError(fiber.StatusBadRequest, "date is required for DELIVERY bulk approval") + } + + if err := ensureMarketingBulkApprovalPermission(c, targetStep); err != nil { + return err + } + + results, err := u.DeliveryOrdersService.BulkApproveToStatus(c, req, targetStep) + if err != nil { + return err + } + + var ( + data interface{} + message = "Bulk approve marketing successfully" + ) + if len(results) == 1 { + data = results[0] + } else { + message = "Bulk approve marketings successfully" + data = results + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: message, + Data: data, + }) +} + +func ensureMarketingBulkApprovalPermission(c *fiber.Ctx, targetStep approvalutils.ApprovalStep) error { + requiredPerms := []string{m.P_SalesOrderApproval} + + if targetStep == utils.MarketingDeliveryOrder { + requiredPerms = append(requiredPerms, m.P_DeliveryUpdateOne) + } + + for _, perm := range requiredPerms { + if !m.HasPermission(c, perm) { + return fiber.NewError(fiber.StatusForbidden, "Insufficient permission") + } + } + + return nil +} diff --git a/internal/modules/marketing/route.go b/internal/modules/marketing/route.go index 81402c7c..364efbec 100644 --- a/internal/modules/marketing/route.go +++ b/internal/modules/marketing/route.go @@ -23,6 +23,7 @@ func RegisterRoutes(router fiber.Router, userService user.UserService, salesOrde route.Post("/sales-orders", m.RequirePermissions(m.P_SalesOrderCreateOne), salesOrdersCtrl.CreateOne) route.Patch("/sales-orders/:id", m.RequirePermissions(m.P_SalesOrderUpdateOne), salesOrdersCtrl.UpdateOne) route.Post("/sales-orders/approvals", m.RequirePermissions(m.P_SalesOrderApproval), salesOrdersCtrl.Approval) + route.Post("/approvals/bulk", deliveryOrdersCtrl.BulkApproveToStatus) route.Post("/delivery-orders", m.RequirePermissions(m.P_DeliveryCreateOne), deliveryOrdersCtrl.CreateOne) route.Patch("/delivery-orders/:id", m.RequirePermissions(m.P_DeliveryUpdateOne), deliveryOrdersCtrl.UpdateOne) diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index 34c415c3..b49b14ef 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -20,6 +20,7 @@ import ( rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rShared "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "github.com/go-playground/validator/v10" @@ -32,6 +33,7 @@ type DeliveryOrdersService interface { GetOne(ctx *fiber.Ctx, id uint) (*dto.MarketingDetailDTO, error) CreateOne(ctx *fiber.Ctx, req *validation.DeliveryOrderCreate) (*dto.MarketingDetailDTO, error) UpdateOne(ctx *fiber.Ctx, req *validation.DeliveryOrderUpdate, id uint) (*dto.MarketingDetailDTO, error) + BulkApproveToStatus(ctx *fiber.Ctx, req *validation.BulkApprovalRequest, target approvalutils.ApprovalStep) ([]dto.MarketingDetailDTO, error) } type deliveryOrdersService struct { @@ -544,6 +546,192 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO return s.getMarketingWithDeliveries(c, id) } +func (s deliveryOrdersService) BulkApproveToStatus(c *fiber.Ctx, req *validation.BulkApprovalRequest, target approvalutils.ApprovalStep) ([]dto.MarketingDetailDTO, 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") + } + + for _, id := range approvableIDs { + if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), id); err != nil { + return nil, err + } + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + var deliveryDate time.Time + if req.RequiresDate(target) { + deliveryDate, err = utils.ParseDateString(req.Date) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid date format") + } + } + + err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) + marketingRepoTx := marketingRepo.NewMarketingRepository(tx) + + for _, id := range approvableIDs { + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Marketing", ID: &id, Exists: marketingRepoTx.IdExists}, + ); err != nil { + return err + } + + marketing, err := marketingRepoTx.GetByID(c.Context(), id, s.withRelations) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Marketing %d not found", id)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch marketing") + } + + latestApproval, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowMarketing, id, nil) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to check approval status") + } + if latestApproval == nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No approval found for Marketing %d", id)) + } + if latestApproval.Action != nil && *latestApproval.Action == entity.ApprovalActionRejected { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Marketing %d is rejected and cannot be bulk approved", id)) + } + + currentStep := approvalutils.ApprovalStep(latestApproval.StepNumber) + if currentStep >= target { + currentStepName := utils.MarketingApprovalSteps[currentStep] + targetStepName := utils.MarketingApprovalSteps[target] + if currentStep == target { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Marketing %d is already at %s step", id, targetStepName)) + } + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Marketing %d is already beyond %s step (current step: %s)", id, targetStepName, currentStepName)) + } + + if len(marketing.Products) > 0 { + pwIDs := make([]uint, 0, len(marketing.Products)) + for _, product := range marketing.Products { + if product.ProductWarehouseId != 0 { + pwIDs = append(pwIDs, product.ProductWarehouseId) + } + } + if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(c.Context(), tx, pwIDs); err != nil { + return err + } + } + + for step := currentStep + 1; step <= target; step++ { + if step == utils.MarketingDeliveryOrder { + if err := s.createDeliveryFromMarketingProducts(c.Context(), tx, marketing, deliveryDate, actorID); err != nil { + return err + } + } + + approvalAction := entity.ApprovalActionApproved + if _, err := approvalSvcTx.CreateApproval( + c.Context(), + utils.ApprovalWorkflowMarketing, + id, + step, + &approvalAction, + actorID, + req.Notes, + ); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create approval") + } + } + } + + 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 marketings") + } + + results := make([]dto.MarketingDetailDTO, 0, len(approvableIDs)) + for _, id := range approvableIDs { + result, err := s.getMarketingWithDeliveries(c, id) + if err != nil { + return nil, err + } + results = append(results, *result) + } + + return results, nil +} + +func (s deliveryOrdersService) createDeliveryFromMarketingProducts( + ctx context.Context, + tx *gorm.DB, + marketing *entity.Marketing, + deliveryDate time.Time, + actorID uint, +) error { + if marketing == nil { + return fiber.NewError(fiber.StatusBadRequest, "Marketing not found") + } + if tx == nil { + return fiber.NewError(fiber.StatusInternalServerError, "Transaction is required") + } + + marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(tx) + + for _, marketingProduct := range marketing.Products { + deliveryProduct := marketingProduct.DeliveryProduct + if deliveryProduct == nil { + record, err := marketingDeliveryProductRepositoryTx.GetByMarketingProductID(ctx, marketingProduct.Id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Delivery product for marketing product %d not found", marketingProduct.Id)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch delivery product") + } + deliveryProduct = record + } + + oldRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty + deliveryDateCopy := deliveryDate + + deliveryProduct.ProductWarehouseId = marketingProduct.ProductWarehouseId + deliveryProduct.UnitPrice = marketingProduct.UnitPrice + deliveryProduct.AvgWeight = marketingProduct.AvgWeight + deliveryProduct.WeightPerConvertion = marketingProduct.WeightPerConvertion + deliveryProduct.TotalWeight = marketingProduct.TotalWeight + deliveryProduct.TotalPrice = marketingProduct.TotalPrice + deliveryProduct.DeliveryDate = &deliveryDateCopy + + requestedQty := marketingProduct.Qty + if requestedQty != oldRequestedQty { + if oldRequestedQty > 0 { + if err := s.releaseDeliveryStock(ctx, tx, deliveryProduct, &marketingProduct, actorID); err != nil { + return err + } + } + if requestedQty > 0 { + if err := s.consumeDeliveryStock(ctx, tx, deliveryProduct, &marketingProduct, requestedQty, actorID); err != nil { + return err + } + } + } + + if err := marketingDeliveryProductRepositoryTx.UpdateOne(ctx, deliveryProduct.Id, deliveryProduct, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") + } + } + + return nil +} + func (s *deliveryOrdersService) calculatePriceByMarketingType(marketingType string, qty, avgWeight, unitPrice float64, week *int, convertionUnit *string, _ *float64) (totalWeight, totalPrice float64) { if marketingType == string(utils.MarketingTypeTrading) { totalWeight = 0 diff --git a/internal/modules/marketing/validations/salesorder.validation.go b/internal/modules/marketing/validations/salesorder.validation.go index 6d6b80b6..f3fda360 100644 --- a/internal/modules/marketing/validations/salesorder.validation.go +++ b/internal/modules/marketing/validations/salesorder.validation.go @@ -1,5 +1,13 @@ package validation +import ( + "errors" + "strings" + + "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" +) + type Create struct { CustomerId uint `json:"customer_id" validate:"required,gt=0"` SalesPersonId uint `json:"sales_person_id" validate:"required,gt=0"` @@ -33,3 +41,27 @@ type Approve struct { ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"` Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` } + +type BulkApprovalRequest struct { + ApprovableIds []uint `json:"approvable_ids" validate:"required,min=1,dive,gt=0"` + Status string `json:"status" validate:"required,max=100"` + Date string `json:"date,omitempty" validate:"omitempty,datetime=2006-01-02"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` +} + +func (r *BulkApprovalRequest) ResolveTarget() (approvalutils.ApprovalStep, error) { + status := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(r.Status), " ", "_")) + + switch status { + case "SALES_ORDER": + return utils.MarketingStepSalesOrder, nil + case "DELIVERY", "DELIVERY_ORDER": + return utils.MarketingDeliveryOrder, nil + default: + return 0, errors.New("status must be one of SALES_ORDER or DELIVERY") + } +} + +func (r *BulkApprovalRequest) RequiresDate(target approvalutils.ApprovalStep) bool { + return target == utils.MarketingDeliveryOrder +}