diff --git a/Marco.Pms.DataAccess/Data/ApplicationDbContext.cs b/Marco.Pms.DataAccess/Data/ApplicationDbContext.cs index d2dc283..c15c297 100644 --- a/Marco.Pms.DataAccess/Data/ApplicationDbContext.cs +++ b/Marco.Pms.DataAccess/Data/ApplicationDbContext.cs @@ -659,6 +659,7 @@ namespace Marco.Pms.DataAccess.Data } ); } + private static void ManageApplicationStructure(ModelBuilder modelBuilder) { // Configure ApplicationRole to Tenant relationship (if Tenant exists) diff --git a/Marco.Pms.Model/Collection/Invoice.cs b/Marco.Pms.Model/Collection/Invoice.cs index cd3bbdf..c8c5a4b 100644 --- a/Marco.Pms.Model/Collection/Invoice.cs +++ b/Marco.Pms.Model/Collection/Invoice.cs @@ -38,5 +38,7 @@ namespace Marco.Pms.Model.Collection [ValidateNever] [ForeignKey("UpdatedById")] public Employee? UpdatedBy { get; set; } + + public ICollection ReceivedInvoicePayments { get; set; } = new List(); } } diff --git a/Marco.Pms.Model/Expenses/Expenses.cs b/Marco.Pms.Model/Expenses/Expenses.cs index 10335fe..23d3fc9 100644 --- a/Marco.Pms.Model/Expenses/Expenses.cs +++ b/Marco.Pms.Model/Expenses/Expenses.cs @@ -15,9 +15,8 @@ namespace Marco.Pms.Model.Expenses public Guid ProjectId { get; set; } public Guid ExpensesTypeId { get; set; } - //[ValidateNever] - //[ForeignKey("ExpensesTypeId")] - //public ExpensesTypeMaster? ExpensesType { get; set; } + public ICollection Attachments { get; set; } = new List(); + public ICollection ExpensesReimburseMappings { get; set; } = new List(); public Guid ExpenseCategoryId { get; set; } diff --git a/Marco.Pms.Model/Master/ExpensesStatusMaster.cs b/Marco.Pms.Model/Master/ExpensesStatusMaster.cs index 35358c3..339b93c 100644 --- a/Marco.Pms.Model/Master/ExpensesStatusMaster.cs +++ b/Marco.Pms.Model/Master/ExpensesStatusMaster.cs @@ -1,4 +1,6 @@ -namespace Marco.Pms.Model.Master +using Marco.Pms.Model.Expenses.Masters; + +namespace Marco.Pms.Model.Master { public class ExpensesStatusMaster { @@ -9,5 +11,7 @@ public string Color { get; set; } = string.Empty; public bool IsSystem { get; set; } = false; public bool IsActive { get; set; } = true; + + public ICollection StatusPermissionMappings { get; set; } = new List(); } } diff --git a/Marco.Pms.Model/ViewModels/Collection/InvoiceListVM.cs b/Marco.Pms.Model/ViewModels/Collection/InvoiceListVM.cs index 2b1796e..ab70c76 100644 --- a/Marco.Pms.Model/ViewModels/Collection/InvoiceListVM.cs +++ b/Marco.Pms.Model/ViewModels/Collection/InvoiceListVM.cs @@ -12,12 +12,14 @@ namespace Marco.Pms.Model.ViewModels.Collection public string InvoiceNumber { get; set; } = default!; public string? EInvoiceNumber { get; set; } public BasicOrganizationVm? BilledTo { get; set; } - public BasicProjectVM? Project { get; set; } + public Guid ProjectId { get; set; } + public BasicProjectVM Project { get; set; } = new BasicProjectVM(); public DateTime InvoiceDate { get; set; } public DateTime ClientSubmitedDate { get; set; } public DateTime ExceptedPaymentDate { get; set; } public double BasicAmount { get; set; } public double TaxAmount { get; set; } + public double TotalAmount { get; set; } public double BalanceAmount { get; set; } public bool IsActive { get; set; } public bool MarkAsCompleted { get; set; } diff --git a/Marco.Pms.Model/ViewModels/Expenses/ExpenseList.cs b/Marco.Pms.Model/ViewModels/Expenses/ExpenseList.cs index d112f1a..5b8bd36 100644 --- a/Marco.Pms.Model/ViewModels/Expenses/ExpenseList.cs +++ b/Marco.Pms.Model/ViewModels/Expenses/ExpenseList.cs @@ -9,6 +9,7 @@ namespace Marco.Pms.Model.ViewModels.Expanses public class ExpenseList { public Guid Id { get; set; } + public Guid ProjectId { get; set; } public BasicProjectVM? Project { get; set; } public ExpenseCategoryMasterVM? ExpenseCategory { get; set; } public PaymentModeMatserVM? PaymentMode { get; set; } diff --git a/Marco.Pms.Model/ViewModels/Master/ExpensesStatusMasterVM.cs b/Marco.Pms.Model/ViewModels/Master/ExpensesStatusMasterVM.cs index 8f6f02a..8c5d174 100644 --- a/Marco.Pms.Model/ViewModels/Master/ExpensesStatusMasterVM.cs +++ b/Marco.Pms.Model/ViewModels/Master/ExpensesStatusMasterVM.cs @@ -6,7 +6,7 @@ public string Name { get; set; } = string.Empty; public string DisplayName { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; - public List? PermissionIds { get; set; } + public List PermissionIds { get; set; } = new List(); public string? Color { get; set; } public bool IsSystem { get; set; } = false; } diff --git a/Marco.Pms.Services/Controllers/CollectionController.cs b/Marco.Pms.Services/Controllers/CollectionController.cs index ab6f8c1..fddf80f 100644 --- a/Marco.Pms.Services/Controllers/CollectionController.cs +++ b/Marco.Pms.Services/Controllers/CollectionController.cs @@ -1,4 +1,5 @@ using AutoMapper; +using AutoMapper.QueryableExtensions; using Marco.Pms.DataAccess.Data; using Marco.Pms.Helpers.Utility; using Marco.Pms.Model.Collection; @@ -145,7 +146,9 @@ namespace Marco.Pms.Services.Controllers .Include(i => i.BilledTo) .Include(i => i.CreatedBy).ThenInclude(e => e!.JobRole) .Include(i => i.UpdatedBy).ThenInclude(e => e!.JobRole) - .Where(i => projIds.Contains(i.ProjectId) && i.IsActive == isActive && i.TenantId == tenantId); + .Include(i => i.ReceivedInvoicePayments) + .Where(i => projIds.Contains(i.ProjectId) && i.IsActive == isActive && i.TenantId == tenantId) + .ProjectTo(_mapper.ConfigurationProvider); // Filter by date, ensuring date boundaries are correct if (fromDate.HasValue && toDate.HasValue) @@ -179,6 +182,12 @@ namespace Marco.Pms.Services.Controllers } } + if (isPending) + { + query = query.Where(i => i.BalanceAmount > 0 && !i.MarkAsCompleted); + _logger.LogDebug("Pending filter applied."); + } + var totalItems = await query.CountAsync(); var totalPages = (int)Math.Ceiling((double)totalItems / pageSize); _logger.LogInfo("Total invoices found: {TotalItems}", totalItems); @@ -197,45 +206,14 @@ namespace Marco.Pms.Services.Controllers )); } - // Fetch related payments in a single query to minimize DB calls - var invoiceIds = pagedInvoices.Select(i => i.Id).ToList(); - var paymentGroups = await _context.ReceivedInvoicePayments - .AsNoTracking() - .Where(p => invoiceIds.Contains(p.InvoiceId) && p.TenantId == tenantId) - .GroupBy(p => p.InvoiceId) - .Select(g => new { InvoiceId = g.Key, PaidAmount = g.Sum(p => p.Amount) }) - .ToDictionaryAsync(g => g.InvoiceId, g => g.PaidAmount); - _logger.LogDebug("Received payment data for {Count} invoices.", paymentGroups.Count); + pagedInvoices.ForEach(i => i.Project = projects.Where(sp => sp.Id == i.ProjectId).FirstOrDefault()!); - // Build results and compute balances in memory for tight control - var results = new List(); - - foreach (var invoice in pagedInvoices) - { - var total = invoice.BasicAmount + invoice.TaxAmount; - var paid = paymentGroups.GetValueOrDefault(invoice.Id, 0); - var balance = total - paid; - - // Filter pending - if (isPending && (balance <= 0 || invoice.MarkAsCompleted)) - continue; - - var vm = _mapper.Map(invoice); - - // Project mapping logic - minimize nested object allocations - vm.Project = projects.Where(sp => sp.Id == invoice.ProjectId).FirstOrDefault(); - - - vm.BalanceAmount = balance; - results.Add(vm); - } - - _logger.LogInfo("Returning {Count} invoices (page {PageNumber} of {TotalPages}).", results.Count, pageNumber, totalPages); + _logger.LogInfo("Returning {Count} invoices (page {PageNumber} of {TotalPages}).", pagedInvoices.Count, pageNumber, totalPages); return Ok(ApiResponse.SuccessResponse( - new { CurrentPage = pageNumber, TotalPages = totalPages, TotalEntities = totalItems, Data = results }, - $"{results.Count} invoices fetched successfully." + new { CurrentPage = pageNumber, TotalPages = totalPages, TotalEntities = totalItems, Data = pagedInvoices }, + $"{pagedInvoices.Count} invoices fetched successfully." )); } catch (Exception ex) diff --git a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs index bd6b2dc..5188464 100644 --- a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs @@ -169,13 +169,13 @@ namespace Marco.Pms.Services.MappingProfiles CreateMap() .ForMember( dest => dest.Id, - opt => opt.MapFrom(src => new Guid(src.Id))); + opt => opt.MapFrom(src => string.IsNullOrWhiteSpace(src.Id) ? Guid.Empty : new Guid(src.Id))); CreateMap() .ForMember( dest => dest.Id, // Explicitly and safely convert string Id to Guid Id - opt => opt.MapFrom(src => new Guid(src.Id)) + opt => opt.MapFrom(src => string.IsNullOrWhiteSpace(src.Id) ? Guid.Empty : new Guid(src.Id)) ).ForMember( dest => dest.ProjectStatusId, // Explicitly and safely convert string ProjectStatusId to Guid ProjectStatusId @@ -293,7 +293,13 @@ namespace Marco.Pms.Services.MappingProfiles #region ======================================================= Expenses ======================================================= - CreateMap(); + CreateMap() + .ForMember( + dest => dest.ExpenseUId, + opt => opt.MapFrom(src => $"{src.UIDPrefix}/{src.UIDPostfix:D5}")) + .ForMember( + dest => dest.PaymentRequestUID, + opt => opt.MapFrom(src => src.PaymentRequest != null ? $"{src.PaymentRequest.UIDPrefix}/{src.PaymentRequest.UIDPostfix:D5}" : null)); CreateMap(); CreateMap(); CreateMap(); @@ -320,7 +326,17 @@ namespace Marco.Pms.Services.MappingProfiles dest => dest.Id, opt => opt.MapFrom(src => Guid.Parse(src.Id))); - CreateMap(); + CreateMap() + .ForMember( + dest => dest.ExpenseUId, + opt => opt.MapFrom(src => $"{src.UIDPrefix}/{src.UIDPostfix:D5}")) + .ForMember( + dest => dest.PaymentRequestUID, + opt => opt.MapFrom(src => src.PaymentRequest != null ? $"{src.PaymentRequest.UIDPrefix}/{src.PaymentRequest.UIDPostfix:D5}" : null)) + .ForMember( + dest => dest.ExpensesReimburse, + opt => opt.MapFrom(src => src.ExpensesReimburseMappings.Select(erm => erm.ExpensesReimburse).FirstOrDefault())); + CreateMap() .ForMember( dest => dest.Id, @@ -370,7 +386,13 @@ namespace Marco.Pms.Services.MappingProfiles #region ======================================================= Collection ======================================================= CreateMap(); - CreateMap(); + CreateMap() + .ForMember( + dest => dest.TotalAmount, + opt => opt.MapFrom(src => src.BasicAmount + src.TaxAmount)) + .ForMember( + dest => dest.BalanceAmount, + opt => opt.MapFrom(src => (src.BasicAmount + src.TaxAmount) - src.ReceivedInvoicePayments.Sum(rip => rip.Amount))); CreateMap(); CreateMap(); @@ -471,7 +493,10 @@ namespace Marco.Pms.Services.MappingProfiles .ForMember( dest => dest.DisplayName, opt => opt.MapFrom(src => string.IsNullOrWhiteSpace(src.DisplayName) ? src.Name : src.DisplayName)); - CreateMap(); + CreateMap() + .ForMember( + dest => dest.PermissionIds, + opt => opt.MapFrom(src => src.StatusPermissionMappings.Select(spm => spm.PermissionId))); CreateMap() .ForMember( diff --git a/Marco.Pms.Services/Service/ExpensesService.cs b/Marco.Pms.Services/Service/ExpensesService.cs index 5b7d5ce..6b72397 100644 --- a/Marco.Pms.Services/Service/ExpensesService.cs +++ b/Marco.Pms.Services/Service/ExpensesService.cs @@ -1,4 +1,5 @@ using AutoMapper; +using AutoMapper.QueryableExtensions; using Marco.Pms.DataAccess.Data; using Marco.Pms.Helpers.Utility; using Marco.Pms.Model.Dtos.Expenses; @@ -16,6 +17,7 @@ using Marco.Pms.Model.MongoDBModels.Utility; using Marco.Pms.Model.TenantModels; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Activities; +using Marco.Pms.Model.ViewModels.DocumentManager; using Marco.Pms.Model.ViewModels.Expanses; using Marco.Pms.Model.ViewModels.Expenses; using Marco.Pms.Model.ViewModels.Expenses.Masters; @@ -283,10 +285,10 @@ namespace Marco.Pms.Services.Service var response = _mapper.Map(m); if (response.Status != null && (response.NextStatus?.Any() ?? false)) { - response.Status.PermissionIds = permissionStatusMapping.Where(ps => ps.StatusId == Guid.Parse(m.Status.Id)).Select(ps => ps.PermissionIds).FirstOrDefault(); + response.Status.PermissionIds = permissionStatusMapping.Where(ps => ps.StatusId == Guid.Parse(m.Status.Id)).Select(ps => ps.PermissionIds).FirstOrDefault() ?? new List(); foreach (var status in response.NextStatus) { - status.PermissionIds = permissionStatusMapping.Where(ps => ps.StatusId == status.Id).Select(ps => ps.PermissionIds).FirstOrDefault(); + status.PermissionIds = permissionStatusMapping.Where(ps => ps.StatusId == status.Id).Select(ps => ps.PermissionIds).FirstOrDefault() ?? new List(); } } return response; @@ -319,13 +321,11 @@ namespace Marco.Pms.Services.Service return ApiResponse.ErrorResponse("Error Occured", ExceptionMapper(ex), 500); } } - public async Task> GetExpensesListDynamicAsync(Employee loggedInEmployee, Guid tenantId, string? searchString, string? filter, int pageSize, int pageNumber) { try { - _logger.LogInfo( - "Attempting to fetch expenses list for PageNumber: {PageNumber}, PageSize: {PageSize} with Filter: {Filter}", + _logger.LogInfo("Attempting to fetch expenses list for PageNumber: {PageNumber}, PageSize: {PageSize} with Filter: {Filter}", pageNumber, pageSize, filter ?? ""); // 1. --- Get User Permissions --- @@ -335,9 +335,6 @@ namespace Marco.Pms.Services.Service _logger.LogWarning("Could not find an employee for the current logged-in user."); return ApiResponse.ErrorResponse("User not found or not authenticated.", 403); } - Guid loggedInEmployeeId = loggedInEmployee.Id; - List expenseVM = new List(); - var totalEntites = 0; var hasViewSelfPermissionTask = HasPermissionAsync(PermissionsMaster.ExpenseViewSelf, loggedInEmployee.Id); var hasViewAllPermissionTask = HasPermissionAsync(PermissionsMaster.ExpenseViewAll, loggedInEmployee.Id); @@ -350,7 +347,7 @@ namespace Marco.Pms.Services.Service if (!hasViewAllPermission && !hasViewSelfPermission) { // User has neither required permission. Deny access. - _logger.LogWarning("Access DENIED for employee {EmployeeId} attempting to get expenses list.", loggedInEmployeeId); + _logger.LogWarning("Access DENIED for employee {EmployeeId} attempting to get expenses list.", loggedInEmployee.Id); return ApiResponse.SuccessResponse(new List(), "You do not have permission to view any expenses.", 200); } @@ -358,13 +355,6 @@ namespace Marco.Pms.Services.Service // 2. --- Deserialize Filter and Apply --- AdvanceFilter? advanceFilter = TryDeserializeAdvanceFilter(filter); - //var (totalPages, totalCount, cacheList) = await _cache.GetExpenseListAsync(tenantId, loggedInEmployeeId, hasViewAllPermissionTask.Result, hasViewSelfPermissionTask.Result, - // pageNumber, pageSize, expenseFilter, searchString); - - List? cacheList = null; - var totalPages = 0; - var totalCount = 0; - // 3. --- Build Base Query and Apply Permissions --- // Start with a base IQueryable. Filters will be chained onto this. var expensesQuery = _context.Expenses @@ -381,133 +371,96 @@ namespace Marco.Pms.Services.Service .Include(e => e.Currency) .Where(e => e.TenantId == tenantId); // Always filter by TenantId first. - if (cacheList == null) + // Apply permission-based filtering BEFORE any other filters or pagination. + if (hasViewAllPermission) { - //await _cache.AddExpensesListToCache(expenses: await expensesQuery.ToListAsync(), tenantId); - - // Apply permission-based filtering BEFORE any other filters or pagination. - if (hasViewAllPermission) - { - expensesQuery = expensesQuery.Where(e => e.CreatedById == loggedInEmployeeId || e.StatusId != Draft); - } - else if (hasViewSelfPermission) - { - // User only has 'View Self' permission, so restrict the query to their own expenses. - _logger.LogInfo("User {EmployeeId} has 'View Self' permission. Restricting query to their expenses.", loggedInEmployeeId); - expensesQuery = expensesQuery.Where(e => e.CreatedById == loggedInEmployeeId); - } - expensesQuery = expensesQuery.ApplyCustomFilters(advanceFilter, "CreatedAt"); - if (advanceFilter != null) - { - if (advanceFilter.Filters != null) - { - expensesQuery = expensesQuery.ApplyListFilters(advanceFilter.Filters); - } - if (advanceFilter.DateFilter != null) - { - expensesQuery = expensesQuery.ApplyDateFilter(advanceFilter.DateFilter); - } - if (advanceFilter.SearchFilters != null) - { - var invoiceSearchFilter = advanceFilter.SearchFilters.Where(f => f.Column != "ProjectName").ToList(); - if (invoiceSearchFilter.Any()) - { - expensesQuery = expensesQuery.ApplySearchFilters(invoiceSearchFilter); - } - } - if (!string.IsNullOrWhiteSpace(advanceFilter.GroupByColumn)) - { - expensesQuery = expensesQuery.ApplyGroupByFilters(advanceFilter.GroupByColumn); - } - } - - if (!string.IsNullOrWhiteSpace(searchString)) - { - var searchStringLower = searchString.ToLower(); - expensesQuery = expensesQuery.Include(e => e.PaidBy).Include(e => e.CreatedBy) - .Where(e => e.Description.ToLower().Contains(searchStringLower) || - (e.TransactionId != null && e.TransactionId.ToLower().Contains(searchStringLower)) || - (e.PaidBy != null && (e.PaidBy.FirstName + " " + e.PaidBy.LastName).ToLower().Contains(searchStringLower)) || - (e.CreatedBy != null && (e.CreatedBy.FirstName + " " + e.CreatedBy.LastName).ToLower().Contains(searchStringLower)) || - (e.UIDPrefix + "/" + e.UIDPostfix.ToString().PadLeft(5, '0')).Contains(searchString)); - } - - // 4. --- Apply Ordering and Pagination --- - // This should be the last step before executing the query. - - totalEntites = await expensesQuery.CountAsync(); - - // 5. --- Execute Query and Map Results --- - var expensesList = await expensesQuery - //.OrderByDescending(e => e.CreatedAt) - .Skip((pageNumber - 1) * pageSize) - .Take(pageSize).ToListAsync(); - - if (!expensesList.Any()) - { - _logger.LogInfo("No expenses found matching the criteria for employee {EmployeeId}.", loggedInEmployeeId); - return ApiResponse.SuccessResponse(new List(), "No expenses found for the given criteria.", 200); - } - - var projectIds = expensesList.Select(e => e.ProjectId).ToList(); - - var infraProjectTask = Task.Run(async () => - { - await using var context = await _dbContextFactory.CreateDbContextAsync(); - return await context.Projects.Where(p => projectIds.Contains(p.Id) && p.TenantId == tenantId).Select(p => _mapper.Map(p)).ToListAsync(); - }); - - var serviceProjectTask = Task.Run(async () => - { - await using var context = await _dbContextFactory.CreateDbContextAsync(); - return await context.ServiceProjects.Where(sp => projectIds.Contains(sp.Id) && sp.TenantId == tenantId).Select(sp => _mapper.Map(sp)).ToListAsync(); - }); - - await Task.WhenAll(infraProjectTask, serviceProjectTask); - - var projects = infraProjectTask.Result; - projects.AddRange(serviceProjectTask.Result); - - //expenseVM = await GetAllExpnesRelatedTables(expensesList, tenantId); - expenseVM = expensesList.Select(e => - { - var result = _mapper.Map(e); - result.ExpenseUId = $"{e.UIDPrefix}/{e.UIDPostfix:D5}"; - if (e.PaymentRequest != null) - result.PaymentRequestUID = $"{e.PaymentRequest.UIDPrefix}/{e.PaymentRequest.UIDPostfix:D5}"; - result.Project = projects.FirstOrDefault(p => p.Id == e.ProjectId); - return result; - }).ToList(); - totalPages = (int)Math.Ceiling((double)totalEntites / pageSize); - + expensesQuery = expensesQuery.Where(e => e.CreatedById == loggedInEmployee.Id || e.StatusId != Draft); } - else + else if (hasViewSelfPermission) { - var permissionStatusMapping = await _context.StatusPermissionMapping - .GroupBy(ps => ps.StatusId) - .Select(g => new - { - StatusId = g.Key, - PermissionIds = g.Select(ps => ps.PermissionId).ToList() - }).ToListAsync(); - - expenseVM = cacheList.Select(m => - { - var response = _mapper.Map(m); - if (response.Status != null && (response.NextStatus?.Any() ?? false)) - { - response.Status.PermissionIds = permissionStatusMapping.Where(ps => ps.StatusId == Guid.Parse(m.Status.Id)).Select(ps => ps.PermissionIds).FirstOrDefault(); - foreach (var status in response.NextStatus) - { - status.PermissionIds = permissionStatusMapping.Where(ps => ps.StatusId == status.Id).Select(ps => ps.PermissionIds).FirstOrDefault(); - } - } - return response; - }).ToList(); - totalEntites = (int)totalCount; + // User only has 'View Self' permission, so restrict the query to their own expenses. + _logger.LogInfo("User {EmployeeId} has 'View Self' permission. Restricting query to their expenses.", loggedInEmployee.Id); + expensesQuery = expensesQuery.Where(e => e.CreatedById == loggedInEmployee.Id); } + expensesQuery = expensesQuery.ApplyCustomFilters(advanceFilter, "CreatedAt"); + if (advanceFilter != null) + { + if (advanceFilter.Filters != null) + { + expensesQuery = expensesQuery.ApplyListFilters(advanceFilter.Filters); + } + if (advanceFilter.DateFilter != null) + { + expensesQuery = expensesQuery.ApplyDateFilter(advanceFilter.DateFilter); + } + if (advanceFilter.SearchFilters != null) + { + var invoiceSearchFilter = advanceFilter.SearchFilters.Where(f => f.Column != "ProjectName").ToList(); + if (invoiceSearchFilter.Any()) + { + expensesQuery = expensesQuery.ApplySearchFilters(invoiceSearchFilter); + } + } + if (!string.IsNullOrWhiteSpace(advanceFilter.GroupByColumn)) + { + expensesQuery = expensesQuery.ApplyGroupByFilters(advanceFilter.GroupByColumn); + } + } + + if (!string.IsNullOrWhiteSpace(searchString)) + { + var searchStringLower = searchString.ToLower(); + expensesQuery = expensesQuery.Include(e => e.PaidBy).Include(e => e.CreatedBy) + .Where(e => e.Description.ToLower().Contains(searchStringLower) || + (e.TransactionId != null && e.TransactionId.ToLower().Contains(searchStringLower)) || + (e.PaidBy != null && (e.PaidBy.FirstName + " " + e.PaidBy.LastName).ToLower().Contains(searchStringLower)) || + (e.CreatedBy != null && (e.CreatedBy.FirstName + " " + e.CreatedBy.LastName).ToLower().Contains(searchStringLower)) || + (e.UIDPrefix + "/" + e.UIDPostfix.ToString().PadLeft(5, '0')).Contains(searchString)); + } + + // 4. --- Apply Ordering and Pagination --- + // This should be the last step before executing the query. + + var totalEntites = await expensesQuery.CountAsync(); + + var totalPages = (int)Math.Ceiling((double)totalEntites / pageSize); + + // 5. --- Execute Query and Map Results --- + var expensesList = await expensesQuery + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + + if (!expensesList.Any()) + { + _logger.LogInfo("No expenses found matching the criteria for employee {EmployeeId}.", loggedInEmployee.Id); + return ApiResponse.SuccessResponse(new List(), "No expenses found for the given criteria.", 200); + } + + var projectIds = expensesList.Select(e => e.ProjectId).ToList(); + + var infraProjectTask = Task.Run(async () => + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.Projects.Where(p => projectIds.Contains(p.Id) && p.TenantId == tenantId).Select(p => _mapper.Map(p)).ToListAsync(); + }); + + var serviceProjectTask = Task.Run(async () => + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.ServiceProjects.Where(sp => projectIds.Contains(sp.Id) && sp.TenantId == tenantId).Select(sp => _mapper.Map(sp)).ToListAsync(); + }); + + await Task.WhenAll(infraProjectTask, serviceProjectTask); + + var projects = infraProjectTask.Result; + projects.AddRange(serviceProjectTask.Result); + + expensesList.ForEach(e => e.Project = projects.FirstOrDefault(p => p.Id == e.ProjectId)); + // 7. --- Return Final Success Response --- - var message = $"{expenseVM.Count} expense records fetched successfully."; + var message = $"{expensesList.Count} expense records fetched successfully."; _logger.LogInfo(message); @@ -516,7 +469,7 @@ namespace Marco.Pms.Services.Service CurrentPage = pageNumber, TotalPages = totalPages, TotalEntites = totalEntites, - Data = expenseVM, + Data = expensesList, }; return ApiResponse.SuccessResponse(response, message, 200); } @@ -532,112 +485,199 @@ namespace Marco.Pms.Services.Service } } - public async Task> GetExpenseDetailsAsync(Guid? id, string? expenseUId, Employee loggedInEmployee, Guid tenantId) + /// + /// Retrieves detailed information for a specific expense, including its status workflow, project details, and attachments. + /// Applies permission filtering and S3 URL generation. + /// + /// The GUID of the expense (optional if expenseUId is provided). + /// The formatted Unique ID of the expense (optional if id is provided). + /// The currently authenticated employee requesting the data. + /// The tenant identifier for data isolation. + /// A standardized API response containing the Expense Detail View Model. + public async Task> GetExpenseDetailsAsync(Guid? id, string? expenseUId, Employee loggedInEmployee, Guid tenantId) { + // Log entry with structured data for traceability + _logger.LogInfo("Starting execution of GetExpenseDetailsAsync. EmployeeId: {EmployeeId}, TenantId: {TenantId}", loggedInEmployee.Id, tenantId); + try { + // 1. Validation: Ensure at least one identifier is present if (!id.HasValue && string.IsNullOrWhiteSpace(expenseUId)) { - _logger.LogWarning("Invalid parameters: Both Id and PaymentRequestUID are null or empty."); - return ApiResponse.ErrorResponse("At least one parameter (Id or expenseUId) must be provided.", "Invalid argument.", 400); + _logger.LogWarning("Validation failed: Both ExpenseId and ExpenseUId are null or empty."); + return ApiResponse.ErrorResponse("At least one parameter (Id or expenseUId) must be provided.", "Invalid Argument", 400); } - ExpenseDetailsMongoDB? expenseDetails = null; - if (expenseDetails == null) - { - var expense = await _context.Expenses - .Include(e => e.PaidBy) - .Include(e => e.CreatedBy) - .Include(e => e.ProcessedBy) - .Include(e => e.ApprovedBy) - .Include(e => e.ReviewedBy) - .Include(e => e.PaymentMode) - .Include(e => e.PaymentMode) - .Include(e => e.ExpenseCategory) - .Include(e => e.Status) - .Include(e => e.Currency) - .Include(e => e.PaymentRequest) - .AsNoTracking().FirstOrDefaultAsync(e => (e.Id == id || (e.UIDPrefix + "/" + e.UIDPostfix.ToString().PadLeft(5, '0')) == expenseUId) && e.TenantId == tenantId); - if (expense == null) + // 2. Main Data Fetch: Using AsSplitQuery to prevent Cartesian explosion with multiple Includes + // Note: Ensure _context is injected as Scoped. + var expense = await _context.Expenses + .AsNoTracking() // Read-only query optimization + .AsSplitQuery() // CRITICAL: Splits query into multiple SQL statements to improve performance with many JOINs + .Include(e => e.PaidBy).ThenInclude(e => e!.JobRole) + .Include(e => e.CreatedBy).ThenInclude(e => e!.JobRole) + .Include(e => e.ProcessedBy).ThenInclude(e => e!.JobRole) + .Include(e => e.ApprovedBy).ThenInclude(e => e!.JobRole) + .Include(e => e.ReviewedBy).ThenInclude(e => e!.JobRole) + .Include(e => e.PaymentMode) + .Include(e => e.ExpenseCategory) + .Include(e => e.Status).ThenInclude(s => s!.StatusPermissionMappings) + .Include(e => e.Currency) + .Include(e => e.PaymentRequest) + .Include(e => e.Attachments).ThenInclude(ba => ba.Document) + .Include(e => e.ExpensesReimburseMappings) + .ThenInclude(erm => erm.ExpensesReimburse) + .ThenInclude(er => er!.ReimburseBy) + .ThenInclude(e => e!.JobRole) + .FirstOrDefaultAsync(e => + (e.Id == id || (e.UIDPrefix + "/" + e.UIDPostfix.ToString().PadLeft(5, '0')) == expenseUId) + && e.TenantId == tenantId); + + // 3. Not Found Handling + if (expense == null) + { + _logger.LogWarning("Expense not found. Id: {Id}, UId: {UId}, TenantId: {TenantId}", id ?? Guid.Empty, expenseUId ?? "", tenantId); + return ApiResponse.ErrorResponse("Expense not found.", "Resource Not Found", 404); + } + + // 4. Map Entity to ViewModel + var response = _mapper.Map(expense); + + // 5. Fetch Permissions + using var scope = _serviceScopeFactory.CreateScope(); + var _permissionServices = scope.ServiceProvider.GetRequiredService(); + var permissionIds = await _permissionServices.GetPermissionIdsByEmployeeId(loggedInEmployee.Id); + + // 6. Fetch Next Valid Statuses + // Logic extracted to private helper method for cleaner code + var nextStatusList = await GetNextStatusesAsync(expense.StatusId, tenantId); + + // 7. Filter and Sort Statuses based on permissions and business logic ("Reject" priority) + response.NextStatus = ProcessNextStatuses(nextStatusList, expense.CreatedById, loggedInEmployee.Id, permissionIds); + + // 8. Fetch Project Details + // We run these sequentially to avoid DbContext threading issues. + // Given indexed columns, the performance hit is negligible compared to the overhead of creating new threads/contexts. + var projectVm = await GetProjectDetailsAsync(expense.ProjectId, tenantId); + response.Project = projectVm; + + response.ExpenseLogs = await _context.ExpenseLogs + .AsNoTracking() // Read-only query optimization + .Include(el => el.UpdatedBy) + .ThenInclude(e => e!.JobRole) + .Where(el => el.ExpenseId == expense.Id) + .OrderByDescending(el => el.UpdateAt) + .ProjectTo(_mapper.ConfigurationProvider) // Projection + .ToListAsync(); + + // 9. Generate S3 Pre-signed URLs for attachments + response.Documents = expense.Attachments + .Where(ba => ba.Document != null) + .Select(ba => { - if (id.HasValue) - { - _logger.LogWarning("User attempted to fetch expense details with ID {ExpenseId}, but not found in both database and cache", id); - } - else if (!string.IsNullOrWhiteSpace(expenseUId)) - { - _logger.LogWarning("User attempted to fetch expense details with expenseUId {ExpenseUId}, but not found in both database and cache", expenseUId); - } - return ApiResponse.ErrorResponse("Expense Not Found", "Expense Not Found", 404); - } + var document = ba.Document!; + var result = _mapper.Map(document); - expenseDetails = await GetAllExpnesRelatedTablesForSingle(expense, loggedInEmployee.Id, expense.TenantId); - } - var vm = _mapper.Map(expenseDetails); + result!.PreSignedUrl = _s3Service.GeneratePreSignedUrl(document.S3Key); + result!.ThumbPreSignedUrl = string.IsNullOrWhiteSpace(document.ThumbS3Key) ? result!.PreSignedUrl : _s3Service.GeneratePreSignedUrl(document.ThumbS3Key); + return result; + }).ToList(); - var permissionStatusMappingTask = Task.Run(async () => - { - await using var context = await _dbContextFactory.CreateDbContextAsync(); - return await context.StatusPermissionMapping - .GroupBy(ps => ps.StatusId) - .Select(g => new - { - StatusId = g.Key, - PermissionIds = g.Select(ps => ps.PermissionId).ToList() - }).ToListAsync(); - }); - var expenseReimburseTask = Task.Run(async () => - { - await using var context = await _dbContextFactory.CreateDbContextAsync(); - return await context.ExpensesReimburseMapping - .Include(er => er.ExpensesReimburse) - .ThenInclude(er => er!.ReimburseBy) - .ThenInclude(e => e!.JobRole) - .Where(er => er.TenantId == tenantId && er.ExpensesId == vm.Id) - .Select(er => er.ExpensesReimburse).FirstOrDefaultAsync(); - }); - var permissionStatusMappings = permissionStatusMappingTask.Result; - var expensesReimburse = expenseReimburseTask.Result; - - if (vm.Status != null && (vm.NextStatus?.Any() ?? false)) - { - vm.Status.PermissionIds = permissionStatusMappings.Where(ps => ps.StatusId == vm.Status.Id).Select(ps => ps.PermissionIds).FirstOrDefault(); - foreach (var status in vm.NextStatus) - { - status.PermissionIds = permissionStatusMappings.Where(ps => ps.StatusId == status.Id).Select(ps => ps.PermissionIds).FirstOrDefault(); - } - int index = vm.NextStatus.FindIndex(ns => ns.DisplayName == "Reject"); - if (index > -1) - { - var item = vm.NextStatus[index]; - vm.NextStatus.RemoveAt(index); - vm.NextStatus.Insert(0, item); - } - } - vm.ExpensesReimburse = _mapper.Map(expensesReimburse); - - foreach (var document in expenseDetails.Documents) - { - var response = vm.Documents.FirstOrDefault(d => d.DocumentId == Guid.Parse(document.DocumentId)); - - response!.PreSignedUrl = _s3Service.GeneratePreSignedUrl(document.S3Key); - response!.ThumbPreSignedUrl = _s3Service.GeneratePreSignedUrl(document.ThumbS3Key); - } - - var expenselogs = await _context.ExpenseLogs.Include(el => el.UpdatedBy).Where(el => el.ExpenseId == vm.Id).Select(el => _mapper.Map(el)).ToListAsync(); - - vm.ExpenseLogs = expenselogs; - - _logger.LogInfo("Employee {EmployeeId} successfully fetched expense details with ID {ExpenseId}", loggedInEmployee.Id, vm.Id); - return ApiResponse.SuccessResponse(vm, "Successfully fetched the details of expense", 200); + _logger.LogInfo("Successfully fetched expense details. ExpenseId: {ExpenseId}", response.Id); + return ApiResponse.SuccessResponse(response, "Successfully fetched the details of expense", 200); } catch (Exception ex) { - _logger.LogError(ex, "An unhandled exception occurred while fetching an expense details {ExpenseId} or {ExpenseUId}.", id ?? Guid.Empty, expenseUId ?? "EX_00000"); - return ApiResponse.ErrorResponse("An internal server error occurred.", ExceptionMapper(ex), 500); + // Capture specific context in the error log + _logger.LogError(ex, "Unhandled exception in GetExpenseDetailsAsync. Id: {Id}, UId: {UId}", id ?? Guid.Empty, expenseUId ?? ""); + return ApiResponse.ErrorResponse("An internal server error occurred.", ExceptionMapper(ex), 500); } } + + #region Helper Methods (Private) + + /// + /// Fetches the list of possible next statuses based on current status. + /// + private async Task> GetNextStatusesAsync(Guid? currentStatusId, Guid tenantId) + { + if (!currentStatusId.HasValue) return new List(); + + return await _context.ExpensesStatusMapping + .AsNoTracking() + .Include(esm => esm.NextStatus).ThenInclude(s => s!.StatusPermissionMappings) + .Where(esm => esm.StatusId == currentStatusId && esm.Status != null) + .Select(esm => esm.NextStatus!) // Select only the NextStatus entity + .ToListAsync(); + } + + /// + /// Filters statuses by permission and reorders specific actions (e.g., Reject). + /// + private List ProcessNextStatuses(List nextStatuses, Guid createdById, Guid loggedInEmployeeId, List userPermissionIds) + { + if (nextStatuses == null || !nextStatuses.Any()) return new List(); + + // Business Logic: Move "Reject" to the top + var rejectStatus = nextStatuses.FirstOrDefault(ns => ns.DisplayName == "Reject"); + if (rejectStatus != null) + { + nextStatuses.Remove(rejectStatus); + nextStatuses.Insert(0, rejectStatus); + } + + var resultVMs = new List(); + + foreach (var item in nextStatuses) + { + var vm = _mapper.Map(item); + + // Case 1: If Creator is viewing and status is Review (Assuming Review is a constant GUID or Enum) + if (item.Id == Review && createdById == loggedInEmployeeId) + { + resultVMs.Add(vm); + continue; + } + + // Case 2: Standard Permission Check + bool hasPermission = vm.PermissionIds.Any(pid => userPermissionIds.Contains(pid)); + + // Exclude "Done" status (Assuming Done is a constant GUID) + if (hasPermission && item.Id != Done) + { + resultVMs.Add(vm); + } + } + + return resultVMs.Distinct().ToList(); + } + + /// + /// Attempts to fetch project details from Projects table, falling back to ServiceProjects. + /// + private async Task GetProjectDetailsAsync(Guid? projectId, Guid tenantId) + { + if (!projectId.HasValue) return null; + + // Check Infrastructure Projects + var infraProject = await _context.Projects + .AsNoTracking() + .Where(p => p.Id == projectId && p.TenantId == tenantId) + .ProjectTo(_mapper.ConfigurationProvider) // Optimized: Project directly to VM inside SQL + .FirstOrDefaultAsync(); + + if (infraProject != null) return infraProject; + + // Fallback to Service Projects + return await _context.ServiceProjects + .AsNoTracking() + .Where(sp => sp.Id == projectId && sp.TenantId == tenantId) + .ProjectTo(_mapper.ConfigurationProvider) + .FirstOrDefaultAsync(); + } + + #endregion + public async Task> GetSupplerNameListAsync(Employee loggedInEmployee, Guid tenantId) { try diff --git a/Marco.Pms.Services/Service/MasterService.cs b/Marco.Pms.Services/Service/MasterService.cs index e55adf8..baac924 100644 --- a/Marco.Pms.Services/Service/MasterService.cs +++ b/Marco.Pms.Services/Service/MasterService.cs @@ -2510,7 +2510,7 @@ namespace Marco.Pms.Services.Service foreach (var status in response) { - status.PermissionIds = permissionStatusMapping.Where(ps => ps.StatusId == status.Id).Select(ps => ps.PermissionIds).FirstOrDefault(); + status.PermissionIds = permissionStatusMapping.Where(ps => ps.StatusId == status.Id).Select(ps => ps.PermissionIds).FirstOrDefault() ?? new List(); } _logger.LogInfo("{Count} records of expense status have been fetched successfully by employee {EmployeeId}", response.Count, loggedInEmployee.Id); diff --git a/Marco.Pms.Services/Service/OrganizationService.cs b/Marco.Pms.Services/Service/OrganizationService.cs index 8c49917..94b39a9 100644 --- a/Marco.Pms.Services/Service/OrganizationService.cs +++ b/Marco.Pms.Services/Service/OrganizationService.cs @@ -129,14 +129,17 @@ namespace Marco.Pms.Services.Service // Fetch corresponding employee details in one query var employeeIds = createdByIds.Union(updatedByIds).ToList(); - var employees = await _context.Employees.Where(e => employeeIds.Contains(e.Id)).ToListAsync(); + var employees = await _context.Employees + .Where(e => employeeIds.Contains(e.Id)) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); // Map data to view models including created and updated by employees var vm = organizations.Select(o => { var orgVm = _mapper.Map(o); - orgVm.CreatedBy = employees.Where(e => e.Id == o.CreatedById).Select(e => _mapper.Map(e)).FirstOrDefault(); - orgVm.UpdatedBy = employees.Where(e => e.Id == o.UpdatedById).Select(e => _mapper.Map(e)).FirstOrDefault(); + orgVm.CreatedBy = employees.Where(e => e.Id == o.CreatedById).FirstOrDefault(); + orgVm.UpdatedBy = employees.Where(e => e.Id == o.UpdatedById).FirstOrDefault(); return orgVm; }).ToList(); diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IExpensesService.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IExpensesService.cs index 57ef55a..383c98e 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IExpensesService.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IExpensesService.cs @@ -1,6 +1,7 @@ using Marco.Pms.Model.Dtos.Expenses; using Marco.Pms.Model.Employees; using Marco.Pms.Model.Utilities; +using Marco.Pms.Model.ViewModels.Expenses; namespace Marco.Pms.Services.Service.ServiceInterfaces { @@ -9,7 +10,7 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces #region =================================================================== Expenses Functions =================================================================== Task> GetExpensesListAsync(Employee loggedInEmployee, Guid tenantId, string? searchString, string? filter, int pageSize, int pageNumber); Task> GetExpensesListDynamicAsync(Employee loggedInEmployee, Guid tenantId, string? searchString, string? filter, int pageSize, int pageNumber); - Task> GetExpenseDetailsAsync(Guid? id, string? expenseUId, Employee loggedInEmployee, Guid tenantId); + Task> GetExpenseDetailsAsync(Guid? id, string? expenseUId, Employee loggedInEmployee, Guid tenantId); Task> GetSupplerNameListAsync(Employee loggedInEmployee, Guid tenantId); Task> GetFilterObjectAsync(Employee loggedInEmployee, Guid tenantId); Task> CreateExpenseAsync(CreateExpensesDto dto, Employee loggedInEmployee, Guid tenantId);