diff --git a/Marco.Pms.Model/Dtos/Projects/WorkItemDto.cs b/Marco.Pms.Model/Dtos/Projects/WorkItemDto.cs index 7c98051..d7961d8 100644 --- a/Marco.Pms.Model/Dtos/Projects/WorkItemDto.cs +++ b/Marco.Pms.Model/Dtos/Projects/WorkItemDto.cs @@ -9,8 +9,8 @@ namespace Marco.Pms.Model.Dtos.Project public Guid WorkAreaID { get; set; } public Guid WorkCategoryId { get; set; } public Guid ActivityID { get; set; } - public int PlannedWork { get; set; } - public int CompletedWork { get; set; } + public double PlannedWork { get; set; } + public double CompletedWork { get; set; } public Guid? ParentTaskId { get; set; } public string? Comment { get; set; } } diff --git a/Marco.Pms.Model/Filters/AdvanceFilter.cs b/Marco.Pms.Model/Filters/AdvanceFilter.cs index 3b376a4..297318f 100644 --- a/Marco.Pms.Model/Filters/AdvanceFilter.cs +++ b/Marco.Pms.Model/Filters/AdvanceFilter.cs @@ -3,6 +3,8 @@ public class AdvanceFilter { // The dynamic filters from your JSON + public DateDynamicFilter? DateFilter { get; set; } + public List? Filters { get; set; } public List? SortFilters { get; set; } public List? SearchFilters { get; set; } public List? AdvanceFilters { get; set; } diff --git a/Marco.Pms.Model/Filters/DateDynamicFilter.cs b/Marco.Pms.Model/Filters/DateDynamicFilter.cs new file mode 100644 index 0000000..46630c9 --- /dev/null +++ b/Marco.Pms.Model/Filters/DateDynamicFilter.cs @@ -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; } + } +} diff --git a/Marco.Pms.Model/Filters/ListDynamicFilter.cs b/Marco.Pms.Model/Filters/ListDynamicFilter.cs new file mode 100644 index 0000000..bae0b96 --- /dev/null +++ b/Marco.Pms.Model/Filters/ListDynamicFilter.cs @@ -0,0 +1,8 @@ +namespace Marco.Pms.Model.Filters +{ + public class ListDynamicFilter + { + public string Column { get; set; } = string.Empty; + public List Values { get; set; } = new List(); + } +} diff --git a/Marco.Pms.Services/Controllers/PurchaseInvoiceController.cs b/Marco.Pms.Services/Controllers/PurchaseInvoiceController.cs index 907e39d..34adeaa 100644 --- a/Marco.Pms.Services/Controllers/PurchaseInvoiceController.cs +++ b/Marco.Pms.Services/Controllers/PurchaseInvoiceController.cs @@ -11,21 +11,48 @@ namespace Marco.Pms.Services.Controllers { private readonly UserHelper _userHelper; private readonly IPurchaseInvoiceService _purchaseInvoiceService; + private readonly ISignalRService _signalR; private readonly Guid tenantId; - public PurchaseInvoiceController(UserHelper userHelper, IPurchaseInvoiceService purchaseInvoiceService) + public PurchaseInvoiceController(UserHelper userHelper, IPurchaseInvoiceService purchaseInvoiceService, ISignalRService signalR) { _userHelper = userHelper; _purchaseInvoiceService = purchaseInvoiceService; tenantId = _userHelper.GetTenantId(); + _signalR = signalR; } + /// + /// Retrieves a list of purchase invoices based on search string, filter, activity status, page size, and page number. + /// + /// Optional search string to filter invoices by. + /// Optional filter to apply to the invoices. + /// Optional flag to filter invoices by activity status. + /// The number of invoices to display per page. + /// The requested page number (1-based). + /// Token to propagate notification that operations should be canceled. + /// A HTTP 200 OK response with a list of purchase invoices or an appropriate HTTP error code. + [HttpGet("list")] + public async Task 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); + } + + /// /// Creates a purchase invoice. /// /// The purchase invoice model. /// The cancellation token. - /// The HTTP response with the purchase invoice status and data. + /// The HTTP response for the creation of the purchase invoice. [HttpPost("create")] public async Task CreatePurchaseInvoice([FromBody] PurchaseInvoiceDto model, CancellationToken ct) { @@ -35,7 +62,19 @@ namespace Marco.Pms.Services.Controllers // Create a purchase invoice using the purchase invoice service 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); } } diff --git a/Marco.Pms.Services/Extensions/QueryableExtensions.cs b/Marco.Pms.Services/Extensions/QueryableExtensions.cs index f0e8277..865203a 100644 --- a/Marco.Pms.Services/Extensions/QueryableExtensions.cs +++ b/Marco.Pms.Services/Extensions/QueryableExtensions.cs @@ -1,4 +1,5 @@ using Marco.Pms.Model.Filters; +using System.Data; using System.Linq.Dynamic.Core; namespace Marco.Pms.Services.Extensions @@ -69,9 +70,16 @@ namespace Marco.Pms.Services.Extensions return query; } + /// + /// Applies search filters to the given IQueryable. + /// + /// The type of the elements in the IQueryable. + /// The IQueryable to apply the filters to. + /// The list of search filters to apply. + /// The filtered IQueryable. public static IQueryable ApplySearchFilters(this IQueryable query, List searchFilters) { - // 1. Apply Search Filters (Contains/Text search) + // Apply search filters to the query if (searchFilters.Any()) { foreach (var search in searchFilters) @@ -86,10 +94,70 @@ namespace Marco.Pms.Services.Extensions return query; } + /// + /// Applies group by filters to the given IQueryable. + /// + /// The type of the elements in the IQueryable. + /// The IQueryable to apply the filters to. + /// The column to group by. + /// The grouped IQueryable. public static IQueryable ApplyGroupByFilters(this IQueryable query, string groupByColumn) { + // Group the query by the specified column and reshape the result to { Key: "Value", Items: [...] } query.GroupBy(groupByColumn, "it") - .Select("new (Key, it as Items)"); // Reshape to { Key: "Value", Items: [...] } + .Select("new (Key, it as Items)"); + return query; + } + + /// + /// Applies list filters to the given IQueryable. + /// + /// The type of the elements in the IQueryable. + /// The IQueryable to apply the filters to. + /// The list of filters to apply. + /// The filtered IQueryable. + public static IQueryable ApplyListFilters(this IQueryable query, List 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; + } + + /// + /// Applies date filters to the given IQueryable. + /// + /// The type of the elements in the IQueryable. + /// The IQueryable to apply the filters to. + /// The date filter to apply. + /// The filtered IQueryable. + public static IQueryable ApplyDateFilter(this IQueryable 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; } } diff --git a/Marco.Pms.Services/Service/OrganizationService.cs b/Marco.Pms.Services/Service/OrganizationService.cs index 11e0299..7bfab16 100644 --- a/Marco.Pms.Services/Service/OrganizationService.cs +++ b/Marco.Pms.Services/Service/OrganizationService.cs @@ -167,13 +167,9 @@ namespace Marco.Pms.Services.Service { try { - // 1. INPUT SANITIZATION - // 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); - + // 1. VALIDATION _logger.LogInfo("Fetching Organization list. Page: {Page}, Size: {Size}, Search: {Search}, User: {UserId}", - actualPage, actualSize, searchString ?? "", loggedInEmployee.Id); + pageNumber, pageSize, searchString ?? "", loggedInEmployee.Id); // 2. QUERY BUILDING // 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. var items = await query .OrderBy(o => o.Name) - .Skip((actualPage - 1) * actualSize) - .Take(actualSize) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) .ProjectTo(_mapper.ConfigurationProvider) // Requires AutoMapper.QueryableExtensions .ToListAsync(ct); // 7. PREPARE RESPONSE - var totalPages = (int)Math.Ceiling((double)totalCount / actualSize); + var totalPages = (int)Math.Ceiling((double)totalCount / pageSize); var pagedResult = new { - CurrentPage = actualPage, - PageSize = actualSize, + CurrentPage = pageNumber, + PageSize = pageSize, TotalPages = totalPages, TotalCount = totalCount, - HasPrevious = actualPage > 1, - HasNext = actualPage < totalPages, + HasPrevious = pageNumber > 1, + HasNext = pageNumber < totalPages, Data = items }; diff --git a/Marco.Pms.Services/Service/PurchaseInvoiceService.cs b/Marco.Pms.Services/Service/PurchaseInvoiceService.cs index 874cc54..5287ff2 100644 --- a/Marco.Pms.Services/Service/PurchaseInvoiceService.cs +++ b/Marco.Pms.Services/Service/PurchaseInvoiceService.cs @@ -1,17 +1,22 @@ using AutoMapper; using Marco.Pms.DataAccess.Data; -using Marco.Pms.Model.DocumentManager; using Marco.Pms.Model.Dtos.PurchaseInvoice; using Marco.Pms.Model.Employees; +using Marco.Pms.Model.Filters; using Marco.Pms.Model.OrganizationModel; +using Marco.Pms.Model.Projects; using Marco.Pms.Model.PurchaseInvoice; +using Marco.Pms.Model.ServiceProject; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Organization; using Marco.Pms.Model.ViewModels.Projects; using Marco.Pms.Model.ViewModels.PurchaseInvoice; +using Marco.Pms.Services.Extensions; using Marco.Pms.Services.Service.ServiceInterfaces; using MarcoBMS.Services.Service; using Microsoft.EntityFrameworkCore; +using System.Text.Json; +using Document = Marco.Pms.Model.DocumentManager.Document; namespace Marco.Pms.Services.Service { @@ -37,6 +42,362 @@ namespace Marco.Pms.Services.Service _mapper = mapper; } + //public async Task> 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(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(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(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.SuccessResponse(pagedResult, "Invoice list fetched successfully", 200); + //} + + + /// + /// 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. + /// + /// Optional basic search string (currently not used, kept for backward compatibility). + /// JSON string containing advanced filter configuration (list/date/search/group filters). + /// Flag to filter active/inactive invoices. + /// Number of records per page. + /// Current page number (1-based). + /// Currently logged-in employee context. + /// Tenant identifier for multi-tenant isolation. + /// Cancellation token for cooperative cancellation. + /// Standard ApiResponse containing a paged invoice list payload. + public async Task> 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.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.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 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 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(p)) + .ToListAsync(ct); + }, ct); + + var serviceProjectTask = Task.Run(async () => + { + await using var projContext = await _dbContextFactory.CreateDbContextAsync(ct); + + IQueryable 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(sp)) + .ToListAsync(ct); + }, ct); + + await Task.WhenAll(infraProjectTask, serviceProjectTask); + + var projects = infraProjectTask.Result ?? new List(); + 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(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.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.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.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.ErrorResponse("An unexpected error occurred while fetching purchase invoices. Please try again later or contact support.", 500); + } + } + /// /// Creates a new Purchase Invoice with validation, S3 file uploads, and transactional database storage. /// @@ -255,7 +616,7 @@ namespace Marco.Pms.Services.Service response.Project = projectVm; response.Supplier = _mapper.Map(supplier); - return ApiResponse.SuccessResponse(response, "Purchase invoice created successfully", 200); + return ApiResponse.SuccessResponse(response, "Purchase invoice created successfully", 201); } catch (DbUpdateException ex) { @@ -285,5 +646,46 @@ namespace Marco.Pms.Services.Service return ApiResponse.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(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; + } + } } diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IPurchaseInvoiceService.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IPurchaseInvoiceService.cs index adb935b..62a81fe 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IPurchaseInvoiceService.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IPurchaseInvoiceService.cs @@ -7,6 +7,8 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces { public interface IPurchaseInvoiceService { + Task> GetPurchaseInvoiceListAsync(string? searchString, string? filter, bool isActive, int pageSize, int pageNumber, + Employee loggedInEmployee, Guid tenantId, CancellationToken ct); Task> CreatePurchaseInvoiceAsync(PurchaseInvoiceDto model, Employee loggedInEmployee, Guid tenantId, CancellationToken ct); } }