Added dynamic sortting, grouping, cloumn searching in collection controller

This commit is contained in:
ashutosh.nehete 2025-11-22 00:31:47 +05:30
parent f1f5fc263f
commit 8f5a49deed
7 changed files with 235 additions and 20 deletions

View File

@ -0,0 +1,11 @@
namespace Marco.Pms.Model.Filters
{
public class AdvanceFilter
{
// The dynamic filters from your JSON
public List<SortItem>? SortFilters { get; set; }
public List<SearchItem>? SearchFilters { get; set; }
public List<AdvanceItem>? AdvanceFilters { get; set; }
public string GroupByColumn { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,9 @@
namespace Marco.Pms.Model.Filters
{
public class AdvanceItem
{
public string Column { get; set; } = string.Empty;
public string Opration { get; set; } = string.Empty; // "greater than", "equal to", etc.
public string Value { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,8 @@
namespace Marco.Pms.Model.Filters
{
public class SearchItem
{
public string Column { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,8 @@
namespace Marco.Pms.Model.Filters
{
public class SortItem
{
public string Column { get; set; } = string.Empty;
public bool SortDescending { get; set; }
}
}

View File

@ -4,6 +4,7 @@ using Marco.Pms.Helpers.Utility;
using Marco.Pms.Model.Collection;
using Marco.Pms.Model.Dtos.Collection;
using Marco.Pms.Model.Entitlements;
using Marco.Pms.Model.Filters;
using Marco.Pms.Model.MongoDBModels.Utility;
using Marco.Pms.Model.OrganizationModel;
using Marco.Pms.Model.Projects;
@ -13,6 +14,7 @@ using Marco.Pms.Model.ViewModels.Activities;
using Marco.Pms.Model.ViewModels.Collection;
using Marco.Pms.Model.ViewModels.Organization;
using Marco.Pms.Model.ViewModels.Projects;
using Marco.Pms.Services.Extensions;
using Marco.Pms.Services.Service;
using MarcoBMS.Services.Helpers;
using MarcoBMS.Services.Service;
@ -20,6 +22,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MongoDB.Driver;
using System.Text.Json;
using Document = Marco.Pms.Model.DocumentManager.Document;
namespace Marco.Pms.Services.Controllers
@ -59,8 +62,8 @@ namespace Marco.Pms.Services.Controllers
/// </summary>
[HttpGet("invoice/list")]
public async Task<IActionResult> GetInvoiceListAsync([FromQuery] Guid? projectId, [FromQuery] string? searchString, [FromQuery] DateTime? fromDate, [FromQuery] DateTime? toDate,
[FromQuery] int pageSize = 20, [FromQuery] int pageNumber = 1, [FromQuery] bool isActive = true, [FromQuery] bool isPending = false)
public async Task<IActionResult> GetInvoiceListAsync([FromQuery] Guid? projectId, [FromQuery] string? searchString, [FromQuery] string? filter, [FromQuery] DateTime? fromDate,
[FromQuery] DateTime? toDate, [FromQuery] int pageSize = 20, [FromQuery] int pageNumber = 1, [FromQuery] bool isActive = true, [FromQuery] bool isPending = false)
{
try
{
@ -94,16 +97,52 @@ namespace Marco.Pms.Services.Controllers
_logger.LogInfo("Permissions validated for EmployeeId {EmployeeId}.", employee.Id);
var advanceFilter = TryDeserializeFilter(filter);
await using var _context = await _dbContextFactory.CreateDbContextAsync();
await using var context = await _dbContextFactory.CreateDbContextAsync();
// Fetch related project data asynchronously and in parallel
var infraProjectsQuery = _context.Projects
.Where(p => p.TenantId == tenantId);
var serviceProjectsQuery = context.ServiceProjects
.Where(sp => sp.TenantId == tenantId);
if (advanceFilter?.SearchFilters != null && advanceFilter.SearchFilters.Any())
{
var projectSearchFilter = advanceFilter.SearchFilters
.Where(f => f.Column == "ProjectName")
.Select(f => new SearchItem { Column = "Name", Value = f.Value })
.ToList();
if (projectSearchFilter.Any())
{
infraProjectsQuery = infraProjectsQuery.ApplySearchFilters(projectSearchFilter);
serviceProjectsQuery = serviceProjectsQuery.ApplySearchFilters(projectSearchFilter);
}
}
var infraProjectsTask = infraProjectsQuery
.Select(p => _mapper.Map<BasicProjectVM>(p))
.ToListAsync();
var serviceProjectsTask = serviceProjectsQuery
.Select(sp => _mapper.Map<BasicProjectVM>(sp))
.ToListAsync();
await Task.WhenAll(infraProjectsTask, serviceProjectsTask);
var projects = infraProjectsTask.Result;
projects.AddRange(serviceProjectsTask.Result);
var projIds = projects.Select(p => p.Id).Distinct().ToList();
// Build invoice query efficiently - always use AsNoTracking for reads
var query = _context.Invoices
.AsNoTracking()
.Include(i => i.BilledTo)
.Include(i => i.CreatedBy).ThenInclude(e => e!.JobRole)
.Include(i => i.UpdatedBy).ThenInclude(e => e!.JobRole)
.Where(i => i.IsActive == isActive && i.TenantId == tenantId);
.Where(i => projIds.Contains(i.ProjectId) && i.IsActive == isActive && i.TenantId == tenantId);
// Filter by date, ensuring date boundaries are correct
if (fromDate.HasValue && toDate.HasValue)
@ -126,11 +165,35 @@ namespace Marco.Pms.Services.Controllers
_logger.LogDebug("Project filter applied: {ProjectId}", projectId.Value);
}
if (advanceFilter != null)
{
query = query.ApplyCustomFilters(advanceFilter);
if (advanceFilter.SearchFilters != null)
{
var invoiceSearchFilter = advanceFilter.SearchFilters.Where(f => f.Column != "ProjectName").ToList();
if (invoiceSearchFilter.Any())
{
query = query.ApplySearchFilters(invoiceSearchFilter);
}
}
}
var hasSortFilters = advanceFilter?.SortFilters?.Any() ?? false;
if (!hasSortFilters)
{
query = query.OrderByDescending(i => i.InvoiceDate);
}
var totalItems = await query.CountAsync();
_logger.LogInfo("Total invoices found: {TotalItems}", totalItems);
string groupByColumn = "ProjectId";
if (!string.IsNullOrWhiteSpace(advanceFilter?.GroupByColumn))
{
groupByColumn = advanceFilter.GroupByColumn;
}
query = query.ApplyGroupByFilters(groupByColumn);
var pagedInvoices = await query
.OrderByDescending(i => i.InvoiceDate)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
@ -155,20 +218,6 @@ namespace Marco.Pms.Services.Controllers
_logger.LogDebug("Received payment data for {Count} invoices.", paymentGroups.Count);
// Fetch related project data asynchronously and in parallel
var projIds = pagedInvoices.Select(i => i.ProjectId).Distinct().ToList();
var infraProjectsTask = _context.Projects
.Where(p => projIds.Contains(p.Id) && p.TenantId == tenantId)
.ToListAsync();
var serviceProjectsTask = context.ServiceProjects
.Where(sp => projIds.Contains(sp.Id) && sp.TenantId == tenantId)
.ToListAsync();
await Task.WhenAll(infraProjectsTask, serviceProjectsTask);
var infraProjects = infraProjectsTask.Result;
var serviceProjects = serviceProjectsTask.Result;
// Build results and compute balances in memory for tight control
var results = new List<InvoiceListVM>();
@ -185,8 +234,7 @@ namespace Marco.Pms.Services.Controllers
var vm = _mapper.Map<InvoiceListVM>(invoice);
// Project mapping logic - minimize nested object allocations
vm.Project = serviceProjects.Where(sp => sp.Id == invoice.ProjectId).Select(p => _mapper.Map<BasicProjectVM>(p)).FirstOrDefault()
?? infraProjects.Where(ip => ip.Id == invoice.ProjectId).Select(sp => _mapper.Map<BasicProjectVM>(sp)).FirstOrDefault();
vm.Project = projects.Where(sp => sp.Id == invoice.ProjectId).FirstOrDefault();
vm.BalanceAmount = balance;
@ -194,6 +242,13 @@ namespace Marco.Pms.Services.Controllers
}
var totalPages = (int)Math.Ceiling((double)totalItems / pageSize);
//string groupByColumn = "Project";
//if (!string.IsNullOrWhiteSpace(advanceFilter?.GroupByColumn))
//{
// groupByColumn = advanceFilter.GroupByColumn;
//}
//var resultQuery = results.AsQueryable();
//object finalResponseData = resultQuery.ApplyGroupByFilters(groupByColumn);
_logger.LogInfo("Returning {Count} invoices (page {PageNumber} of {TotalPages}).", results.Count, pageNumber, totalPages);
@ -1155,6 +1210,45 @@ namespace Marco.Pms.Services.Controllers
#region =================================================================== Helper Functions ===================================================================
private AdvanceFilter? TryDeserializeFilter(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<AdvanceFilter>(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<string>(filter, options) ?? "";
if (!string.IsNullOrWhiteSpace(unescapedJsonString))
{
advanceFilter = JsonSerializer.Deserialize<AdvanceFilter>(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;
}
/// <summary>
/// Async permission check helper with scoped DI lifetime
/// </summary>

View File

@ -0,0 +1,84 @@
using Marco.Pms.Model.Filters;
using System.Linq.Dynamic.Core;
namespace Marco.Pms.Services.Extensions
{
/// <summary>
/// Enterprise-grade extension methods for applying dynamic filters and sorting to IQueryable sources.
/// </summary>
public static class QueryableExtensions
{
public static IQueryable<T> ApplyCustomFilters<T>(this IQueryable<T> query, AdvanceFilter filter)
{
// 1. Apply Advanced Filters (Arithmetic/Logic)
if (filter.AdvanceFilters != null && filter.AdvanceFilters.Any())
{
foreach (var advanceFilter in filter.AdvanceFilters)
{
if (string.IsNullOrWhiteSpace(advanceFilter.Column)) continue;
string op = advanceFilter.Opration.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;
}
if (!string.IsNullOrEmpty(predicate))
{
// Dynamic LINQ handles type conversion (string "100" to int 100) automatically
query = query.Where(predicate, advanceFilter.Value);
}
}
}
// 2. Apply Sorting
if (filter.SortFilters != null && filter.SortFilters.Any())
{
// Build a comma-separated sort string: "Column1 desc, Column2 asc"
var sortExpressions = new List<string>();
foreach (var sort in filter.SortFilters)
{
string direction = sort.SortDescending ? "desc" : "asc";
sortExpressions.Add($"{sort.Column} {direction}");
}
query = query.OrderBy(string.Join(", ", sortExpressions));
}
return query;
}
public static IQueryable<T> ApplySearchFilters<T>(this IQueryable<T> query, List<SearchItem> searchFilters)
{
// 1. Apply Search Filters (Contains/Text search)
if (searchFilters.Any())
{
foreach (var search in searchFilters)
{
if (string.IsNullOrWhiteSpace(search.Column) || string.IsNullOrWhiteSpace(search.Value)) continue;
// Generates: x.Column.Contains("Value")
// Case insensitive logic can be handled here if needed
query = query.Where($"{search.Column}.Contains(@0)", search.Value);
}
}
return query;
}
public static IQueryable<T> ApplyGroupByFilters<T>(this IQueryable<T> query, string groupByColumn)
{
query.GroupBy(groupByColumn).Select("new (Key, ToList() as Items)"); // Reshape to { Key: "Value", Items: [...] }
return query;
}
}
}

View File

@ -42,6 +42,7 @@
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.MongoDB" Version="7.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.7.0" />
</ItemGroup>
<ItemGroup>