package service import ( "context" "database/sql" "encoding/json" "errors" "fmt" "mime/multipart" 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 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) DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error Approval(ctx *fiber.Ctx, req *validation.ApprovalRequest, approvalType string) ([]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.Nonstock"). Preload("Nonstocks.Realization"). Preload("Nonstocks.ProjectFlockKandang.Kandang.Location"). Preload("Nonstocks.Kandang"). Preload("Nonstocks.Kandang.Location") } func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error) { if err := s.Validate.Struct(params); err != nil { 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 { return nil, 0, err } 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 { return nil, 0, 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) { expense, err := s.Repository.GetByID(c.Context(), id, s.withRelations) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Expense not found") } return nil, err } approval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, func(db *gorm.DB) *gorm.DB { return db.Preload("ActionUser") }) if err != nil { return nil, err } expense.LatestApproval = approval responseDTO := expenseDto.ToExpenseDetailDTO(expense) return &responseDTO, nil } func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expenseDto.ExpenseDetailDTO, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } 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 _, expenseNonstock := range req.ExpenseNonstocks { for _, costItem := range expenseNonstock.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 } supplierFound, err := s.NonstockRepo.IsNonstockAssociatedWithSupplier(c.Context(), nonstockId, req.SupplierID) 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 check nonstock-supplier relation") } 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)) projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(dbTransaction) referenceNumber, err := GenerateExpenseReferenceNumber(c.Context(), expenseRepoTx) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate reference number") } createdBy := uint64(1) //todo get from auth expense = &entity.Expense{ ReferenceNumber: referenceNumber, PoNumber: req.PoNumber, Category: req.Category, SupplierId: req.SupplierID, TransactionDate: expenseDate, CreatedBy: createdBy, } if err := expenseRepoTx.CreateOne(c.Context(), expense, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense") } if len(req.ExpenseNonstocks) > 0 { for _, expenseNonstock := range req.ExpenseNonstocks { var projectFlockKandangId *uint64 if req.Category == "BOP" { projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(expenseNonstock.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") } id := uint64(projectFlockKandang.Id) projectFlockKandangId = &id } for _, costItem := range expenseNonstock.CostItems { nonstockId := costItem.NonstockID var kandangId *uint64 if req.Category == "NON-BOP" { id := uint64(expenseNonstock.KandangID) kandangId = &id } else if req.Category == "BOP" { if projectFlockKandangId != nil { kandangId = &expenseNonstock.KandangID } } expenseNonstock := &entity.ExpenseNonstock{ ExpenseId: &expense.Id, ProjectFlockKandangId: projectFlockKandangId, KandangId: kandangId, NonstockId: &nonstockId, Qty: costItem.Quantity, Price: costItem.Price, Notes: 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") } if len(req.Documents) > 0 { if err := s.processDocuments(c, expenseRepoTx, uint(expense.Id), req.Documents, false); err != nil { return err } } 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") } responseDTO, err := s.GetOne(c, uint(expense.Id)) if err != nil { return nil, err } return responseDTO, nil } func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*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: &id, Exists: s.Repository.IdExists}, ); err != nil { return nil, err } 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 { if latestApproval.StepNumber >= uint16(utils.ExpenseStepRealisasi) { currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] 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.TransactionDate != nil { expenseDate, err := utils.ParseDateString(*req.TransactionDate) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction_date format") } updateBody["transaction_date"] = expenseDate } if req.Category != nil { updateBody["category"] = *req.Category } if req.SupplierID != nil { 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 } updateBody["supplier_id"] = *req.SupplierID } if len(updateBody) == 0 && req.ExpenseNonstocks == nil && len(req.Documents) == 0 { responseDTO, err := s.GetOne(c, id) if err != nil { return nil, err } return responseDTO, nil } 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) currentExpense, err := expenseRepoTx.GetByID(c.Context(), id, nil) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Expense not found") } return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense") } categoryChanged := false var newCategory string if req.Category != nil && *req.Category != currentExpense.Category { categoryChanged = true newCategory = *req.Category } 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") } return err } } if categoryChanged { if currentExpense.Category == "BOP" && newCategory == "NON-BOP" { var existingExpenseNonstocks []entity.ExpenseNonstock if err := tx.Where("expense_id = ?", id).Find(&existingExpenseNonstocks).Error; err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense nonstocks") } for _, ens := range existingExpenseNonstocks { updateData := map[string]interface{}{ "project_flock_kandang_id": nil, } if err := expenseNonstockRepoTx.PatchOne(c.Context(), uint(ens.Id), updateData, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update project flock kandang id to null") } } } else if currentExpense.Category == "NON-BOP" && newCategory == "BOP" { var existingExpenseNonstocks []entity.ExpenseNonstock if err := tx.Where("expense_id = ?", id).Find(&existingExpenseNonstocks).Error; err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense nonstocks") } projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx) for _, ens := range existingExpenseNonstocks { if ens.KandangId != nil { projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*ens.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") } projectFlockKandangId := uint64(projectFlockKandang.Id) updateData := map[string]interface{}{ "project_flock_kandang_id": projectFlockKandangId, } if err := expenseNonstockRepoTx.PatchOne(c.Context(), uint(ens.Id), updateData, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update project flock kandang id") } } } } } if req.ExpenseNonstocks != nil { var existingExpenseNonstocks []entity.ExpenseNonstock if err := tx.Where("expense_id = ?", id).Find(&existingExpenseNonstocks).Error; err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense nonstocks for deletion") } for _, ens := range existingExpenseNonstocks { if err := expenseNonstockRepoTx.DeleteOne(c.Context(), uint(ens.Id)); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete expense nonstock") } } updatedExpense, err := expenseRepoTx.GetByID(c.Context(), id, nil) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Expense not found") } return fiber.NewError(fiber.StatusInternalServerError, "Failed to get updated expense") } for _, expenseNonstock := range *req.ExpenseNonstocks { var projectFlockKandangId *uint64 if updatedExpense.Category == "BOP" { projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx) projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(expenseNonstock.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") } id := uint64(projectFlockKandang.Id) projectFlockKandangId = &id } for _, costItem := range expenseNonstock.CostItems { nonstockId := uint(costItem.NonstockID) if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "Nonstock", ID: &nonstockId, Exists: s.NonstockRepo.IdExists}, ); err != nil { return err } var kandangId *uint64 if updatedExpense.Category == "NON-BOP" { id := uint64(expenseNonstock.KandangID) kandangId = &id } else if updatedExpense.Category == "BOP" { if projectFlockKandangId != nil { kandangId = &expenseNonstock.KandangID } } expenseId := uint64(id) expenseNonstock := &entity.ExpenseNonstock{ ExpenseId: &expenseId, ProjectFlockKandangId: projectFlockKandangId, KandangId: kandangId, NonstockId: &costItem.NonstockID, Qty: costItem.Quantity, Price: costItem.Price, Notes: costItem.Notes, } if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item") } } } } actorID := uint(1) // TODO: replace with authenticated user id if *latestApproval.Action != entity.ApprovalActionUpdated { approvalAction := entity.ApprovalActionUpdated if _, err := approvalSvcTx.CreateApproval( c.Context(), utils.ApprovalWorkflowExpense, id, utils.ExpenseStepPengajuan, &approvalAction, actorID, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to reset approval step") } } if len(req.Documents) > 0 { if err := s.processDocuments(c, expenseRepoTx, id, req.Documents, false); err != nil { return err } } 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") } responseDTO, err := s.GetOne(c, id) if err != nil { return nil, err } return responseDTO, nil } func (s expenseService) DeleteOne(c *fiber.Ctx, id uint) error { if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists}, ); 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) 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: s.Repository.IdExists}, ); err != nil { return nil, err } realizationDate, err := utils.ParseDateString(req.RealizationDate) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid realization_date format") } 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) expenseRepoTx := repository.NewExpenseRepository(tx) for _, realizationItem := range req.Realizations { expenseNonstockID := realizationItem.ExpenseNonstockID if err := s.validateExpenseNonstockRelation(c, expenseNonstockRepoTx, expenseID, expenseNonstockID); err != nil { return err } _, 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, Qty: realizationItem.Qty, Price: realizationItem.Price, Notes: "", } if realizationItem.Notes != nil { realization.Notes = *realizationItem.Notes } if err := realizationRepoTx.CreateOne(c.Context(), realization, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization") } } 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 len(req.Documents) > 0 { if err := s.processDocuments(c, expenseRepoTx, expenseID, req.Documents, true); err != nil { return err } } approvalAction := entity.ApprovalActionCreated if _, err := approvalSvc.CreateApproval( c.Context(), utils.ApprovalWorkflowExpense, expenseID, utils.ExpenseStepRealisasi, &approvalAction, uint(1), // TODO: replace with authenticated user id nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization approval") } return nil }); err != nil { return nil, err } responseDTO, err := s.GetOne(c, expenseID) if err != nil { return nil, err } return responseDTO, nil } func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) (*expenseDto.ExpenseDetailDTO, error) { if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists}, ); err != nil { return nil, err } actorID := uint(1) // TODO: replace with authenticated user id 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 != uint16(utils.ExpenseStepRealisasi) { currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot complete expense at %s step. Must be at Realisasi step", currentStepName)) } 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 { return fiber.NewError(fiber.StatusInternalServerError, "Failed to complete expense") } return nil }) if err != nil { return nil, err } responseDTO, err := s.GetOne(c, id) if err != nil { return nil, err } return responseDTO, nil } func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*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: s.Repository.IdExists}, ); err != nil { return nil, err } latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, expenseID, nil) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow") } if latestApproval != nil && (latestApproval.StepNumber < uint16(utils.ExpenseStepRealisasi)) { currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("tidak bisa update realisasi pada step %s. Harus pada step Realisasi atau selesai", currentStepName)) } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) realizationRepoTx := repository.NewExpenseRealizationRepository(tx) expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(tx) expenseRepoTx := repository.NewExpenseRepository(tx) // Check if only updating documents updateDataOnly := req.Realizations == nil && len(req.Documents) > 0 if req.Realizations != nil { for _, realizationItem := range *req.Realizations { expenseNonstockID := realizationItem.ExpenseNonstockID if err := s.validateExpenseNonstockRelation(c, expenseNonstockRepoTx, expenseID, expenseNonstockID); err != nil { return err } existingRealization, err := realizationRepoTx.GetByExpenseNonstockID(c.Context(), uint64(expenseNonstockID)) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Realization not found for this expense nonstock") } return fiber.NewError(fiber.StatusInternalServerError, "Failed to get existing realization") } updateData := map[string]interface{}{ "qty": realizationItem.Qty, "price": realizationItem.Price, } if realizationItem.Notes != nil { updateData["notes"] = *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") } } } if req.RealizationDate != nil { if err := expenseRepoTx.PatchOne(c.Context(), expenseID, map[string]interface{}{"realization_date": *req.RealizationDate}, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date") } } if len(req.Documents) > 0 { if err := s.processDocuments(c, expenseRepoTx, expenseID, req.Documents, true); err != nil { return err } } if !updateDataOnly && *latestApproval.Action == entity.ApprovalActionUpdated { actorID := uint(1) // TODO: replace with authenticated user id approvalAction := entity.ApprovalActionUpdated if _, err := approvalSvcTx.CreateApproval( c.Context(), utils.ApprovalWorkflowExpense, expenseID, utils.ExpenseStepRealisasi, &approvalAction, actorID, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization approval") } } return nil }) if err != nil { if fiberErr, ok := err.(*fiber.Error); ok { return nil, fiberErr } return nil, fiber.NewError(fiber.StatusInternalServerError, "gagal update realisasi expense") } responseDTO, err := s.GetOne(c, expenseID) if err != nil { return nil, err } return responseDTO, nil } func (s *expenseService) processDocuments(ctx *fiber.Ctx, expenseRepoTx repository.ExpenseRepository, expenseID uint, documents []*multipart.FileHeader, isRealization bool) error { if len(documents) == 0 { return nil } var existingDocuments []expenseDto.DocumentDTO var fieldName string if isRealization { fieldName = "realization_document_path" } else { fieldName = "document_path" } expense, err := expenseRepoTx.GetByID(ctx.Context(), expenseID, nil) if err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense for document processing") } } else { var documentField sql.NullString if isRealization { documentField = expense.RealizationDocumentPath } else { documentField = expense.DocumentPath } if documentField.Valid && documentField.String != "" { if err := json.Unmarshal([]byte(documentField.String), &existingDocuments); err != nil { existingDocuments = []expenseDto.DocumentDTO{} } } } var startID uint64 = 1 if len(existingDocuments) > 0 { maxID := uint64(0) for _, doc := range existingDocuments { if doc.ID > maxID { maxID = doc.ID } } startID = maxID + 1 } for i, doc := range documents { documentPath := doc.Filename document := expenseDto.DocumentDTO{ ID: startID + uint64(i), Path: documentPath, } existingDocuments = append(existingDocuments, document) } documentJSON, err := json.Marshal(existingDocuments) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") } if err := expenseRepoTx.PatchOne(ctx.Context(), expenseID, map[string]interface{}{ fieldName: string(documentJSON), }, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") } return nil } func (s *expenseService) DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error { if err := commonSvc.EnsureRelations(ctx.Context(), commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: s.Repository.IdExists}, ); err != nil { return err } if err := s.Repository.DB().WithContext(ctx.Context()).Transaction(func(tx *gorm.DB) error { expenseRepoTx := repository.NewExpenseRepository(tx) expense, err := expenseRepoTx.GetByID(ctx.Context(), expenseID, nil) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense for document deletion") } var existingDocuments []expenseDto.DocumentDTO var fieldName string if isRealization { fieldName = "realization_document_path" if expense.RealizationDocumentPath.Valid && expense.RealizationDocumentPath.String != "" { if err := json.Unmarshal([]byte(expense.RealizationDocumentPath.String), &existingDocuments); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to parse existing realization documents") } } } else { fieldName = "document_path" if expense.DocumentPath.Valid && expense.DocumentPath.String != "" { if err := json.Unmarshal([]byte(expense.DocumentPath.String), &existingDocuments); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to parse existing documents") } } } var updatedDocuments []expenseDto.DocumentDTO documentFound := false for _, doc := range existingDocuments { if doc.ID == documentID { documentFound = true continue } updatedDocuments = append(updatedDocuments, doc) } if !documentFound { return fiber.NewError(fiber.StatusNotFound, "Document not found") } documentJSON, err := json.Marshal(updatedDocuments) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") } if err := expenseRepoTx.PatchOne(ctx.Context(), expenseID, map[string]interface{}{ fieldName: string(documentJSON), }, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") } return nil }); err != nil { return err } return nil } func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest, approvalType string) ([]expenseDto.ExpenseDetailDTO, error) { if len(req.ApprovableIds) == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "No expense IDs provided") } actorID := uint(1) // TODO: replace with authenticated user id var results []expenseDto.ExpenseDetailDTO 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 req.ApprovableIds { if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists}, ); err != nil { return err } latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow") } var stepNumber approvalutils.ApprovalStep if approvalType == "manager" { stepNumber = utils.ExpenseStepManager if latestApproval.StepNumber != uint16(utils.ExpenseStepPengajuan) { currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot process at Manager step. Latest approval is at %s step. Expected previous step: Pengajuan", currentStepName)) } } else if approvalType == "finance" { stepNumber = utils.ExpenseStepFinance if latestApproval.StepNumber != uint16(utils.ExpenseStepManager) { currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot process at Finance step. Latest approval is at %s step. Expected previous step: Manager", currentStepName)) } } else { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid approval type: %v", approvalType)) } var approvalAction entity.ApprovalAction if req.Action == "APPROVED" { approvalAction = entity.ApprovalActionApproved } else if req.Action == "REJECTED" { approvalAction = entity.ApprovalActionRejected } else { return fiber.NewError(fiber.StatusBadRequest, "Invalid approval action") } if _, err := approvalSvcTx.CreateApproval( c.Context(), utils.ApprovalWorkflowExpense, id, stepNumber, &approvalAction, actorID, req.Notes); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create approval") } if stepNumber == utils.ExpenseStepFinance && req.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") } } responseDTO, err := s.GetOne(c, id) if err != nil { return err } results = append(results, *responseDTO) } return nil }) if err != nil { if fiberErr, ok := err.(*fiber.Error); ok { return nil, fiberErr } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed approve expenses") } return results, 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 } poNum := fmt.Sprintf("PO-%s", expense.ReferenceNumber) return poNum, nil } func (s *expenseService) validateExpenseNonstockRelation(ctx *fiber.Ctx, expenseNonstockRepoTx repository.ExpenseNonstockRepository, expenseID uint, expenseNonstockID uint64) error { belongsToExpense, err := expenseNonstockRepoTx.GetByExpenseID(ctx.Context(), uint64(expenseID), 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") } return nil } // func actorIDFromContext(c *fiber.Ctx) (uint, error) { // user, ok := authmiddleware.AuthenticatedUser(c) // if !ok || user == nil || user.Id == 0 { // return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") // } // return user.Id, nil // } // return user.Id, nil // }