package service import ( "context" "encoding/json" "errors" "fmt" "time" "gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress" 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" middleware "gitlab.com/mbugroup/lti-api.git/internal/middleware" 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 uint64) 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) BulkApproveToStatus(ctx *fiber.Ctx, req *validation.BulkApprovalRequest, target approvalutils.ApprovalStep) ([]expenseDto.ExpenseDetailDTO, error) GetProgressRows(ctx *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, 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 DocumentSvc commonSvc.DocumentService } func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierRepo.SupplierRepository, nonstockRepo nonstockRepo.NonstockRepository, approvalSvc commonSvc.ApprovalService, realizationRepo repository.ExpenseRealizationRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, validate *validator.Validate) ExpenseService { return &expenseService{ Log: utils.Log, Validate: validate, Repository: repo, SupplierRepo: supplierRepo, NonstockRepo: nonstockRepo, ApprovalSvc: approvalSvc, RealizationRepository: realizationRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, DocumentSvc: documentSvc, } } func (s expenseService) withRelations(db *gorm.DB) *gorm.DB { return db. Preload("CreatedUser"). Preload("Supplier"). Preload("Location"). Preload("Nonstocks.Nonstock"). Preload("Nonstocks.Realization"). Preload("Nonstocks.ProjectFlockKandang.Kandang.Location"). Preload("Nonstocks.Kandang"). Preload("Nonstocks.Kandang.Location"). Preload("Documents", func(db *gorm.DB) *gorm.DB { return db.Where("documentable_type = ?", string(utils.DocumentableTypeExpense)) }). Preload("RealizationDocuments", func(db *gorm.DB) *gorm.DB { return db.Where("documentable_type = ?", string(utils.DocumentableTypeExpenseRealization)) }) } 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 } var scopeErr error 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) db, scopeErr = middleware.ApplyLocationScope(c, db, "expenses.location_id") if params.Search != "" { return db.Where("category ILIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) if scopeErr != nil { return nil, 0, scopeErr } 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) { var scopeErr error expense, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) db, scopeErr = middleware.ApplyLocationScope(c, db, "expenses.location_id") return db }) if scopeErr != nil { return nil, scopeErr } 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) GetProgressRows(c *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error) { locationScope, err := middleware.ResolveLocationScope(c, s.Repository.DB()) if err != nil { return nil, err } return s.Repository.GetProgressRows(c.Context(), query.StartDate, query.EndDate, locationScope.IDs, locationScope.Restrict) } 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) if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "Supplier", ID: &supplierID, Exists: s.SupplierRepo.IdExists}, ); 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") } actorID, err := middleware.ActorIDFromContext(c) if err != nil { return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") } createdBy := uint64(actorID) hasKandang := false for _, ens := range req.ExpenseNonstocks { if ens.KandangID != nil { hasKandang = true break } } var projectFlockIdJSON *string if !hasKandang && req.Category == string(utils.ExpenseCategoryBOP) { projectFlockRepoTx := projectFlockKandangRepo.NewProjectflockRepository(dbTransaction) activeProjectFlocks, err := projectFlockRepoTx.GetActiveByLocationID(c.Context(), req.LocationID) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get active project flocks for location") } if len(activeProjectFlocks) > 0 { projectFlockIDs := make([]uint64, len(activeProjectFlocks)) for i, pf := range activeProjectFlocks { projectFlockIDs[i] = uint64(pf.Id) } projectFlockIdsJSON, err := json.Marshal(projectFlockIDs) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to marshal project_flock_ids") } jsonStr := string(projectFlockIdsJSON) projectFlockIdJSON = &jsonStr } } expense = &entity.Expense{ ReferenceNumber: referenceNumber, PoNumber: req.PoNumber, Category: req.Category, SupplierId: req.SupplierID, LocationId: req.LocationID, ProjectFlockId: projectFlockIdJSON, 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 { isAttachingToKandang := (expenseNonstock.KandangID != nil) var projectFlockKandangId *uint64 var kandangId *uint64 if isAttachingToKandang { kandangId = expenseNonstock.KandangID if req.Category == string(utils.ExpenseCategoryBOP) { projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*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 } } else { kandangId = nil projectFlockKandangId = nil } for _, costItem := range expenseNonstock.CostItems { nonstockId := costItem.NonstockID newExpenseNonstock := &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(), newExpenseNonstock, 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 s.DocumentSvc != nil && len(req.Documents) > 0 { documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) for idx, file := range req.Documents { documentFiles = append(documentFiles, commonSvc.DocumentFile{ File: file, Type: string(utils.DocumentTypeExpense), Index: &idx, }) } _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ DocumentableType: string(utils.DocumentableTypeExpense), DocumentableID: expense.Id, CreatedBy: &createdByUint, Files: documentFiles, }) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense documents") } } 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 } s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, uint(expense.Id), expenseDate, nil) 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) var requestedTransactionDate *time.Time 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 requestedTransactionDate = &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 req.Notes != nil { updateBody["notes"] = *req.Notes } if req.LocationID != nil { locationID := uint(*req.LocationID) updateBody["location_id"] = locationID } 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 } var invalidationFromDate time.Time var invalidationFarmIDs []uint 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") } if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), currentExpense); err != nil { return err } oldFarmIDs, resolveOldFarmErr := commonSvc.ResolveProjectFlockIDsByExpenseID(c.Context(), tx, id) if resolveOldFarmErr != nil { s.Log.Warnf("Failed to resolve old expense farm ids for invalidation (expense_id=%d): %+v", id, resolveOldFarmErr) } invalidationFarmIDs = append(invalidationFarmIDs, oldFarmIDs...) invalidationFromDate = currentExpense.TransactionDate if requestedTransactionDate != nil { invalidationFromDate = commonSvc.MinNonZeroDateOnlyUTC(currentExpense.TransactionDate, *requestedTransactionDate) } 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 == string(utils.ExpenseCategoryBOP) && newCategory == string(utils.ExpenseCategoryNonBOP) { 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 == string(utils.ExpenseCategoryNonBOP) && newCategory == string(utils.ExpenseCategoryBOP) { 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 err := s.ensureProjectFlockNotClosedForExpense(c.Context(), currentExpense); err != nil { return err } 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 var kandangId *uint64 // Check if attaching to kandang if expenseNonstock.KandangID != nil { kandangId = expenseNonstock.KandangID if updatedExpense.Category == string(utils.ExpenseCategoryBOP) { // BOP with kandang: Get active project flock kandang projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx) projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*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 } // NON-BOP: projectFlockKandangId stays nil } 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 } expenseId := uint64(id) newExpenseNonstock := &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(), newExpenseNonstock, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item") } } } } actorID, err := middleware.ActorIDFromContext(c) if err != nil { return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") } if *latestApproval.Action != entity.ApprovalActionUpdated && latestApproval.StepNumber > uint16(utils.ExpenseStepPengajuan) { approvalAction := entity.ApprovalActionUpdated previousStep := approvalutils.ApprovalStep(latestApproval.StepNumber) - 1 if previousStep < utils.ExpenseStepPengajuan { previousStep = utils.ExpenseStepPengajuan } if _, err := approvalSvcTx.CreateApproval( c.Context(), utils.ApprovalWorkflowExpense, id, previousStep, &approvalAction, actorID, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to reset approval step") } } if s.DocumentSvc != nil && len(req.Documents) > 0 { documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) for idx, file := range req.Documents { documentFiles = append(documentFiles, commonSvc.DocumentFile{ File: file, Type: string(utils.DocumentTypeExpense), Index: &idx, }) } _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ DocumentableType: string(utils.DocumentableTypeExpense), DocumentableID: uint64(id), CreatedBy: &actorID, Files: documentFiles, }) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense documents") } } newFarmIDs, resolveNewFarmErr := commonSvc.ResolveProjectFlockIDsByExpenseID(c.Context(), tx, id) if resolveNewFarmErr != nil { s.Log.Warnf("Failed to resolve new expense farm ids for invalidation (expense_id=%d): %+v", id, resolveNewFarmErr) } invalidationFarmIDs = append(invalidationFarmIDs, newFarmIDs...) 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 } s.invalidateDepreciationSnapshots(c.Context(), nil, invalidationFarmIDs, invalidationFromDate) return responseDTO, nil } func (s expenseService) DeleteOne(c *fiber.Ctx, id uint64) error { idUint := uint(id) if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "Expense", ID: &idUint, Exists: s.Repository.IdExists}, ); err != nil { return err } expense, err := s.Repository.GetByID(c.Context(), idUint, func(db *gorm.DB) *gorm.DB { return db.Preload("Nonstocks") }) if 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 get expense for ID %d: %+v", id, err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense") } if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil { return err } farmIDs, resolveFarmErr := commonSvc.ResolveProjectFlockIDsByExpenseID(c.Context(), s.Repository.DB(), idUint) if resolveFarmErr != nil { s.Log.Warnf("Failed to resolve expense farm ids before delete (expense_id=%d): %+v", idUint, resolveFarmErr) } if err := s.Repository.DeleteOne(c.Context(), idUint); 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) invalidationFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, expense.RealizationDate) s.invalidateDepreciationSnapshots(c.Context(), nil, farmIDs, invalidationFromDate) 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") } expense, err := s.Repository.GetByID(c.Context(), expenseID, func(db *gorm.DB) *gorm.DB { return db.Preload("Nonstocks") }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Expense not found") } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense") } if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil { return nil, err } 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) 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 s.DocumentSvc != nil && len(req.Documents) > 0 { documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) for idx, file := range req.Documents { documentFiles = append(documentFiles, commonSvc.DocumentFile{ File: file, Type: string(utils.DocumentTypeExpenseRealization), Index: &idx, }) } _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ DocumentableType: string(utils.DocumentableTypeExpenseRealization), DocumentableID: uint64(expenseID), CreatedBy: &actorID, Files: documentFiles, }) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense realization documents") } } 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(), utils.ApprovalWorkflowExpense, expenseID, utils.ExpenseStepRealisasi, &approvalAction, actorID, 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 } invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, realizationDate, expense.RealizationDate) s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil) 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(), commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists}, ); err != nil { return nil, err } actorID, err := middleware.ActorIDFromContext(c) if err != nil { return nil, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") } 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 } expense, expenseErr := s.Repository.GetByID(c.Context(), id, nil) if expenseErr != nil { s.Log.Warnf("Failed to load expense for depreciation invalidation after complete (expense_id=%d): %+v", id, expenseErr) } else { invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, expense.RealizationDate) s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, id, invalidateFromDate, nil) } 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 } expense, err := s.Repository.GetByID(c.Context(), expenseID, func(db *gorm.DB) *gorm.DB { return db.Preload("Nonstocks") }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Expense not found") } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense") } if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil { return nil, err } invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, expense.RealizationDate) if req.RealizationDate != nil { if parsedDate, parseErr := utils.ParseDateString(*req.RealizationDate); parseErr == nil { invalidateFromDate = commonSvc.MinNonZeroDateOnlyUTC(invalidateFromDate, parsedDate) } } 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 s.DocumentSvc != nil && len(req.Documents) > 0 { documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) for idx, file := range req.Documents { documentFiles = append(documentFiles, commonSvc.DocumentFile{ File: file, Type: string(utils.DocumentTypeExpenseRealization), 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), CreatedBy: &actorID, Files: documentFiles, }) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense realization documents") } } 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 } s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil) return responseDTO, 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 s.DocumentSvc == nil { return fiber.NewError(fiber.StatusInternalServerError, "Document service not available") } // Verify document exists and belongs to the expense var documentableType string if isRealization { documentableType = string(utils.DocumentableTypeExpenseRealization) } else { documentableType = string(utils.DocumentableTypeExpense) } documents, err := s.DocumentSvc.ListByTarget(ctx.Context(), documentableType, uint64(expenseID)) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve documents") } documentFound := false var documentIDsToDelete []uint for _, doc := range documents { if uint64(doc.Id) == documentID { documentFound = true documentIDsToDelete = append(documentIDsToDelete, doc.Id) break } } if !documentFound { return fiber.NewError(fiber.StatusNotFound, "Document not found") } // Delete document from database and storage if err := s.DocumentSvc.DeleteDocuments(ctx.Context(), documentIDsToDelete, true); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete document") } 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, err := middleware.ActorIDFromContext(c) if err != nil { return nil, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") } var results []expenseDto.ExpenseDetailDTO invalidateFromDateByExpenseID := make(map[uint]time.Time) 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 } expenseForInvalidation, 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 load expense") } invalidateFromDateByExpenseID[id] = commonSvc.MinNonZeroDateOnlyUTC( expenseForInvalidation.TransactionDate, expenseForInvalidation.RealizationDate, ) 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 == "head-area" { stepNumber = utils.ExpenseStepHeadArea if latestApproval.StepNumber != uint16(utils.ExpenseStepPengajuan) { currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot process at Head Area step. Latest approval is at %s step. Expected previous step: Pengajuan", currentStepName)) } } else if approvalType == "unit-vice-president" { stepNumber = utils.ExpenseStepUnitVicePresident if latestApproval.StepNumber != uint16(utils.ExpenseStepHeadArea) { currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot process at Unit Vice President step. Latest approval is at %s step. Expected previous step: Head Area", currentStepName)) } } else if approvalType == "finance" { stepNumber = utils.ExpenseStepFinance if latestApproval.StepNumber != uint16(utils.ExpenseStepUnitVicePresident) { 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: Unit Vice President", 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 approvalAction == entity.ApprovalActionApproved { 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") } if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil { return err } } 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") } for expenseID, invalidateFromDate := range invalidateFromDateByExpenseID { s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil) } return results, nil } func (s *expenseService) invalidateDepreciationSnapshotsByExpense( ctx context.Context, tx *gorm.DB, expenseID uint, fromDate time.Time, fallbackFarmIDs []uint, ) { targetDB := s.Repository.DB() if tx != nil { targetDB = tx } farmIDs := append([]uint{}, fallbackFarmIDs...) if expenseID != 0 { resolvedFarmIDs, err := commonSvc.ResolveProjectFlockIDsByExpenseID(ctx, targetDB, expenseID) if err != nil { s.Log.Warnf("Failed to resolve expense farm ids for invalidation (expense_id=%d): %+v", expenseID, err) } else { farmIDs = append(farmIDs, resolvedFarmIDs...) } } s.invalidateDepreciationSnapshots(ctx, tx, farmIDs, fromDate) } func (s *expenseService) invalidateDepreciationSnapshots( ctx context.Context, tx *gorm.DB, farmIDs []uint, fromDate time.Time, ) { if fromDate.IsZero() { return } targetDB := s.Repository.DB() if tx != nil { targetDB = tx } farmIDs = utils.UniqueUintSlice(farmIDs) if len(farmIDs) == 0 { if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, nil, fromDate); err != nil { s.Log.Warnf( "Failed to invalidate depreciation snapshots globally (from=%s): %+v", fromDate.Format("2006-01-02"), err, ) } return } if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, farmIDs, fromDate); err != nil { s.Log.Warnf( "Failed to invalidate depreciation snapshots (farm_ids=%v, from=%s): %+v", farmIDs, fromDate.Format("2006-01-02"), err, ) } } 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 (s *expenseService) ensureProjectFlockNotClosedForExpense( ctx context.Context, expense *entity.Expense, ) error { // Kalau repo belum di-wire atau expense kosong → gak usah ngecek apa-apa if s.ProjectFlockKandangRepo == nil || expense == nil { return nil } seen := make(map[uint]struct{}) for _, ens := range expense.Nonstocks { // Field ini pointer, bisa nil if ens.ProjectFlockKandangId == nil || *ens.ProjectFlockKandangId == 0 { continue } pfkID := uint(*ens.ProjectFlockKandangId) if _, ok := seen[pfkID]; ok { continue } seen[pfkID] = struct{}{} pfk, err := s.ProjectFlockKandangRepo.GetByID(ctx, pfkID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError( fiber.StatusBadRequest, fmt.Sprintf("Project flock %d tidak ditemukan", pfkID), ) } s.Log.Errorf("Failed to validate project flock %d for expense %d: %+v", pfkID, expense.Id, err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate project flock") } // ❗ RULE: kalau ClosedAt tidak nil → project sudah closing if pfk.ClosedAt != nil { return fiber.NewError(fiber.StatusBadRequest, "Project sudah closing") } } return nil }