From 82c9f249ef805a5507cdf4f127b2b7da22ec22c2 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Mon, 8 Dec 2025 10:02:59 +0530 Subject: [PATCH] Added an new API to filter expense by advance filter --- Marco.Pms.Model/Filters/AdvanceItem.cs | 2 +- .../Controllers/ExpenseController.cs | 8 + .../Extensions/QueryableExtensions.cs | 59 +++- Marco.Pms.Services/Service/ExpensesService.cs | 277 +++++++++++++++++- .../ServiceInterfaces/IExpensesService.cs | 1 + 5 files changed, 335 insertions(+), 12 deletions(-) diff --git a/Marco.Pms.Model/Filters/AdvanceItem.cs b/Marco.Pms.Model/Filters/AdvanceItem.cs index 33d6532..2d8ef6b 100644 --- a/Marco.Pms.Model/Filters/AdvanceItem.cs +++ b/Marco.Pms.Model/Filters/AdvanceItem.cs @@ -3,7 +3,7 @@ public class AdvanceItem { public string Column { get; set; } = string.Empty; - public string Opration { get; set; } = string.Empty; // "greater than", "equal to", etc. + public string Operation { get; set; } = string.Empty; // "greater than", "equal to", etc. public string Value { get; set; } = string.Empty; } } diff --git a/Marco.Pms.Services/Controllers/ExpenseController.cs b/Marco.Pms.Services/Controllers/ExpenseController.cs index c3196a0..c5aa29a 100644 --- a/Marco.Pms.Services/Controllers/ExpenseController.cs +++ b/Marco.Pms.Services/Controllers/ExpenseController.cs @@ -46,6 +46,14 @@ namespace Marco.Pms.Services.Controllers return StatusCode(response.StatusCode, response); } + [HttpGet("list-dynamic")] + public async Task GetExpensesListDynamic([FromQuery] string? searchString, [FromQuery] string? filter, [FromQuery] int pageSize = 20, [FromQuery] int pageNumber = 1) + { + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _expensesService.GetExpensesListDynamicAsync(loggedInEmployee, tenantId, searchString, filter, pageSize, pageNumber); + return StatusCode(response.StatusCode, response); + } + [HttpGet("details/{id?}")] public async Task GetExpenseDetails(Guid? id, [FromQuery] string? expenseUId) { diff --git a/Marco.Pms.Services/Extensions/QueryableExtensions.cs b/Marco.Pms.Services/Extensions/QueryableExtensions.cs index 865203a..633d87e 100644 --- a/Marco.Pms.Services/Extensions/QueryableExtensions.cs +++ b/Marco.Pms.Services/Extensions/QueryableExtensions.cs @@ -18,19 +18,62 @@ namespace Marco.Pms.Services.Extensions { if (string.IsNullOrWhiteSpace(advanceFilter.Column)) continue; - string op = advanceFilter.Opration.ToLower().Trim(); + string op = advanceFilter.Operation.ToLower().Trim(); string predicate = ""; // Map your custom strings to Dynamic LINQ operators switch (op) { - case "greater than": predicate = $"{advanceFilter.Column} > @0"; break; - case "less than": predicate = $"{advanceFilter.Column} < @0"; break; - case "equal to": predicate = $"{advanceFilter.Column} == @0"; break; - case "not equal": predicate = $"{advanceFilter.Column} != @0"; break; - case "greater or equal": predicate = $"{advanceFilter.Column} >= @0"; break; - case "smaller or equal": predicate = $"{advanceFilter.Column} <= @0"; break; - default: continue; + // --- Equality --- + case "eq": + case "equal to": + predicate = $"{advanceFilter.Column} == @0"; + break; + + case "neq": + case "not equal": + predicate = $"{advanceFilter.Column} != @0"; + break; + + // --- Numeric / Date Comparison --- + case "gt": + case "greater than": + case "after": // Date specific + predicate = $"{advanceFilter.Column} > @0"; + break; + + case "gte": + case "greater or equal": + predicate = $"{advanceFilter.Column} >= @0"; + break; + + case "lt": // Added for consistency + case "less than": + case "before": // Date specific + predicate = $"{advanceFilter.Column} < @0"; + break; + + case "lte": + case "less or equal": + case "smaller or equal": + predicate = $"{advanceFilter.Column} <= @0"; + break; + + // --- Text Specific --- + case "contains": + predicate = $"{advanceFilter.Column}.Contains(@0)"; + break; + + case "starts": + predicate = $"{advanceFilter.Column}.StartsWith(@0)"; + break; + + case "ends": + predicate = $"{advanceFilter.Column}.EndsWith(@0)"; + break; + + default: + continue; } if (!string.IsNullOrEmpty(predicate)) diff --git a/Marco.Pms.Services/Service/ExpensesService.cs b/Marco.Pms.Services/Service/ExpensesService.cs index d3309b9..5b7d5ce 100644 --- a/Marco.Pms.Services/Service/ExpensesService.cs +++ b/Marco.Pms.Services/Service/ExpensesService.cs @@ -21,6 +21,7 @@ using Marco.Pms.Model.ViewModels.Expenses; using Marco.Pms.Model.ViewModels.Expenses.Masters; using Marco.Pms.Model.ViewModels.Master; using Marco.Pms.Model.ViewModels.Projects; +using Marco.Pms.Services.Extensions; using Marco.Pms.Services.Helpers; using Marco.Pms.Services.Service.ServiceInterfaces; using MarcoBMS.Services.Service; @@ -38,7 +39,6 @@ namespace Marco.Pms.Services.Service private readonly ILoggingService _logger; private readonly S3UploadService _s3Service; private readonly IServiceScopeFactory _serviceScopeFactory; - private readonly UtilityMongoDBHelper _updateLogHelper; private readonly CacheUpdateHelper _cache; private readonly IMapper _mapper; @@ -65,7 +65,6 @@ namespace Marco.Pms.Services.Service IDbContextFactory dbContextFactory, ApplicationDbContext context, IServiceScopeFactory serviceScopeFactory, - UtilityMongoDBHelper updateLogHelper, CacheUpdateHelper cache, ILoggingService logger, S3UploadService s3Service, @@ -76,7 +75,6 @@ namespace Marco.Pms.Services.Service _logger = logger; _cache = cache; _serviceScopeFactory = serviceScopeFactory; - _updateLogHelper = updateLogHelper; _s3Service = s3Service; _mapper = mapper; } @@ -321,6 +319,219 @@ 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}", + pageNumber, pageSize, filter ?? ""); + + // 1. --- Get User Permissions --- + if (loggedInEmployee == null) + { + // This is an authentication/authorization issue. The user should be logged in. + _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); + + await Task.WhenAll(hasViewSelfPermissionTask, hasViewAllPermissionTask); + + var hasViewAllPermission = hasViewAllPermissionTask.Result; + var hasViewSelfPermission = hasViewSelfPermissionTask.Result; + + if (!hasViewAllPermission && !hasViewSelfPermission) + { + // User has neither required permission. Deny access. + _logger.LogWarning("Access DENIED for employee {EmployeeId} attempting to get expenses list.", loggedInEmployeeId); + return ApiResponse.SuccessResponse(new List(), "You do not have permission to view any expenses.", 200); + } + + + // 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 + .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.PaymentRequest) + .Include(e => e.Status) + .Include(e => e.Currency) + .Where(e => e.TenantId == tenantId); // Always filter by TenantId first. + + if (cacheList == null) + { + //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); + + } + else + { + 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; + } + // 7. --- Return Final Success Response --- + var message = $"{expenseVM.Count} expense records fetched successfully."; + _logger.LogInfo(message); + + + var response = new + { + CurrentPage = pageNumber, + TotalPages = totalPages, + TotalEntites = totalEntites, + Data = expenseVM, + }; + return ApiResponse.SuccessResponse(response, message, 200); + } + catch (DbUpdateException dbEx) + { + _logger.LogError(dbEx, "Databsae Exception occured while fetching list expenses"); + return ApiResponse.ErrorResponse("Databsae Exception", ExceptionMapper(dbEx), 500); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occured while fetching list expenses"); + return ApiResponse.ErrorResponse("Error Occured", ExceptionMapper(ex), 500); + } + } + public async Task> GetExpenseDetailsAsync(Guid? id, string? expenseUId, Employee loggedInEmployee, Guid tenantId) { try @@ -733,6 +944,7 @@ namespace Marco.Pms.Services.Service { using var scope = _serviceScopeFactory.CreateScope(); var _firebase = scope.ServiceProvider.GetRequiredService(); + var _updateLogHelper = scope.ServiceProvider.GetRequiredService(); // 1. Fetch Existing Expense with Related Entities (Single Query) var expense = await _context.Expenses @@ -1061,6 +1273,8 @@ namespace Marco.Pms.Services.Service return ApiResponse.ErrorResponse("The employee Id in the path does not match the Id in the request body.", "The employee Id in the path does not match the Id in the request body.", 400); } + using var scope = _serviceScopeFactory.CreateScope(); + var _updateLogHelper = scope.ServiceProvider.GetRequiredService(); // Check if the employee has the required permission var hasManagePermission = await HasPermissionAsync(PermissionsMaster.ExpenseManage, loggedInEmployee.Id); @@ -1294,6 +1508,9 @@ namespace Marco.Pms.Services.Service .Select(ba => ba.DocumentId) .ToListAsync(); + using var scope = _serviceScopeFactory.CreateScope(); + var _updateLogHelper = scope.ServiceProvider.GetRequiredService(); + var existingEntityBson = _updateLogHelper.EntityToBsonDocument(existingExpense); _context.Expenses.Remove(existingExpense); @@ -2130,6 +2347,8 @@ namespace Marco.Pms.Services.Service return ApiResponse.ErrorResponse("You do not have permission for this action.", "Access Denied", 403); } + var _updateLogHelper = scope.ServiceProvider.GetRequiredService(); + // 5. Prepare for update (Audit snapshot) var paymentRequestStateBeforeChange = _updateLogHelper.EntityToBsonDocument(paymentRequest); @@ -2599,6 +2818,9 @@ namespace Marco.Pms.Services.Service bool isVariableRecurring = paymentRequest.RecurringPayment?.IsVariable ?? false; + using var scope = _serviceScopeFactory.CreateScope(); + var _updateLogHelper = scope.ServiceProvider.GetRequiredService(); + // Capture existing state for auditing var existingEntityBson = _updateLogHelper.EntityToBsonDocument(paymentRequest); @@ -2778,6 +3000,9 @@ namespace Marco.Pms.Services.Service .Select(ba => ba.DocumentId) .ToListAsync(); + using var scope = _serviceScopeFactory.CreateScope(); + var _updateLogHelper = scope.ServiceProvider.GetRequiredService(); + var existingEntityBson = _updateLogHelper.EntityToBsonDocument(paymentRequest); paymentRequest.IsActive = false; @@ -3765,6 +3990,46 @@ namespace Marco.Pms.Services.Service #endregion #region =================================================================== Helper Functions =================================================================== + + private AdvanceFilter? TryDeserializeAdvanceFilter(string? filter) + { + if (string.IsNullOrWhiteSpace(filter)) + { + return null; + } + + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + AdvanceFilter? advanceFilter = null; + + try + { + // First, try to deserialize directly. This is the expected case (e.g., from a web client). + advanceFilter = JsonSerializer.Deserialize(filter, options); + } + catch (JsonException ex) + { + _logger.LogError(ex, "[{MethodName}] Failed to directly deserialize filter. Attempting to unescape and re-parse. Filter: {Filter}", nameof(TryDeserializeFilter), filter); + + // If direct deserialization fails, it might be an escaped string (common with tools like Postman or some mobile clients). + try + { + // Unescape the string first, then deserialize the result. + string unescapedJsonString = JsonSerializer.Deserialize(filter, options) ?? ""; + if (!string.IsNullOrWhiteSpace(unescapedJsonString)) + { + advanceFilter = JsonSerializer.Deserialize(unescapedJsonString, options); + } + } + catch (JsonException ex1) + { + // If both attempts fail, log the final error and return null. + _logger.LogError(ex1, "[{MethodName}] All attempts to deserialize the filter failed. Filter will be ignored. Filter: {Filter}", nameof(TryDeserializeFilter), filter); + return null; + } + } + return advanceFilter; + } + private async Task HasPermissionAsync(Guid permission, Guid employeeId) { using var scope = _serviceScopeFactory.CreateScope(); @@ -4135,6 +4400,9 @@ namespace Marco.Pms.Services.Service private async Task DeleteAttachemnts(List documentIds) { + using var scope = _serviceScopeFactory.CreateScope(); + var _updateLogHelper = scope.ServiceProvider.GetRequiredService(); + var attachmentTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); @@ -4176,6 +4444,9 @@ namespace Marco.Pms.Services.Service } private async Task DeletePaymentRequestAttachemnts(List documentIds) { + using var scope = _serviceScopeFactory.CreateScope(); + var _updateLogHelper = scope.ServiceProvider.GetRequiredService(); + var attachmentTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IExpensesService.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IExpensesService.cs index 547862b..57ef55a 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IExpensesService.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IExpensesService.cs @@ -8,6 +8,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> GetSupplerNameListAsync(Employee loggedInEmployee, Guid tenantId); Task> GetFilterObjectAsync(Employee loggedInEmployee, Guid tenantId);