Added an API to get list of purchase invoices wth filter

This commit is contained in:
ashutosh.nehete 2025-11-26 15:00:44 +05:30
parent b6baff7d00
commit 886a32b3e3
9 changed files with 548 additions and 22 deletions

View File

@ -9,8 +9,8 @@ namespace Marco.Pms.Model.Dtos.Project
public Guid WorkAreaID { get; set; } public Guid WorkAreaID { get; set; }
public Guid WorkCategoryId { get; set; } public Guid WorkCategoryId { get; set; }
public Guid ActivityID { get; set; } public Guid ActivityID { get; set; }
public int PlannedWork { get; set; } public double PlannedWork { get; set; }
public int CompletedWork { get; set; } public double CompletedWork { get; set; }
public Guid? ParentTaskId { get; set; } public Guid? ParentTaskId { get; set; }
public string? Comment { get; set; } public string? Comment { get; set; }
} }

View File

@ -3,6 +3,8 @@
public class AdvanceFilter public class AdvanceFilter
{ {
// The dynamic filters from your JSON // The dynamic filters from your JSON
public DateDynamicFilter? DateFilter { get; set; }
public List<ListDynamicFilter>? Filters { get; set; }
public List<SortItem>? SortFilters { get; set; } public List<SortItem>? SortFilters { get; set; }
public List<SearchItem>? SearchFilters { get; set; } public List<SearchItem>? SearchFilters { get; set; }
public List<AdvanceItem>? AdvanceFilters { get; set; } public List<AdvanceItem>? AdvanceFilters { get; set; }

View File

@ -0,0 +1,9 @@
namespace Marco.Pms.Model.Filters
{
public class DateDynamicFilter
{
public string Column { get; set; } = string.Empty;
public DateTime StartValue { get; set; }
public DateTime EndValue { get; set; }
}
}

View File

@ -0,0 +1,8 @@
namespace Marco.Pms.Model.Filters
{
public class ListDynamicFilter
{
public string Column { get; set; } = string.Empty;
public List<Guid> Values { get; set; } = new List<Guid>();
}
}

View File

@ -11,21 +11,48 @@ namespace Marco.Pms.Services.Controllers
{ {
private readonly UserHelper _userHelper; private readonly UserHelper _userHelper;
private readonly IPurchaseInvoiceService _purchaseInvoiceService; private readonly IPurchaseInvoiceService _purchaseInvoiceService;
private readonly ISignalRService _signalR;
private readonly Guid tenantId; private readonly Guid tenantId;
public PurchaseInvoiceController(UserHelper userHelper, IPurchaseInvoiceService purchaseInvoiceService) public PurchaseInvoiceController(UserHelper userHelper, IPurchaseInvoiceService purchaseInvoiceService, ISignalRService signalR)
{ {
_userHelper = userHelper; _userHelper = userHelper;
_purchaseInvoiceService = purchaseInvoiceService; _purchaseInvoiceService = purchaseInvoiceService;
tenantId = _userHelper.GetTenantId(); tenantId = _userHelper.GetTenantId();
_signalR = signalR;
} }
/// <summary>
/// Retrieves a list of purchase invoices based on search string, filter, activity status, page size, and page number.
/// </summary>
/// <param name="searchString">Optional search string to filter invoices by.</param>
/// <param name="filter">Optional filter to apply to the invoices.</param>
/// <param name="isActive">Optional flag to filter invoices by activity status.</param>
/// <param name="pageSize">The number of invoices to display per page.</param>
/// <param name="pageNumber">The requested page number (1-based).</param>
/// <param name="cancellationToken">Token to propagate notification that operations should be canceled.</param>
/// <returns>A HTTP 200 OK response with a list of purchase invoices or an appropriate HTTP error code.</returns>
[HttpGet("list")]
public async Task<IActionResult> GetPurchaseInvoiceListAsync([FromQuery] string? searchString, [FromQuery] string? filter, CancellationToken cancellationToken, [FromQuery] bool isActive = true,
[FromQuery] int pageSize = 20, [FromQuery] int pageNumber = 1)
{
// Get the currently logged-in employee
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
// Retrieve the purchase invoice list using the service
var response = await _purchaseInvoiceService.GetPurchaseInvoiceListAsync(searchString, filter, isActive, pageSize, pageNumber, loggedInEmployee, tenantId, cancellationToken);
// Return the response with the appropriate HTTP status code
return StatusCode(response.StatusCode, response);
}
/// <summary> /// <summary>
/// Creates a purchase invoice. /// Creates a purchase invoice.
/// </summary> /// </summary>
/// <param name="model">The purchase invoice model.</param> /// <param name="model">The purchase invoice model.</param>
/// <param name="ct">The cancellation token.</param> /// <param name="ct">The cancellation token.</param>
/// <returns>The HTTP response with the purchase invoice status and data.</returns> /// <returns>The HTTP response for the creation of the purchase invoice.</returns>
[HttpPost("create")] [HttpPost("create")]
public async Task<IActionResult> CreatePurchaseInvoice([FromBody] PurchaseInvoiceDto model, CancellationToken ct) public async Task<IActionResult> CreatePurchaseInvoice([FromBody] PurchaseInvoiceDto model, CancellationToken ct)
{ {
@ -35,7 +62,19 @@ namespace Marco.Pms.Services.Controllers
// Create a purchase invoice using the purchase invoice service // Create a purchase invoice using the purchase invoice service
var response = await _purchaseInvoiceService.CreatePurchaseInvoiceAsync(model, loggedInEmployee, tenantId, ct); var response = await _purchaseInvoiceService.CreatePurchaseInvoiceAsync(model, loggedInEmployee, tenantId, ct);
// Return the HTTP response with the purchase invoice status and data // If the creation is successful, send a notification to the SignalR service
if (response.Success)
{
var notification = new
{
LoggedInUserId = loggedInEmployee.Id,
Keyword = "Purchase_Invoice",
Response = response.Data
};
await _signalR.SendNotificationAsync(notification);
}
// Return the HTTP response
return StatusCode(response.StatusCode, response); return StatusCode(response.StatusCode, response);
} }
} }

View File

@ -1,4 +1,5 @@
using Marco.Pms.Model.Filters; using Marco.Pms.Model.Filters;
using System.Data;
using System.Linq.Dynamic.Core; using System.Linq.Dynamic.Core;
namespace Marco.Pms.Services.Extensions namespace Marco.Pms.Services.Extensions
@ -69,9 +70,16 @@ namespace Marco.Pms.Services.Extensions
return query; return query;
} }
/// <summary>
/// Applies search filters to the given IQueryable.
/// </summary>
/// <typeparam name="T">The type of the elements in the IQueryable.</typeparam>
/// <param name="query">The IQueryable to apply the filters to.</param>
/// <param name="searchFilters">The list of search filters to apply.</param>
/// <returns>The filtered IQueryable.</returns>
public static IQueryable<T> ApplySearchFilters<T>(this IQueryable<T> query, List<SearchItem> searchFilters) public static IQueryable<T> ApplySearchFilters<T>(this IQueryable<T> query, List<SearchItem> searchFilters)
{ {
// 1. Apply Search Filters (Contains/Text search) // Apply search filters to the query
if (searchFilters.Any()) if (searchFilters.Any())
{ {
foreach (var search in searchFilters) foreach (var search in searchFilters)
@ -86,10 +94,70 @@ namespace Marco.Pms.Services.Extensions
return query; return query;
} }
/// <summary>
/// Applies group by filters to the given IQueryable.
/// </summary>
/// <typeparam name="T">The type of the elements in the IQueryable.</typeparam>
/// <param name="query">The IQueryable to apply the filters to.</param>
/// <param name="groupByColumn">The column to group by.</param>
/// <returns>The grouped IQueryable.</returns>
public static IQueryable<T> ApplyGroupByFilters<T>(this IQueryable<T> query, string groupByColumn) public static IQueryable<T> ApplyGroupByFilters<T>(this IQueryable<T> query, string groupByColumn)
{ {
// Group the query by the specified column and reshape the result to { Key: "Value", Items: [...] }
query.GroupBy(groupByColumn, "it") query.GroupBy(groupByColumn, "it")
.Select("new (Key, it as Items)"); // Reshape to { Key: "Value", Items: [...] } .Select("new (Key, it as Items)");
return query;
}
/// <summary>
/// Applies list filters to the given IQueryable.
/// </summary>
/// <typeparam name="T">The type of the elements in the IQueryable.</typeparam>
/// <param name="query">The IQueryable to apply the filters to.</param>
/// <param name="filters">The list of filters to apply.</param>
/// <returns>The filtered IQueryable.</returns>
public static IQueryable<T> ApplyListFilters<T>(this IQueryable<T> query, List<ListDynamicFilter> filters)
{
// Check if there are any filters
if (filters == null || !filters.Any()) return query;
// Apply filters to the query
foreach (var filter in filters)
{
// Skip if column is empty or values are null or empty
if (string.IsNullOrWhiteSpace(filter.Column) || filter.Values == null || !filter.Values.Any()) continue;
// Apply filter to the query
query = query.Where($"@0.Contains({filter.Column})", filter.Values);
}
// Return the filtered query
return query;
}
/// <summary>
/// Applies date filters to the given IQueryable.
/// </summary>
/// <typeparam name="T">The type of the elements in the IQueryable.</typeparam>
/// <param name="query">The IQueryable to apply the filters to.</param>
/// <param name="dateFilter">The date filter to apply.</param>
/// <returns>The filtered IQueryable.</returns>
public static IQueryable<T> ApplyDateFilter<T>(this IQueryable<T> query, DateDynamicFilter dateFilter)
{
// Check if date filter is null or column is empty
if (dateFilter == null || string.IsNullOrWhiteSpace(dateFilter.Column)) return query;
// Convert start and end values to date
var startValue = dateFilter.StartValue.Date;
var endValue = dateFilter.EndValue.Date.AddDays(1);
// Apply a filter to include items with a date greater than or equal to the start value
query = query.Where($"{dateFilter.Column} >= @0", startValue);
// Apply a filter to include items with a date less than or equal to the end value
query = query.Where($"{dateFilter.Column} < @0", endValue);
// Return the filtered IQueryable
return query; return query;
} }
} }

