Merge branch 'Purchase_Invoice_Management' of https://git.marcoaiot.com/admin/marco.pms.api into Response_Encryption

This commit is contained in:
ashutosh.nehete 2025-12-09 16:14:26 +05:30
commit 5459a24a58
13 changed files with 338 additions and 282 deletions

View File

@ -659,6 +659,7 @@ namespace Marco.Pms.DataAccess.Data
}
);
}
private static void ManageApplicationStructure(ModelBuilder modelBuilder)
{
// Configure ApplicationRole to Tenant relationship (if Tenant exists)

View File

@ -38,5 +38,7 @@ namespace Marco.Pms.Model.Collection
[ValidateNever]
[ForeignKey("UpdatedById")]
public Employee? UpdatedBy { get; set; }
public ICollection<ReceivedInvoicePayment> ReceivedInvoicePayments { get; set; } = new List<ReceivedInvoicePayment>();
}
}

View File

@ -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<BillAttachments> Attachments { get; set; } = new List<BillAttachments>();
public ICollection<ExpensesReimburseMapping> ExpensesReimburseMappings { get; set; } = new List<ExpensesReimburseMapping>();
public Guid ExpenseCategoryId { get; set; }

View File

@ -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<StatusPermissionMapping> StatusPermissionMappings { get; set; } = new List<StatusPermissionMapping>();
}
}

View File

@ -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; }

View File

@ -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; }

View File

@ -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<Guid>? PermissionIds { get; set; }
public List<Guid> PermissionIds { get; set; } = new List<Guid>();
public string? Color { get; set; }
public bool IsSystem { get; set; } = false;
}

View File

