package service import ( "context" "encoding/json" "errors" "fmt" "time" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" expenseDto "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/validations" nonstockRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/repositories" supplierRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" "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 ExpenseService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*expenseDto.ExpenseDetailDTO, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*expenseDto.ExpenseDetailDTO, error) DeleteOne(ctx *fiber.Ctx, id uint) error ApproveExpense(ctx *fiber.Ctx, id uint, step string, action string, notes *string) (*expenseDto.ExpenseDetailDTO, error) CreateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.CreateRealization) (*expenseDto.ExpenseDetailDTO, error) CompleteExpense(ctx *fiber.Ctx, id uint, notes *string) (*expenseDto.ExpenseDetailDTO, error) UpdateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error) } type expenseService struct { Log *logrus.Logger Validate *validator.Validate Repository repository.ExpenseRepository SupplierRepo supplierRepo.SupplierRepository NonstockRepo nonstockRepo.NonstockRepository ApprovalSvc commonSvc.ApprovalService RealizationRepository repository.ExpenseRealizationRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository } func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierRepo.SupplierRepository, nonstockRepo nonstockRepo.NonstockRepository, approvalSvc commonSvc.ApprovalService, realizationRepo repository.ExpenseRealizationRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, validate *validator.Validate) ExpenseService { return &expenseService{ Log: utils.Log, Validate: validate, Repository: repo, SupplierRepo: supplierRepo, NonstockRepo: nonstockRepo, ApprovalSvc: approvalSvc, RealizationRepository: realizationRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, } } func (s expenseService) withRelations(db *gorm.DB) *gorm.DB { return db. Preload("CreatedUser"). Preload("Supplier"). Preload("Nonstocks", func(db *gorm.DB) *gorm.DB { return db.Preload("Nonstock").Preload("Realization").Preload("ProjectFlockKandang.Kandang.Location") }) } func (s expenseService) getExpenseWithDetails(c *fiber.Ctx, expenseId uint) (*expenseDto.ExpenseDetailDTO, error) { expense, err := s.Repository.GetByID(c.Context(), expenseId, s.withRelations) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { s.Log.Errorf("Expense not found for ID %d: %+v", expenseId, err) return nil, fiber.NewError(fiber.StatusNotFound, "Expense not found") } s.Log.Errorf("Failed to get expense for ID %d: %+v", expenseId, err) return nil, err } // Load latest approval with ActionUser latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, expenseId, func(db *gorm.DB) *gorm.DB { return db.Preload("ActionUser") }) if err != nil { // Don't fail if approval loading fails, just log s.Log.Warnf("Failed to load approval for expense %d: %+v", expenseId, err) } expense.LatestApproval = latestApproval responseDTO := expenseDto.ToExpenseDetailDTO(expense) return &responseDTO, nil } func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error) { if err := s.Validate.Struct(params); err != nil { s.Log.Errorf("Validation failed for GetAll: %+v", err) return nil, 0, err } offset := (params.Page - 1) * params.Limit expenses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { return db.Where("category LIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) if err != nil { s.Log.Errorf("Failed to get expenses: %+v", err) return nil, 0, err } // Load approvals for each expense for i := range expenses { latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, uint(expenses[i].Id), func(db *gorm.DB) *gorm.DB { return db.Preload("ActionUser") }) if err != nil { s.Log.Warnf("Failed to load approval for expense %d: %+v", expenses[i].Id, err) } expenses[i].LatestApproval = latestApproval } result := expenseDto.ToExpenseListDTOs(expenses) return result, total, nil } func (s expenseService) GetOne(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error) { return s.getExpenseWithDetails(c, id) } func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expenseDto.ExpenseDetailDTO, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } // Validate supplier exists using common service supplierID := uint(req.SupplierID) supplierExistsFunc := func(ctx context.Context, id uint) (bool, error) { return commonRepo.Exists[entity.Supplier](ctx, s.SupplierRepo.DB(), id) } if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "Supplier", ID: &supplierID, Exists: supplierExistsFunc}, ); err != nil { return nil, err } for _, costPerKandang := range req.CostPerKandangs { for _, costItem := range costPerKandang.CostItems { nonstockId := uint(costItem.NonstockID) if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "Nonstock", ID: &nonstockId, Exists: s.NonstockRepo.IdExists}, ); err != nil { return nil, err } nonstockEntity, err := s.NonstockRepo.GetByID(c.Context(), nonstockId, func(db *gorm.DB) *gorm.DB { return db.Preload("Suppliers") }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Nonstock not found") } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get nonstock") } supplierFound := false for _, sn := range nonstockEntity.Suppliers { if uint64(sn.Id) == req.SupplierID { supplierFound = true break } } if !supplierFound { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Nonstock ID %d does not belong to supplier ID %d", nonstockId, req.SupplierID)) } } } expenseDate, err := utils.ParseDateString(req.TransactionDate) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction_date format") } var expense *entity.Expense err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { expenseRepoTx := repository.NewExpenseRepository(dbTransaction) expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(dbTransaction) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) referenceNumber, err := s.generateReferenceNumber(dbTransaction) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate reference number") } var grandTotal float64 for _, costPerKandang := range req.CostPerKandangs { for _, costItem := range costPerKandang.CostItems { grandTotal += costItem.TotalCost } } createdBy := uint64(1) //todo get from auth expense = &entity.Expense{ ReferenceNumber: &referenceNumber, PoNumber: req.PoNumber, Category: req.Category, SupplierId: &req.SupplierID, ExpenseDate: expenseDate, GrandTotal: grandTotal, CreatedBy: &createdBy, } if err := expenseRepoTx.CreateOne(c.Context(), expense, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense") } if len(req.CostPerKandangs) > 0 { for _, costPerKandang := range req.CostPerKandangs { projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(dbTransaction) projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(costPerKandang.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 costPerKandang.CostItems { projectFlockKandangId := uint64(projectFlockKandang.Id) nonstockId := costItem.NonstockID expenseNonstock := &entity.ExpenseNonstock{ ExpenseId: &expense.Id, ProjectFlockKandangId: &projectFlockKandangId, NonstockId: &nonstockId, Qty: costItem.Quantity, TotalPrice: costItem.TotalCost, Note: &costItem.Notes, } if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item") } } } } approvalAction := entity.ApprovalActionCreated createdByUint := uint(createdBy) if _, err := approvalSvcTx.CreateApproval( c.Context(), utils.ApprovalWorkflowExpense, uint(expense.Id), utils.ExpenseStepPengajuan, &approvalAction, createdByUint, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create initial approval") } // 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 }) if err != nil { if fiberErr, ok := err.(*fiber.Error); ok { return nil, fiberErr } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense") } return s.getExpenseWithDetails(c, uint(expense.Id)) } func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*expenseDto.ExpenseDetailDTO, error) { if err := s.Validate.Struct(req); err != nil { s.Log.Errorf("Validation failed for UpdateOne: %+v", err) return nil, err } // Validate expense exists using common service if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: func(ctx context.Context, id uint) (bool, error) { return s.Repository.IdExists(ctx, uint64(id)) }}, ); err != nil { 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 { // Validate supplier exists using common service supplierID := uint(*req.SupplierID) if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "Supplier", ID: &supplierID, Exists: func(ctx context.Context, id uint) (bool, error) { return commonRepo.Exists[entity.Supplier](ctx, s.SupplierRepo.DB(), id) }}, ); err != nil { return nil, err } updateBody["supplier_id"] = *req.SupplierID } 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) } // 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 } } // 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) } func (s expenseService) DeleteOne(c *fiber.Ctx, id uint) error { // Validate expense exists using common service if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: func(ctx context.Context, id uint) (bool, error) { return s.Repository.IdExists(ctx, uint64(id)) }}, ); err != nil { return err } if err := s.Repository.DeleteOne(c.Context(), id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { s.Log.Errorf("Expense not found for ID %d: %+v", id, err) return fiber.NewError(fiber.StatusNotFound, "Expense not found") } s.Log.Errorf("Failed to delete expense for ID %d: %+v", id, err) return err } s.Log.Infof("Successfully deleted expense with ID %d", id) return nil } func (s *expenseService) ApproveExpense(c *fiber.Ctx, id uint, stepName string, action string, notes *string) (*expenseDto.ExpenseDetailDTO, error) { if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: func(ctx context.Context, id uint) (bool, error) { return s.Repository.IdExists(ctx, uint64(id)) }}, ); err != nil { return nil, err } actorID := uint(1) // TODO: replace with authenticated user id var stepNumber approvalutils.ApprovalStep switch stepName { case "Manager": stepNumber = utils.ExpenseStepManager case "Finance": stepNumber = utils.ExpenseStepFinance default: s.Log.Errorf("Invalid approval step: %s", stepName) return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid approval step") } var expectedPreviousStep uint16 switch stepName { case "Manager": expectedPreviousStep = uint16(utils.ExpenseStepPengajuan) case "Finance": expectedPreviousStep = uint16(utils.ExpenseStepManager) } latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow") } if latestApproval == nil { return nil, fiber.NewError(fiber.StatusBadRequest, "No approval found. Please create expense first.") } if latestApproval.StepNumber != expectedPreviousStep { expectedStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(expectedPreviousStep)] currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot approve at step %s. Latest approval is at %s step. Expected previous step: %s", stepName, currentStepName, expectedStepName)) } var approvalAction entity.ApprovalAction switch action { case "APPROVED": approvalAction = entity.ApprovalActionApproved case "REJECTED": approvalAction = entity.ApprovalActionRejected default: return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid approval action") } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) expenseRepoTx := repository.NewExpenseRepository(tx) if _, err := approvalSvc.CreateApproval( c.Context(), utils.ApprovalWorkflowExpense, id, stepNumber, &approvalAction, actorID, notes); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create approval") } if stepName == "Finance" && action == "APPROVED" { poNumber, err := s.generatePoNumber(tx, id) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate PO number") } updateData := map[string]interface{}{ "po_number": poNumber, } if err := expenseRepoTx.PatchOne(c.Context(), id, updateData, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update PO number") } } return nil }) if err != nil { if fiberErr, ok := err.(*fiber.Error); ok { return nil, fiberErr } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to Approve") } return s.getExpenseWithDetails(c, id) } func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *validation.CreateRealization) (*expenseDto.ExpenseDetailDTO, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: func(ctx context.Context, id uint) (bool, error) { return s.Repository.IdExists(ctx, uint64(id)) }}, ); err != nil { return nil, err } realizationDate := time.Now() createdBy := uint64(1) // TODO: replace with authenticated user id 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) for _, realizationItem := range req.Realizations { expenseNonstockID := realizationItem.ExpenseNonstockID belongsToExpense, err := expenseNonstockRepoTx.GetByExpenseID(c.Context(), uint64(expenseID), uint64(expenseNonstockID)) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate ExpenseNonstock relation") } if !belongsToExpense { return fiber.NewError(fiber.StatusNotFound, "ExpenseNonstock not found or does not belong to this expense") } _, err = realizationRepoTx.GetByExpenseNonstockID(c.Context(), uint64(expenseNonstockID)) if err == nil { return fiber.NewError(fiber.StatusBadRequest, "Realization already exists for this expense nonstock") } else if !errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusInternalServerError, "Failed to check existing realization") } realization := &entity.ExpenseRealization{ ExpenseNonstockId: &expenseNonstockID, RealizationQty: realizationItem.Qty, RealizationUnitPrice: realizationItem.UnitPrice, RealizationTotalPrice: realizationItem.TotalPrice, RealizationDate: realizationDate, Note: realizationItem.Notes, CreatedBy: &createdBy, } if err := realizationRepoTx.CreateOne(c.Context(), realization, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization") } } approvalAction := entity.ApprovalActionCreated if _, err := approvalSvc.CreateApproval( c.Context(), utils.ApprovalWorkflowExpense, expenseID, utils.ExpenseStepRealisasi, &approvalAction, uint(createdBy), nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization approval") } return nil }); err != nil { return nil, err } return s.getExpenseWithDetails(c, expenseID) } func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) (*expenseDto.ExpenseDetailDTO, error) { // Validate expense exists using common service if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: func(ctx context.Context, id uint) (bool, error) { return s.Repository.IdExists(ctx, uint64(id)) }}, ); err != nil { return nil, err } actorID := uint(1) // TODO: replace with authenticated user id // 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 completed (must be at Realisasi step) if latestApproval == nil { s.Log.Errorf("No approval found for expense %d", id) return nil, fiber.NewError(fiber.StatusBadRequest, "No approval found. Please create expense first.") } if latestApproval.StepNumber != uint16(utils.ExpenseStepRealisasi) { currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] s.Log.Errorf("Cannot complete expense at step %s. Must be at Realisasi step", currentStepName) return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot complete expense at %s step. Must be at Realisasi step", currentStepName)) } // Create approval for Selesai step (step 5) using transaction err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) approvalAction := entity.ApprovalActionApproved if _, err := approvalSvc.CreateApproval( c.Context(), utils.ApprovalWorkflowExpense, id, utils.ExpenseStepSelesai, &approvalAction, actorID, notes); err != nil { s.Log.Errorf("Failed to create Selesai approval for expense %d: %+v", id, err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to complete expense") } return nil }) if err != nil { return nil, err } return s.getExpenseWithDetails(c, id) } func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error) { if err := s.Validate.Struct(req); err != nil { s.Log.Errorf("Validation failed for UpdateRealization: %+v", err) return nil, err } // Validate Expense exists using common service if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: func(ctx context.Context, id uint) (bool, error) { return s.Repository.IdExists(ctx, uint64(id)) }}, ); err != nil { return nil, err } // Use current date for realization date realizationDate := time.Now() // Update realizations using transaction if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { realizationRepoTx := repository.NewExpenseRealizationRepository(tx) expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(tx) // Process each realization item for _, realizationItem := range req.Realizations { // Validate ExpenseNonstock exists and belongs to this expense expenseNonstockID := realizationItem.ExpenseNonstockID belongsToExpense, err := expenseNonstockRepoTx.GetByExpenseID(c.Context(), uint64(expenseID), uint64(expenseNonstockID)) if err != nil { s.Log.Errorf("Failed to validate ExpenseNonstock relation: %+v", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate ExpenseNonstock relation") } if !belongsToExpense { s.Log.Errorf("ExpenseNonstock not found or does not belong to expense %d", expenseID) return fiber.NewError(fiber.StatusNotFound, "ExpenseNonstock not found or does not belong to this expense") } // Get existing realization existingRealization, err := realizationRepoTx.GetByExpenseNonstockID(c.Context(), uint64(expenseNonstockID)) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { s.Log.Errorf("Realization not found for expense nonstock %d", expenseNonstockID) return fiber.NewError(fiber.StatusNotFound, "Realization not found for this expense nonstock") } s.Log.Errorf("Failed to get existing realization: %+v", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to get existing realization") } // Update realization updateData := map[string]interface{}{ "realization_qty": realizationItem.Qty, "realization_unit_price": realizationItem.UnitPrice, "realization_total_price": realizationItem.TotalPrice, "realization_date": realizationDate, } if realizationItem.Notes != nil { updateData["note"] = *realizationItem.Notes } if err := realizationRepoTx.PatchOne(c.Context(), uint(existingRealization.Id), updateData, nil); err != nil { s.Log.Errorf("Failed to update realization: %+v", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization") } } return nil }); err != nil { return nil, err } // Return updated expense return s.getExpenseWithDetails(c, expenseID) } func (s *expenseService) generateReferenceNumber(ctx *gorm.DB) (string, error) { sequence, err := s.Repository.GetNextSequence(ctx.Statement.Context) if err != nil { return "", err } refNum := fmt.Sprintf("BOP-LTI-%05d", sequence) return refNum, nil } func (s *expenseService) generatePoNumber(ctx *gorm.DB, expenseID uint) (string, error) { expenseRepoTx := repository.NewExpenseRepository(ctx) expense, err := expenseRepoTx.GetByID(context.Background(), uint(expenseID), nil) if err != nil { return "", err } if expense.ReferenceNumber == nil { return "", errors.New("reference number is required") } poNum := fmt.Sprintf("PO-%s", *expense.ReferenceNumber) return poNum, nil }