View File

@ -167,13 +167,9 @@ namespace Marco.Pms.Services.Service
{ {
try try
{ {
// 1. INPUT SANITIZATION // 1. VALIDATION
// Ensure valid pagination limits to prevent performance degradation or DoS attacks.
int actualPage = pageNumber < 1 ? 1 : pageNumber;
int actualSize = pageSize > 50 ? 50 : (pageSize < 1 ? 10 : pageSize);
_logger.LogInfo("Fetching Organization list. Page: {Page}, Size: {Size}, Search: {Search}, User: {UserId}", _logger.LogInfo("Fetching Organization list. Page: {Page}, Size: {Size}, Search: {Search}, User: {UserId}",
actualPage, actualSize, searchString ?? "<empty>", loggedInEmployee.Id); pageNumber, pageSize, searchString ?? "<empty>", loggedInEmployee.Id);
// 2. QUERY BUILDING // 2. QUERY BUILDING
// Use AsNoTracking() for read-only scenarios to reduce overhead. // Use AsNoTracking() for read-only scenarios to reduce overhead.
@ -202,22 +198,22 @@ namespace Marco.Pms.Services.Service
// rather than fetching the whole Entity and discarding data in memory. // rather than fetching the whole Entity and discarding data in memory.
var items = await query var items = await query
.OrderBy(o => o.Name) .OrderBy(o => o.Name)
.Skip((actualPage - 1) * actualSize) .Skip((pageNumber - 1) * pageSize)
.Take(actualSize) .Take(pageSize)
.ProjectTo<BasicOrganizationVm>(_mapper.ConfigurationProvider) // Requires AutoMapper.QueryableExtensions .ProjectTo<BasicOrganizationVm>(_mapper.ConfigurationProvider) // Requires AutoMapper.QueryableExtensions
.ToListAsync(ct); .ToListAsync(ct);
// 7. PREPARE RESPONSE // 7. PREPARE RESPONSE
var totalPages = (int)Math.Ceiling((double)totalCount / actualSize); var totalPages = (int)Math.Ceiling((double)totalCount / pageSize);
var pagedResult = new var pagedResult = new
{ {
CurrentPage = actualPage, CurrentPage = pageNumber,
PageSize = actualSize, PageSize = pageSize,
TotalPages = totalPages, TotalPages = totalPages,
TotalCount = totalCount, TotalCount = totalCount,
HasPrevious = actualPage > 1, HasPrevious = pageNumber > 1,
HasNext = actualPage < totalPages, HasNext = pageNumber < totalPages,
Data = items Data = items
}; };

View File

@ -1,17 +1,22 @@
using AutoMapper; using AutoMapper;
using Marco.Pms.DataAccess.Data; using Marco.Pms.DataAccess.Data;
using Marco.Pms.Model.DocumentManager;
using Marco.Pms.Model.Dtos.PurchaseInvoice; using Marco.Pms.Model.Dtos.PurchaseInvoice;
using Marco.Pms.Model.Employees; using Marco.Pms.Model.Employees;
using Marco.Pms.Model.Filters;
using Marco.Pms.Model.OrganizationModel; using Marco.Pms.Model.OrganizationModel;
using Marco.Pms.Model.Projects;
using Marco.Pms.Model.PurchaseInvoice; using Marco.Pms.Model.PurchaseInvoice;
using Marco.Pms.Model.ServiceProject;
using Marco.Pms.Model.Utilities; using Marco.Pms.Model.Utilities;
using Marco.Pms.Model.ViewModels.Organization; using Marco.Pms.Model.ViewModels.Organization;
using Marco.Pms.Model.ViewModels.Projects; using Marco.Pms.Model.ViewModels.Projects;
using Marco.Pms.Model.ViewModels.PurchaseInvoice; using Marco.Pms.Model.ViewModels.PurchaseInvoice;
using Marco.Pms.Services.Extensions;
using Marco.Pms.Services.Service.ServiceInterfaces; using Marco.Pms.Services.Service.ServiceInterfaces;
using MarcoBMS.Services.Service; using MarcoBMS.Services.Service;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using Document = Marco.Pms.Model.DocumentManager.Document;
namespace Marco.Pms.Services.Service namespace Marco.Pms.Services.Service
{ {
@ -37,6 +42,362 @@ namespace Marco.Pms.Services.Service
_mapper = mapper; _mapper = mapper;
} }
//public async Task<ApiResponse<object>> GetPurchaseInvoiceListAsync(string? searchString, string? filter, bool isActive, int pageSize, int pageNumber,
// Employee loggedInEmployee, Guid tenantId, CancellationToken ct)
//{
// _logger.LogInfo("GetPurchaseInvoiceListAsync called for TenantId: {TenantId}, EmployeeId: {EmployeeId}", tenantId, loggedInEmployee.Id);
// await using var _context = await _dbContextFactory.CreateDbContextAsync(ct);
// //var purchaseInvoices = _context.PurchaseInvoiceDetails
// var query = _context.PurchaseInvoiceDetails
// .Include(pid => pid.Organization)
// .Include(pid => pid.Supplier)
// .Include(pid => pid.Status)
// .Where(pid => pid.IsActive == isActive && pid.TenantId == tenantId);
// var advanceFilter = TryDeserializeFilter(filter);
// query = query.ApplyCustomFilters(advanceFilter, "CreatedAt");
// if (advanceFilter != null)
// {
// if (advanceFilter.Filters != null)
// {
// query = query.ApplyListFilters(advanceFilter.Filters);
// }
// if (advanceFilter.DateFilter != null)
// {
// query = query.ApplyDateFilter(advanceFilter.DateFilter);
// }
// if (advanceFilter.SearchFilters != null)
// {
// var invoiceSearchFilter = advanceFilter.SearchFilters.Where(f => f.Column != "ProjectName" || f.Column != "Project").ToList();
// if (invoiceSearchFilter.Any())
// {
// query = query.ApplySearchFilters(invoiceSearchFilter);
// }
// }
// if (!string.IsNullOrWhiteSpace(advanceFilter.GroupByColumn))
// {
// query = query.ApplyGroupByFilters(advanceFilter.GroupByColumn);
// }
// }
// bool isProjectFilter = false;
// var infraProjectTask = Task.Run(async () =>
// {
// await using var context = await _dbContextFactory.CreateDbContextAsync();
// var infraProjectsQuery = context.Projects.Where(p => p.TenantId == tenantId);
// if (advanceFilter?.SearchFilters != null && advanceFilter.SearchFilters.Any())
// {
// var projectSearchFilter = advanceFilter.SearchFilters
// .Where(f => f.Column == "ProjectName" || f.Column == "Project")
// .Select(f => new SearchItem { Column = "Name", Value = f.Value })
// .ToList();
// if (projectSearchFilter.Any())
// {
// infraProjectsQuery = infraProjectsQuery.ApplySearchFilters(projectSearchFilter);
// isProjectFilter = true;
// }
// }
// return await infraProjectsQuery.Select(p => _mapper.Map<BasicProjectVM>(p)).ToListAsync();
// });
// var serviceProjectTask = Task.Run(async () =>
// {
// await using var context = await _dbContextFactory.CreateDbContextAsync();
// 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" || f.Column == "Project")
// .Select(f => new SearchItem { Column = "Name", Value = f.Value })
// .ToList();
// if (projectSearchFilter.Any())
// {
// serviceProjectsQuery = serviceProjectsQuery.ApplySearchFilters(projectSearchFilter);
// isProjectFilter = true;
// }
// }
// return await serviceProjectsQuery.Select(sp => _mapper.Map<BasicProjectVM>(sp)).ToListAsync();
// });
// await Task.WhenAll(infraProjectTask, serviceProjectTask);
// var projects = infraProjectTask.Result;
// projects.AddRange(serviceProjectTask.Result);
// if (isProjectFilter)
// {
// var projectIds = projects.Select(p => p.Id).ToList();
// query = query.Where(pid => projectIds.Contains(pid.ProjectId));
// }
// var totalCount = await query.CountAsync(ct);
// var purchaseInvoices = await query
// .Skip((pageNumber - 1) * pageSize)
// .Take(pageSize)
// .ToListAsync();
// var totalPages = (int)Math.Ceiling((double)totalCount / pageSize);
// var response = purchaseInvoices.Select(pi =>
// {
// var result = _mapper.Map<PurchaseInvoiceListVM>(pi);
// result.Project = projects.FirstOrDefault(p => p.Id == pi.ProjectId);
// return result;
// }).ToList();
// var pagedResult = new
// {
// CurrentPage = pageNumber,
// PageSize = pageSize,
// TotalPages = totalPages,
// TotalCount = totalCount,
// HasPrevious = pageNumber > 1,
// HasNext = pageNumber < totalPages,
// Data = response
// };
// return ApiResponse<object>.SuccessResponse(pagedResult, "Invoice list fetched successfully", 200);
//}
/// <summary>
/// Retrieves a paged list of purchase invoices for a given tenant with support for
/// advanced filters, project-based search across Infra and Service projects, and
/// consistent structured logging and error handling.
/// </summary>
/// <param name="searchString">Optional basic search string (currently not used, kept for backward compatibility).</param>
/// <param name="filter">JSON string containing advanced filter configuration (list/date/search/group filters).</param>
/// <param name="isActive">Flag to filter active/inactive invoices.</param>
/// <param name="pageSize">Number of records per page.</param>
/// <param name="pageNumber">Current page number (1-based).</param>
/// <param name="loggedInEmployee">Currently logged-in employee context.</param>
/// <param name="tenantId">Tenant identifier for multi-tenant isolation.</param>
/// <param name="ct">Cancellation token for cooperative cancellation.</param>
/// <returns>Standard ApiResponse containing a paged invoice list payload.</returns>
public async Task<ApiResponse<object>> GetPurchaseInvoiceListAsync(string? searchString, string? filter, bool isActive, int pageSize, int pageNumber, Employee loggedInEmployee, Guid tenantId,
CancellationToken ct)
{
// Basic argument validation and guard clauses
if (tenantId == Guid.Empty)
{
_logger.LogWarning("GetPurchaseInvoiceListAsync called with empty TenantId. EmployeeId: {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Tenant information is missing. Please retry with a valid tenant.", 400);
}
if (pageSize <= 0 || pageNumber <= 0)
{
_logger.LogWarning(
"GetPurchaseInvoiceListAsync called with invalid paging parameters. TenantId: {TenantId}, EmployeeId: {EmployeeId}, PageSize: {PageSize}, PageNumber: {PageNumber}",
tenantId, loggedInEmployee.Id, pageSize, pageNumber);
return ApiResponse<object>.ErrorResponse("Invalid paging parameters. Page size and page number must be greater than zero.", 400);
}
// A correlationId can be pushed earlier in middleware and enriched into all logs.
// Here it is assumed to be available through some context (e.g. _requestContext).
_logger.LogInfo(
"GetPurchaseInvoiceListAsync started. TenantId: {TenantId}, EmployeeId: {EmployeeId}, IsActive: {IsActive}, PageSize: {PageSize}, PageNumber: {PageNumber}",
tenantId, loggedInEmployee.Id, isActive, pageSize, pageNumber);
try
{
await using var context = await _dbContextFactory.CreateDbContextAsync(ct);
// Base query for purchase invoices scoped to tenant and active flag
IQueryable<PurchaseInvoiceDetails> query = context.PurchaseInvoiceDetails
.Include(pid => pid.Organization)
.Include(pid => pid.Supplier)
.Include(pid => pid.Status)
.Where(pid => pid.IsActive == isActive && pid.TenantId == tenantId);
var advanceFilter = TryDeserializeFilter(filter);
// Apply ordering, default sort, etc. through your custom extension
query = query.ApplyCustomFilters(advanceFilter, "CreatedAt");
if (advanceFilter != null)
{
// Apply list / dropdown / enum filters
if (advanceFilter.Filters != null)
{
query = query.ApplyListFilters(advanceFilter.Filters);
}
// Apply created/modified date range filters
if (advanceFilter.DateFilter != null)
{
query = query.ApplyDateFilter(advanceFilter.DateFilter);
}
// Apply non-project search filters on invoice fields
if (advanceFilter.SearchFilters != null)
{
// NOTE: fixed logic - use && so that Project/ProjectName are excluded
var invoiceSearchFilter = advanceFilter.SearchFilters
.Where(f => f.Column != "ProjectName" && f.Column != "Project")
.ToList();
if (invoiceSearchFilter.Any())
{
query = query.ApplySearchFilters(invoiceSearchFilter);
}
}
// Apply grouping if configured
if (!string.IsNullOrWhiteSpace(advanceFilter.GroupByColumn))
{
query = query.ApplyGroupByFilters(advanceFilter.GroupByColumn);
}
}
bool isProjectFilter = false;
// Run project lookups in parallel to reduce latency for project search scenarios.
// Each task gets its own DbContext instance from the factory.
var infraProjectTask = Task.Run(async () =>
{
await using var projContext = await _dbContextFactory.CreateDbContextAsync(ct);
IQueryable<Project> infraProjectsQuery = projContext.Projects
.Where(p => p.TenantId == tenantId);
if (advanceFilter?.SearchFilters != null && advanceFilter.SearchFilters.Any())
{
var projectSearchFilter = advanceFilter.SearchFilters
.Where(f => f.Column == "ProjectName" || f.Column == "Project")
.Select(f => new SearchItem { Column = "Name", Value = f.Value })
.ToList();
if (projectSearchFilter.Any())
{
infraProjectsQuery = infraProjectsQuery.ApplySearchFilters(projectSearchFilter);
isProjectFilter = true; // NOTE: shared flag, see comment below.
}
}
return await infraProjectsQuery
.Select(p => _mapper.Map<BasicProjectVM>(p))
.ToListAsync(ct);
}, ct);
var serviceProjectTask = Task.Run(async () =>
{
await using var projContext = await _dbContextFactory.CreateDbContextAsync(ct);
IQueryable<ServiceProject> serviceProjectsQuery = projContext.ServiceProjects
.Where(sp => sp.TenantId == tenantId);
if (advanceFilter?.SearchFilters != null && advanceFilter.SearchFilters.Any())
{
var projectSearchFilter = advanceFilter.SearchFilters
.Where(f => f.Column == "ProjectName" || f.Column == "Project")
.Select(f => new SearchItem { Column = "Name", Value = f.Value })
.ToList();
if (projectSearchFilter.Any())
{
serviceProjectsQuery = serviceProjectsQuery.ApplySearchFilters(projectSearchFilter);
isProjectFilter = true; // This is safe for bool but can be refactored for purity.
}
}
return await serviceProjectsQuery
.Select(sp => _mapper.Map<BasicProjectVM>(sp))
.ToListAsync(ct);
}, ct);
await Task.WhenAll(infraProjectTask, serviceProjectTask);
var projects = infraProjectTask.Result ?? new List<BasicProjectVM>();
if (serviceProjectTask.Result != null && serviceProjectTask.Result.Any())
{
projects.AddRange(serviceProjectTask.Result);
}
// If project filters were involved, constrain invoices to those projects
if (isProjectFilter && projects.Any())
{
var projectIds = projects.Select(p => p.Id).ToList();
query = query.Where(pid => projectIds.Contains(pid.ProjectId));
}
// Compute total count before paging
var totalCount = await query.CountAsync(ct);
// Apply paging
var purchaseInvoices = await query
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync(ct);
var totalPages = totalCount == 0
? 0
: (int)Math.Ceiling((double)totalCount / pageSize);
// Map invoice entities to view model and attach project details
var response = purchaseInvoices.Select(pi =>
{
var vm = _mapper.Map<PurchaseInvoiceListVM>(pi);
vm.Project = projects.FirstOrDefault(p => p.Id == pi.ProjectId);
return vm;
}).ToList();
var pagedResult = new
{
CurrentPage = pageNumber,
PageSize = pageSize,
TotalPages = totalPages,
TotalCount = totalCount,
HasPrevious = pageNumber > 1,
HasNext = pageNumber < totalPages,
Data = response
};
_logger.LogInfo(
"GetPurchaseInvoiceListAsync completed successfully. TenantId: {TenantId}, EmployeeId: {EmployeeId}, TotalCount: {TotalCount}, ReturnedCount: {ReturnedCount}, PageNumber: {PageNumber}, PageSize: {PageSize}",
tenantId, loggedInEmployee.Id, totalCount, response.Count, pageNumber, pageSize);
var successMessage = totalCount == 0
? "No purchase invoices found for the specified criteria."
: "Purchase invoice list fetched successfully.";
return ApiResponse<object>.SuccessResponse(pagedResult, successMessage, 200);
}
catch (OperationCanceledException ocex)
{
// Respect cancellation and return a 499-style semantic code if your ApiResponse supports it
_logger.LogError(ocex, "GetPurchaseInvoiceListAsync request was canceled. TenantId: {TenantId}, EmployeeId: {EmployeeId}", tenantId, loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("The request was canceled by the client or the server. Please retry if this was unintentional.", 499);
}
catch (DbUpdateException dbEx)
{
_logger.LogError(dbEx,
"Database error while fetching purchase invoices. TenantId: {TenantId}, EmployeeId: {EmployeeId}", tenantId, loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("A database error occurred while fetching purchase invoices. Please try again later or contact support.", 500);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception in GetPurchaseInvoiceListAsync. CorrelationId: {CorrelationId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", tenantId, loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("An unexpected error occurred while fetching purchase invoices. Please try again later or contact support.", 500);
}
}
/// <summary> /// <summary>
/// Creates a new Purchase Invoice with validation, S3 file uploads, and transactional database storage. /// Creates a new Purchase Invoice with validation, S3 file uploads, and transactional database storage.
/// </summary> /// </summary>
@ -255,7 +616,7 @@ namespace Marco.Pms.Services.Service
response.Project = projectVm; response.Project = projectVm;
response.Supplier = _mapper.Map<BasicOrganizationVm>(supplier); response.Supplier = _mapper.Map<BasicOrganizationVm>(supplier);
return ApiResponse<PurchaseInvoiceListVM>.SuccessResponse(response, "Purchase invoice created successfully", 200); return ApiResponse<PurchaseInvoiceListVM>.SuccessResponse(response, "Purchase invoice created successfully", 201);
} }
catch (DbUpdateException ex) catch (DbUpdateException ex)
{ {
@ -285,5 +646,46 @@ namespace Marco.Pms.Services.Service
return ApiResponse<PurchaseInvoiceListVM>.ErrorResponse("Creation Failed", "An unexpected error occurred while processing your request.", 500); return ApiResponse<PurchaseInvoiceListVM>.ErrorResponse("Creation Failed", "An unexpected error occurred while processing your request.", 500);
} }
} }
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;
}
} }
} }

View File

@ -7,6 +7,8 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces
{ {
public interface IPurchaseInvoiceService public interface IPurchaseInvoiceService
{ {
Task<ApiResponse<object>> GetPurchaseInvoiceListAsync(string? searchString, string? filter, bool isActive, int pageSize, int pageNumber,
Employee loggedInEmployee, Guid tenantId, CancellationToken ct);
Task<ApiResponse<PurchaseInvoiceListVM>> CreatePurchaseInvoiceAsync(PurchaseInvoiceDto model, Employee loggedInEmployee, Guid tenantId, CancellationToken ct); Task<ApiResponse<PurchaseInvoiceListVM>> CreatePurchaseInvoiceAsync(PurchaseInvoiceDto model, Employee loggedInEmployee, Guid tenantId, CancellationToken ct);
} }
} }