@ -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<InvoiceListVM>(_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<InvoiceListVM>();
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<InvoiceListVM>(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<object>.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)

View File

@ -169,13 +169,13 @@ namespace Marco.Pms.Services.MappingProfiles
CreateMap<ProjectBasicMongoDB, BasicProjectVM>()
.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<ProjectMongoDB, Project>()
.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<Expenses, ExpenseList>();
CreateMap<Expenses, ExpenseList>()
.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<CreateExpensesDto, Expenses>();
CreateMap<UpdateExpensesDto, Expenses>();
CreateMap<ExpenseLog, ExpenseLogVM>();
@ -320,7 +326,17 @@ namespace Marco.Pms.Services.MappingProfiles
dest => dest.Id,
opt => opt.MapFrom(src => Guid.Parse(src.Id)));
CreateMap<Expenses, ExpenseDetailsVM>();
CreateMap<Expenses, ExpenseDetailsVM>()
.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<ExpenseDetailsMongoDB, ExpenseDetailsVM>()
.ForMember(
dest => dest.Id,
@ -370,7 +386,13 @@ namespace Marco.Pms.Services.MappingProfiles
#region ======================================================= Collection =======================================================
CreateMap<InvoiceDto, Invoice>();
CreateMap<Invoice, InvoiceListVM>();
CreateMap<Invoice, InvoiceListVM>()
.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<Invoice, InvoiceDetailsVM>();
CreateMap<ReceivedInvoicePaymentDto, ReceivedInvoicePayment>();
@ -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<ExpensesStatusMaster, ExpensesStatusMasterVM>();
CreateMap<ExpensesStatusMaster, ExpensesStatusMasterVM>()
.ForMember(
dest => dest.PermissionIds,
opt => opt.MapFrom(src => src.StatusPermissionMappings.Select(spm => spm.PermissionId)));
CreateMap<ExpensesStatusMaster, ExpensesStatusMasterMongoDB>()
.ForMember(

View File

@ -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<ExpenseList>(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<Guid>();
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<Guid>();
}
}
return response;
@ -319,13 +321,11 @@ namespace Marco.Pms.Services.Service
return ApiResponse<object>.ErrorResponse("Error Occured", ExceptionMapper(ex), 500);
}
}
public async Task<ApiResponse<object>> 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<object>.ErrorResponse("User not found or not authenticated.", 403);
}
Guid loggedInEmployeeId = loggedInEmployee.Id;
List<ExpenseList> expenseVM = new List<ExpenseList>();
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<object>.SuccessResponse(new List<ExpenseList>(), "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<ExpenseDetailsMongoDB>? 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<object>.SuccessResponse(new List<ExpenseList>(), "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<BasicProjectVM>(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<BasicProjectVM>(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<ExpenseList>(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<ExpenseList>(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<ExpenseList>(_mapper.ConfigurationProvider)
.ToListAsync();
if (!expensesList.Any())
{
_logger.LogInfo("No expenses found matching the criteria for employee {EmployeeId}.", loggedInEmployee.Id);
return ApiResponse<object>.SuccessResponse(new List<ExpenseList>(), "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<BasicProjectVM>(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<BasicProjectVM>(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<object>.SuccessResponse(response, message, 200);
}
@ -532,112 +485,199 @@ namespace Marco.Pms.Services.Service
}
}
public async Task<ApiResponse<object>> GetExpenseDetailsAsync(Guid? id, string? expenseUId, Employee loggedInEmployee, Guid tenantId)
/// <summary>
/// Retrieves detailed information for a specific expense, including its status workflow, project details, and attachments.
/// Applies permission filtering and S3 URL generation.
/// </summary>
/// <param name="id">The GUID of the expense (optional if expenseUId is provided).</param>
/// <param name="expenseUId">The formatted Unique ID of the expense (optional if id is provided).</param>
/// <param name="loggedInEmployee">The currently authenticated employee requesting the data.</param>
/// <param name="tenantId">The tenant identifier for data isolation.</param>
/// <returns>A standardized API response containing the Expense Detail View Model.</returns>
public async Task<ApiResponse<ExpenseDetailsVM>> 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<object>.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<ExpenseDetailsVM>.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<ExpenseDetailsVM>.ErrorResponse("Expense not found.", "Resource Not Found", 404);
}
// 4. Map Entity to ViewModel
var response = _mapper.Map<ExpenseDetailsVM>(expense);
// 5. Fetch Permissions
using var scope = _serviceScopeFactory.CreateScope();
var _permissionServices = scope.ServiceProvider.GetRequiredService<PermissionServices>();
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<ExpenseLogVM>(_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<object>.ErrorResponse("Expense Not Found", "Expense Not Found", 404);
}
var document = ba.Document!;
var result = _mapper.Map<BasicDocumentVM>(document);
expenseDetails = await GetAllExpnesRelatedTablesForSingle(expense, loggedInEmployee.Id, expense.TenantId);
}
var vm = _mapper.Map<ExpenseDetailsVM>(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<ExpensesReimburseVM>(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<ExpenseLogVM>(el)).ToListAsync();
vm.ExpenseLogs = expenselogs;
_logger.LogInfo("Employee {EmployeeId} successfully fetched expense details with ID {ExpenseId}", loggedInEmployee.Id, vm.Id);
return ApiResponse<object>.SuccessResponse(vm, "Successfully fetched the details of expense", 200);
_logger.LogInfo("Successfully fetched expense details. ExpenseId: {ExpenseId}", response.Id);
return ApiResponse<ExpenseDetailsVM>.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<object>.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<ExpenseDetailsVM>.ErrorResponse("An internal server error occurred.", ExceptionMapper(ex), 500);
}
}
#region Helper Methods (Private)
/// <summary>
/// Fetches the list of possible next statuses based on current status.
/// </summary>
private async Task<List<ExpensesStatusMaster>> GetNextStatusesAsync(Guid? currentStatusId, Guid tenantId)
{
if (!currentStatusId.HasValue) return new List<ExpensesStatusMaster>();
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();
}
/// <summary>
/// Filters statuses by permission and reorders specific actions (e.g., Reject).
/// </summary>
private List<ExpensesStatusMasterVM> ProcessNextStatuses(List<ExpensesStatusMaster> nextStatuses, Guid createdById, Guid loggedInEmployeeId, List<Guid> userPermissionIds)
{
if (nextStatuses == null || !nextStatuses.Any()) return new List<ExpensesStatusMasterVM>();
// 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<ExpensesStatusMasterVM>();
foreach (var item in nextStatuses)
{
var vm = _mapper.Map<ExpensesStatusMasterVM>(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();
}
/// <summary>
/// Attempts to fetch project details from Projects table, falling back to ServiceProjects.
/// </summary>
private async Task<BasicProjectVM?> 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<BasicProjectVM>(_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<BasicProjectVM>(_mapper.ConfigurationProvider)
.FirstOrDefaultAsync();
}
#endregion
public async Task<ApiResponse<object>> GetSupplerNameListAsync(Employee loggedInEmployee, Guid tenantId)
{
try

View File

@ -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<Guid>();
}
_logger.LogInfo("{Count} records of expense status have been fetched successfully by employee {EmployeeId}", response.Count, loggedInEmployee.Id);

View File

@ -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<BasicEmployeeVM>(_mapper.ConfigurationProvider)
.ToListAsync();
// Map data to view models including created and updated by employees
var vm = organizations.Select(o =>
{
var orgVm = _mapper.Map<OrganizationVM>(o);
orgVm.CreatedBy = employees.Where(e => e.Id == o.CreatedById).Select(e => _mapper.Map<BasicEmployeeVM>(e)).FirstOrDefault();
orgVm.UpdatedBy = employees.Where(e => e.Id == o.UpdatedById).Select(e => _mapper.Map<BasicEmployeeVM>(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();

View File

@ -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<ApiResponse<object>> GetExpensesListAsync(Employee loggedInEmployee, Guid tenantId, string? searchString, string? filter, int pageSize, int pageNumber);
Task<ApiResponse<object>> GetExpensesListDynamicAsync(Employee loggedInEmployee, Guid tenantId, string? searchString, string? filter, int pageSize, int pageNumber);
Task<ApiResponse<object>> GetExpenseDetailsAsync(Guid? id, string? expenseUId, Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<ExpenseDetailsVM>> GetExpenseDetailsAsync(Guid? id, string? expenseUId, Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<object>> GetSupplerNameListAsync(Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<object>> GetFilterObjectAsync(Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<object>> CreateExpenseAsync(CreateExpensesDto dto, Employee loggedInEmployee, Guid tenantId);