From 4c7e5b073114ffd3203f50d9c109a950f0505a74 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Thu, 20 Nov 2025 08:27:02 +0700 Subject: [PATCH] Feat[BE-261,265]: add category request body on create on --- .../controllers/expense.controller.go | 1 + internal/modules/expenses/dto/expense.dto.go | 38 +++- .../expenses/services/expense.service.go | 194 ++++++++++++++++-- .../validations/expense.validation.go | 1 + 4 files changed, 206 insertions(+), 28 deletions(-) diff --git a/internal/modules/expenses/controllers/expense.controller.go b/internal/modules/expenses/controllers/expense.controller.go index 3a9d76bb..2d0bebac 100644 --- a/internal/modules/expenses/controllers/expense.controller.go +++ b/internal/modules/expenses/controllers/expense.controller.go @@ -82,6 +82,7 @@ func (u *ExpenseController) CreateOne(c *fiber.Ctx) error { req := new(validation.Create) req.TransactionDate = c.FormValue("transaction_date") + req.Category = c.FormValue("category") supplierID, err := strconv.ParseUint(c.FormValue("supplier_id"), 10, 64) if err != nil { diff --git a/internal/modules/expenses/dto/expense.dto.go b/internal/modules/expenses/dto/expense.dto.go index 1f1fecef..ae048ca1 100644 --- a/internal/modules/expenses/dto/expense.dto.go +++ b/internal/modules/expenses/dto/expense.dto.go @@ -1,11 +1,13 @@ package dto import ( + "encoding/json" "fmt" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" nonstockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/dto" supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" @@ -14,12 +16,14 @@ import ( // === Base DTO === type ExpenseBaseDTO struct { - Id uint64 `json:"id"` - ReferenceNumber string `json:"reference_number"` - PoNumber *string `json:"po_number,omitempty"` - Category string `json:"category"` - ExpenseDate time.Time `json:"expense_date"` - GrandTotal float64 `json:"grand_total"` + Id uint64 `json:"id"` + ReferenceNumber string `json:"reference_number"` + PoNumber *string `json:"po_number"` + Category string `json:"category"` + Documents []string `json:"documents,omitempty"` + ExpenseDate time.Time `json:"expense_date"` + GrandTotal float64 `json:"grand_total"` + Location *locationDTO.LocationBaseDTO `json:"location,omitempty"` } // === List DTO (untuk GetAll) === @@ -110,13 +114,33 @@ func getStringValue(s *string) string { // === Mapper Functions === func ToExpenseBaseDTO(e *entity.Expense) ExpenseBaseDTO { + var documents []string + var location *locationDTO.LocationBaseDTO + + // Parse document paths from JSON if available + if e.DocumentPath.Valid && e.DocumentPath.String != "" { + if err := json.Unmarshal([]byte(e.DocumentPath.String), &documents); err == nil { + // Successfully parsed documents + } + } + + // Get location from the first kandang if available + if len(e.Nonstocks) > 0 && e.Nonstocks[0].ProjectFlockKandang != nil { + if e.Nonstocks[0].ProjectFlockKandang.Kandang.Location.Id != 0 { + mapped := locationDTO.ToLocationBaseDTO(e.Nonstocks[0].ProjectFlockKandang.Kandang.Location) + location = &mapped + } + } + return ExpenseBaseDTO{ Id: e.Id, ReferenceNumber: getStringValue(e.ReferenceNumber), - PoNumber: e.PoNumber, + PoNumber: e.PoNumber, // Keep as pointer to allow null in JSON Category: e.Category, + Documents: documents, ExpenseDate: e.ExpenseDate, GrandTotal: e.GrandTotal, + Location: location, } } diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index e0d2b343..3ed792fb 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "errors" "fmt" "time" @@ -65,7 +66,7 @@ func (s expenseService) withRelations(db *gorm.DB) *gorm.DB { Preload("CreatedUser"). Preload("Supplier"). Preload("Nonstocks", func(db *gorm.DB) *gorm.DB { - return db.Preload("Nonstock").Preload("Realization").Preload("ProjectFlockKandang") + return db.Preload("Nonstock").Preload("Realization").Preload("ProjectFlockKandang.Kandang.Location") }) } @@ -150,15 +151,6 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen return nil, err } - // Get supplier for category - supplierEntity, err := s.SupplierRepo.GetByID(c.Context(), uint(req.SupplierID), nil) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Supplier not found") - } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get supplier") - } - for _, costPerKandang := range req.CostPerKandangs { for _, costItem := range costPerKandang.CostItems { nonstockId := uint(costItem.NonstockID) @@ -220,7 +212,7 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen expense = &entity.Expense{ ReferenceNumber: &referenceNumber, PoNumber: req.PoNumber, - Category: supplierEntity.Category, + Category: req.Category, SupplierId: &req.SupplierID, ExpenseDate: expenseDate, GrandTotal: grandTotal, @@ -276,12 +268,36 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen &approvalAction, createdByUint, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create initial approval") + } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to create initial approval") - } // TODO: Handle documents (save file references) + // Handle documents - save file paths as JSON array + if len(req.Documents) > 0 { + documentPaths := make([]string, len(req.Documents)) + for i, doc := range req.Documents { + // Generate dummy path for each document + // In real implementation, this would save the file and return the actual path + documentPath := fmt.Sprintf("/documents/expenses/%d_%d_%s", expense.Id, i+1, doc.Filename) + documentPaths[i] = documentPath + } + + // Save document paths as JSON in expense record + documentPathsJSON, err := json.Marshal(documentPaths) + if err != nil { + s.Log.Errorf("Failed to marshal document paths: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to save document references") + } + + if err := expenseRepoTx.PatchOne(c.Context(), uint(expense.Id), map[string]interface{}{ + "document_path": string(documentPathsJSON), + }, nil); err != nil { + s.Log.Errorf("Failed to save document paths: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to save document references") + } + } - return nil - }) + return nil + }) if err != nil { if fiberErr, ok := err.(*fiber.Error); ok { @@ -308,6 +324,23 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) return nil, err } + // Get latest approval to validate workflow + latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to get latest approval for expense %d: %+v", id, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow") + } + + // Check if expense can be updated (must be before Realisasi step) + if latestApproval != nil { + if latestApproval.StepNumber >= uint16(utils.ExpenseStepRealisasi) { + currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] + s.Log.Errorf("Cannot update expense at %s step. Must be before Realisasi step", currentStepName) + return nil, fiber.NewError(fiber.StatusBadRequest, + fmt.Sprintf("Cannot update expense at %s step. Must be before Realisasi step", currentStepName)) + } + } + updateBody := make(map[string]any) if req.SupplierID != nil { @@ -324,16 +357,135 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) updateBody["supplier_id"] = *req.SupplierID } - if len(updateBody) == 0 { + if req.TransactionDate != nil { + // Parse transaction_date + expenseDate, err := utils.ParseDateString(*req.TransactionDate) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction_date format") + } + updateBody["expense_date"] = expenseDate + } + + if req.PoNumber != nil { + updateBody["po_number"] = *req.PoNumber + } + + if len(updateBody) == 0 && req.CostPerKandang == nil { return s.getExpenseWithDetails(c, id) } - if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Expense not found") + // Update expense using transaction + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + expenseRepoTx := repository.NewExpenseRepository(tx) + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) + expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(tx) + + // Update expense + if len(updateBody) > 0 { + if err := expenseRepoTx.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Expense not found") + } + s.Log.Errorf("Failed to update expense: %+v", err) + return err + } } - s.Log.Errorf("Failed to update expense: %+v", err) - return nil, err + + // Update cost per kandang if provided + if req.CostPerKandang != nil { + // First, delete existing expense nonstocks using GORM + if err := tx.Where("expense_id = ?", id).Delete(&entity.ExpenseNonstock{}).Error; err != nil { + s.Log.Errorf("Failed to delete existing expense nonstocks: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update expense items") + } + + // Calculate new grand total + var grandTotal float64 + for _, cpk := range *req.CostPerKandang { + for _, costItem := range cpk.CostItems { + grandTotal += costItem.TotalCost + } + } + + // Update expense grand total + if err := expenseRepoTx.PatchOne(c.Context(), id, map[string]interface{}{ + "grand_total": grandTotal, + }, nil); err != nil { + s.Log.Errorf("Failed to update expense grand total: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update expense grand total") + } + + // Create new expense nonstocks + for _, cpk := range *req.CostPerKandang { + // Get active project_flock_kandang for this kandang + projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx) + projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(cpk.KandangID)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang") + } + + for _, costItem := range cpk.CostItems { + // Validate nonstock exists + nonstockId := uint(costItem.NonstockID) + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Nonstock", ID: &nonstockId, Exists: s.NonstockRepo.IdExists}, + ); err != nil { + return err + } + + projectFlockKandangId := uint64(projectFlockKandang.Id) + expenseId := uint64(id) + expenseNonstock := &entity.ExpenseNonstock{ + ExpenseId: &expenseId, + ProjectFlockKandangId: &projectFlockKandangId, + NonstockId: &costItem.NonstockID, + Qty: costItem.Quantity, + TotalPrice: costItem.TotalCost, + Note: &costItem.Notes, + } + + if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, nil); err != nil { + s.Log.Errorf("Failed to create expense nonstock: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item") + } + } + } + } + + // Reset approval step to Pengajuan + actorID := uint(1) // TODO: replace with authenticated user id + var approvalAction entity.ApprovalAction + + // Check if latest approval was Updated, then use Updated action, otherwise use Created + if latestApproval != nil && latestApproval.Action != nil && *latestApproval.Action == entity.ApprovalActionUpdated { + approvalAction = entity.ApprovalActionUpdated + } else { + approvalAction = entity.ApprovalActionCreated + } + + if _, err := approvalSvcTx.CreateApproval( + c.Context(), + utils.ApprovalWorkflowExpense, + id, + utils.ExpenseStepPengajuan, + &approvalAction, + actorID, + nil); err != nil { + s.Log.Errorf("Failed to reset approval to Pengajuan for expense %d: %+v", id, err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to reset approval step") + } + + return nil + }) + + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update expense") } return s.getExpenseWithDetails(c, id) diff --git a/internal/modules/expenses/validations/expense.validation.go b/internal/modules/expenses/validations/expense.validation.go index 53a8d95f..56420e06 100644 --- a/internal/modules/expenses/validations/expense.validation.go +++ b/internal/modules/expenses/validations/expense.validation.go @@ -13,6 +13,7 @@ type ApprovalRequest struct { type Create struct { PoNumber *string `form:"po_number" validate:"omitempty,max=50"` TransactionDate string `form:"transaction_date" validate:"required,datetime=2006-01-02"` + Category string `form:"category" validate:"required,oneof=BOP NON-BOP"` SupplierID uint64 `form:"supplier_id" validate:"required,gt=0"` Documents []*multipart.FileHeader `form:"documents" validate:"omitempty,dive"` CostPerKandangs []CostPerKandang `form:"cost_per_kandangs" validate:"required,min=1,dive"`