Added cache to expenses get list and create expense APIs

This commit is contained in:
ashutosh.nehete 2025-07-23 09:56:01 +05:30
parent d536b9c99c
commit 73cf85a1cc
13 changed files with 642 additions and 130 deletions

View File

@ -27,6 +27,7 @@ namespace Marco.Pms.Helpers.CacheHelper
var update = Builders<EmployeePermissionMongoDB>.Update
.AddToSetEach(e => e.ApplicationRoleIds, newRoleIds)
.Set(r => r.ExpireAt, DateTime.UtcNow.Date.AddDays(1))
.AddToSetEach(e => e.PermissionIds, newPermissionIds);
var options = new UpdateOptions { IsUpsert = true };
@ -46,6 +47,7 @@ namespace Marco.Pms.Helpers.CacheHelper
var filter = Builders<EmployeePermissionMongoDB>.Filter.Eq(e => e.Id, employeeId.ToString());
var update = Builders<EmployeePermissionMongoDB>.Update
.Set(r => r.ExpireAt, DateTime.UtcNow.Date.AddDays(1))
.AddToSetEach(e => e.ProjectIds, newprojectIds);
var result = await _collection.UpdateOneAsync(filter, update, new UpdateOptions { IsUpsert = true });
@ -187,17 +189,12 @@ namespace Marco.Pms.Helpers.CacheHelper
// A private method to handle the one-time setup of the collection's indexes.
private async Task InitializeCollectionAsync()
{
// 1. Define the TTL (Time-To-Live) index on the 'ExpireAt' field.
var indexKeys = Builders<EmployeePermissionMongoDB>.IndexKeys.Ascending(x => x.ExpireAt);
var indexOptions = new CreateIndexOptions
{
// This tells MongoDB to automatically delete documents when their 'ExpireAt' time is reached.
ExpireAfter = TimeSpan.FromSeconds(0)
ExpireAfter = TimeSpan.Zero // required for fixed expiration time
};
var indexModel = new CreateIndexModel<EmployeePermissionMongoDB>(indexKeys, indexOptions);
// 2. Create the index. This is an idempotent operation if the index already exists.
// Use CreateOneAsync since we are only creating a single index.
await _collection.Indexes.CreateOneAsync(indexModel);
}
}

View File

@ -1,4 +1,5 @@
using Marco.Pms.Model.MongoDBModels.Employees;
using Marco.Pms.Model.MongoDBModels.Expenses;
using Marco.Pms.Model.Utilities;
using Microsoft.Extensions.Configuration;
using MongoDB.Driver;
@ -6,7 +7,7 @@ namespace Marco.Pms.Helpers.CacheHelper
{
public class ExpenseCache
{
private readonly IMongoCollection<EmployeePermissionMongoDB> _collection;
private readonly IMongoCollection<ExpenseDetailsMongoDB> _collection;
public ExpenseCache(IConfiguration configuration)
{
@ -14,11 +15,91 @@ namespace Marco.Pms.Helpers.CacheHelper
var mongoUrl = new MongoUrl(connectionString);
var client = new MongoClient(mongoUrl); // Your MongoDB connection string
var mongoDB = client.GetDatabase(mongoUrl.DatabaseName); // Your MongoDB Database name
_collection = mongoDB.GetCollection<EmployeePermissionMongoDB>("Expenses");
_collection = mongoDB.GetCollection<ExpenseDetailsMongoDB>("Expenses");
}
public async Task AddExpenseToCacheAsync()
public async Task AddExpenseToCacheAsync(ExpenseDetailsMongoDB expense)
{
await _collection.InsertOneAsync(expense);
await InitializeCollectionAsync();
}
public async Task AddExpensesListToCacheAsync(List<ExpenseDetailsMongoDB> expenses)
{
// 1. Add a guard clause to avoid an unnecessary database call for an empty list.
if (expenses == null || !expenses.Any())
{
return;
}
// 2. Perform the insert operation. This is the only responsibility of this method.
await _collection.InsertManyAsync(expenses);
await InitializeCollectionAsync();
}
public async Task<(int totalPages, long totalCount, List<ExpenseDetailsMongoDB> expenseList)> GetExpenseListFromCacheAsync(Guid tenantId, Guid loggedInEmployeeId, bool viewAll,
bool viewSelf, int pageNumber, int pageSize, ExpensesFilter? expenseFilter)
{
var filterBuilder = Builders<ExpenseDetailsMongoDB>.Filter;
var filter = filterBuilder.Empty;
// Permission-based filter
if (!viewAll && viewSelf)
{
filter &= filterBuilder.Eq(e => e.CreatedById, loggedInEmployeeId.ToString());
}
// Apply filters
if (expenseFilter != null)
{
if (expenseFilter.StartDate.HasValue && expenseFilter.EndDate.HasValue)
{
filter &= filterBuilder.Gte(e => e.CreatedAt, expenseFilter.StartDate.Value.Date)
& filterBuilder.Lte(e => e.CreatedAt, expenseFilter.EndDate.Value.Date.AddDays(1).AddTicks(-1));
}
if (expenseFilter.ProjectIds?.Any() == true)
{
filter &= filterBuilder.In(e => e.ProjectId, expenseFilter.ProjectIds.Select(p => p.ToString()).ToList());
}
if (expenseFilter.StatusIds?.Any() == true)
{
filter &= filterBuilder.In(e => e.StatusId, expenseFilter.StatusIds.Select(p => p.ToString()).ToList());
}
if (expenseFilter.PaidById?.Any() == true)
{
filter &= filterBuilder.In(e => e.PaidById, expenseFilter.PaidById.Select(p => p.ToString()).ToList());
}
if (expenseFilter.CreatedByIds?.Any() == true && viewAll)
{
filter &= filterBuilder.In(e => e.CreatedById, expenseFilter.CreatedByIds.Select(p => p.ToString()).ToList());
}
}
// Total count
var totalCount = await _collection.CountDocumentsAsync(filter);
var totalPages = (int)Math.Ceiling((double)totalCount / pageSize);
// Fetch paginated data
var expenses = await _collection
.Find(filter)
.Skip((pageNumber - 1) * pageSize)
.Limit(pageSize)
.SortByDescending(e => e.CreatedAt)
.ToListAsync();
return (totalPages, totalCount, expenses);
}
private async Task InitializeCollectionAsync()
{
var indexKeys = Builders<ExpenseDetailsMongoDB>.IndexKeys.Ascending(x => x.ExpireAt);
var indexOptions = new CreateIndexOptions
{
ExpireAfter = TimeSpan.Zero // required for fixed expiration time
};
var indexModel = new CreateIndexModel<ExpenseDetailsMongoDB>(indexKeys, indexOptions);
await _collection.Indexes.CreateOneAsync(indexModel);
}
}
}

View File

@ -30,13 +30,7 @@ namespace Marco.Pms.Helpers
{
await _projectCollection.InsertOneAsync(projectDetails);
var indexKeys = Builders<ProjectMongoDB>.IndexKeys.Ascending(x => x.ExpireAt);
var indexOptions = new CreateIndexOptions
{
ExpireAfter = TimeSpan.Zero // required for fixed expiration time
};
var indexModel = new CreateIndexModel<ProjectMongoDB>(indexKeys, indexOptions);
await _projectCollection.Indexes.CreateOneAsync(indexModel);
await InitializeCollectionAsync();
}
public async Task AddProjectDetailsListToCache(List<ProjectMongoDB> projectDetailsList)
@ -53,17 +47,12 @@ namespace Marco.Pms.Helpers
}
private async Task InitializeCollectionAsync()
{
// 1. Define the TTL (Time-To-Live) index on the 'ExpireAt' field.
var indexKeys = Builders<ProjectMongoDB>.IndexKeys.Ascending(x => x.ExpireAt);
var indexOptions = new CreateIndexOptions
{
// This tells MongoDB to automatically delete documents when their 'ExpireAt' time is reached.
ExpireAfter = TimeSpan.FromSeconds(0)
ExpireAfter = TimeSpan.Zero // required for fixed expiration time
};
var indexModel = new CreateIndexModel<ProjectMongoDB>(indexKeys, indexOptions);
// 2. Create the index. This is an idempotent operation if the index already exists.
// Use CreateOneAsync since we are only creating a single index.
await _projectCollection.Indexes.CreateOneAsync(indexModel);
}
public async Task<bool> UpdateProjectDetailsOnlyToCache(Project project, StatusMaster projectStatus)

View File

@ -5,7 +5,6 @@
public Guid EmpID { get; set; }
public Guid JobRoleId { get; set; }
public Guid ProjectId { get; set; }
public bool Status { get; set; }
}
@ -14,7 +13,6 @@
{
public Guid ProjectId { get; set; }
public Guid JobRoleId { get; set; }
public bool Status { get; set; }
}
}

View File

@ -0,0 +1,13 @@
namespace Marco.Pms.Model.MongoDBModels.Employees
{
public class BasicEmployeeMongoDB
{
public string Id { get; set; } = string.Empty;
public string? FirstName { get; set; }
public string? LastName { get; set; }
public byte[]? Photo { get; set; }
public string? JobRoleId { get; set; }
public string? JobRoleName { get; set; }
public string TenantId { get; set; } = string.Empty;
}
}

View File

@ -2,5 +2,27 @@
{
public class ExpenseDetailsMongoDB
{
public string Id { get; set; } = string.Empty;
public string ProjectId { get; set; } = string.Empty;
public string ExpensesTypeId { get; set; } = string.Empty;
public string PaymentModeId { get; set; } = string.Empty;
public string PaidById { get; set; } = string.Empty;
public string CreatedById { get; set; } = string.Empty;
public DateTime TransactionDate { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime ExpireAt { get; set; } = DateTime.UtcNow.Date.AddDays(1);
public string SupplerName { get; set; } = string.Empty;
public double Amount { get; set; }
public string StatusId { get; set; } = string.Empty;
public bool PreApproved { get; set; } = false;
public string? TransactionId { get; set; }
public string Description { get; set; } = string.Empty;
public string? Location { get; set; }
public List<string> S3Key { get; set; } = new List<string>();
public List<string>? ThumbS3Key { get; set; }
public string? GSTNumber { get; set; }
public int? NoOfPersons { get; set; }
public bool IsActive { get; set; } = true;
public string TenantId { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,13 @@
namespace Marco.Pms.Model.MongoDBModels.Masters
{
public class ExpensesStatusMasterMongoDB
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string? Color { get; set; }
public bool IsSystem { get; set; } = false;
public string TenantId { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,11 @@
namespace Marco.Pms.Model.MongoDBModels.Masters
{
public class ExpensesTypeMasterMongoDB
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public bool NoOfPersonsRequired { get; set; }
public string Description { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,10 @@
namespace Marco.Pms.Model.MongoDBModels.Masters
{
public class PaymentModeMatserMongoDB
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,10 @@
namespace Marco.Pms.Model.MongoDBModels.Project
{
public class ProjectBasicMongoDB
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string ShortName { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
}
}

View File

@ -1,40 +1,58 @@
using Marco.Pms.Helpers;
using Marco.Pms.Helpers.CacheHelper;
using AutoMapper;
using Marco.Pms.DataAccess.Data;
using Marco.Pms.Helpers;
using Marco.Pms.Helpers.CacheHelper;
using Marco.Pms.Model.Expenses;
using Marco.Pms.Model.Master;
using Marco.Pms.Model.MongoDBModels.Expenses;
using Marco.Pms.Model.MongoDBModels.Masters;
using Marco.Pms.Model.MongoDBModels.Project;
using Marco.Pms.Model.MongoDBModels.Utility;
using Marco.Pms.Model.Projects;
using Marco.Pms.Model.Utilities;
using MarcoBMS.Services.Service;
using Microsoft.EntityFrameworkCore;
using MongoDB.Driver;
using Project = Marco.Pms.Model.Projects.Project;
namespace Marco.Pms.Services.Helpers
{
public class CacheUpdateHelper
{
private readonly IDbContextFactory<ApplicationDbContext> _dbContextFactory;
private readonly ApplicationDbContext _context;
private readonly IMapper _mapper;
private readonly ProjectCache _projectCache;
private readonly EmployeeCache _employeeCache;
private readonly ReportCache _reportCache;
private readonly ExpenseCache _expenseCache;
private readonly ILoggingService _logger;
private readonly IDbContextFactory<ApplicationDbContext> _dbContextFactory;
private readonly ApplicationDbContext _context;
private readonly GeneralHelper _generalHelper;
private static readonly Guid Draft = Guid.Parse("297e0d8f-f668-41b5-bfea-e03b354251c8");
private static readonly Guid Rejected = Guid.Parse("d1ee5eec-24b6-4364-8673-a8f859c60729");
public CacheUpdateHelper(ProjectCache projectCache, EmployeeCache employeeCache, ReportCache reportCache, ILoggingService logger,
IDbContextFactory<ApplicationDbContext> dbContextFactory, ApplicationDbContext context, GeneralHelper generalHelper)
public CacheUpdateHelper(
IMapper mapper,
ProjectCache projectCache,
EmployeeCache employeeCache,
ReportCache reportCache,
ExpenseCache expenseCache,
ILoggingService logger,
IDbContextFactory<ApplicationDbContext> dbContextFactory,
ApplicationDbContext context,
GeneralHelper generalHelper)
{
_mapper = mapper;
_projectCache = projectCache;
_employeeCache = employeeCache;
_reportCache = reportCache;
_expenseCache = expenseCache;
_logger = logger;
_dbContextFactory = dbContextFactory;
_context = context;
_generalHelper = generalHelper;
}
// ------------------------------------ Project Details Cache ---------------------------------------
#region ======================================================= Project Details Cache =======================================================
public async Task AddProjectDetails(Project project)
{
@ -507,7 +525,9 @@ namespace Marco.Pms.Services.Helpers
}
}
// ------------------------------------ Project Infrastructure Cache ---------------------------------------
#endregion
#region ======================================================= Project Infrastructure Cache =======================================================
public async Task AddBuildngInfra(Guid projectId, Building? building = null, Floor? floor = null, WorkArea? workArea = null, Guid? buildingId = null)
{
@ -573,7 +593,9 @@ namespace Marco.Pms.Services.Helpers
}
}
// ------------------------------------------------------- WorkItem -------------------------------------------------------
#endregion
#region ======================================================= WorkItem Cache =======================================================
public async Task<List<WorkItemMongoDB>?> GetWorkItemsByWorkAreaIds(List<Guid> workAreaIds)
{
@ -680,8 +702,10 @@ namespace Marco.Pms.Services.Helpers
}
}
#endregion
#region ======================================================= Employee Profile Cache =======================================================
// ------------------------------------ Employee Profile Cache ---------------------------------------
public async Task AddApplicationRole(Guid employeeId, List<Guid> roleIds)
{
// 1. Guard Clause: Avoid unnecessary database work if there are no roles to add.
@ -839,8 +863,146 @@ namespace Marco.Pms.Services.Helpers
}
}
#endregion
// ------------------------------------ Report Cache ---------------------------------------
#region ======================================================= Expenses Cache =======================================================
public async Task AddExpenseByObjectAsync(Expenses expense)
{
var expenseCache = _mapper.Map<ExpenseDetailsMongoDB>(expense);
try
{
var billAttachments = await _context.BillAttachments
.Include(ba => ba.Document)
.AsNoTracking()
.Where(ba => ba.ExpensesId == expense.Id && ba.Document != null)
.GroupBy(ba => ba.ExpensesId)
.Select(g => new
{
S3Keys = g.Select(ba => ba.Document!.S3Key).ToList(),
ThumbS3Keys = g.Select(ba => ba.Document!.ThumbS3Key ?? ba.Document.S3Key).ToList()
})
.FirstOrDefaultAsync(); ;
if (billAttachments != null)
{
expenseCache.S3Key = billAttachments.S3Keys;
expenseCache.ThumbS3Key = billAttachments.ThumbS3Keys;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error occurd while fetched expense related tables to save in cahce");
}
try
{
await _expenseCache.AddExpenseToCacheAsync(expenseCache);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error occurd while storing expense related table in cahce");
}
}
public async Task AddExpenseByIdAsync(Guid Id, Guid tenantId)
{
var expense = await _context.Expenses.AsNoTracking().FirstOrDefaultAsync(e => e.Id == Id && e.TenantId == tenantId);
var expenseCache = _mapper.Map<ExpenseDetailsMongoDB>(expense);
if (expense != null)
{
try
{
var billAttachments = await _context.BillAttachments
.Include(ba => ba.Document)
.AsNoTracking()
.Where(ba => ba.ExpensesId == expense.Id && ba.Document != null)
.GroupBy(ba => ba.ExpensesId)
.Select(g => new
{
S3Keys = g.Select(ba => ba.Document!.S3Key).ToList(),
ThumbS3Keys = g.Select(ba => ba.Document!.ThumbS3Key ?? ba.Document.S3Key).ToList()
})
.FirstOrDefaultAsync();
if (billAttachments != null)
{
expenseCache.S3Key = billAttachments.S3Keys;
expenseCache.ThumbS3Key = billAttachments.ThumbS3Keys;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error occurd while fetched expense related tables to save in cahce");
}
}
try
{
await _expenseCache.AddExpenseToCacheAsync(expenseCache);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error occurd while storing expense related table in cahce");
}
}
public async Task AddExpensesListToCache(List<Expenses> expenses)
{
var expensesCache = _mapper.Map<List<ExpenseDetailsMongoDB>>(expenses);
var expenseIds = expenses.Select(e => e.Id).ToList();
try
{
var billAttachments = await _context.BillAttachments
.Include(ba => ba.Document)
.AsNoTracking()
.Where(ba => expenseIds.Contains(ba.ExpensesId) && ba.Document != null)
.GroupBy(ba => ba.ExpensesId)
.Select(g => new
{
ExpensesId = g.Key,
S3Keys = g.Select(ba => ba.Document!.S3Key).ToList(),
ThumbS3Keys = g.Select(ba => ba.Document!.ThumbS3Key ?? ba.Document.S3Key).ToList()
})
.ToListAsync();
foreach (var expenseCache in expensesCache)
{
expenseCache.S3Key = billAttachments.Where(ba => ba.ExpensesId == Guid.Parse(expenseCache.Id)).Select(ba => ba.S3Keys).FirstOrDefault() ?? new List<string>();
expenseCache.ThumbS3Key = billAttachments.Where(ba => ba.ExpensesId == Guid.Parse(expenseCache.Id)).Select(ba => ba.ThumbS3Keys).FirstOrDefault();
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error occurd while fetched expense related tables to save in cahce");
}
try
{
await _expenseCache.AddExpensesListToCacheAsync(expensesCache);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error occured while saving the list of expenses to Cache");
}
}
public async Task<(int totalPages, long totalCount, List<ExpenseDetailsMongoDB>? expenseList)> GetExpenseListAsync(Guid tenantId, Guid loggedInEmployeeId, bool viewAll,
bool viewSelf, int pageNumber, int pageSize, ExpensesFilter? filter)
{
try
{
var (totalPages, totalCount, expenseList) = await _expenseCache.GetExpenseListFromCacheAsync(tenantId, loggedInEmployeeId, viewAll, viewSelf, pageNumber, pageSize, filter);
if (expenseList.Any())
{
return (totalPages, totalCount, expenseList);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error occured while fetching the list of expenses to Cache");
}
return (0, 0, null);
}
#endregion
#region ======================================================= Report Cache =======================================================
public async Task<List<ProjectReportEmailMongoDB>?> GetProjectReportMail(bool IsSend)
{
@ -866,5 +1028,7 @@ namespace Marco.Pms.Services.Helpers
_logger.LogError(ex, "Error occured while adding project report mail bodys");
}
}
#endregion
}
}

View File

@ -5,6 +5,8 @@ using Marco.Pms.Model.Dtos.Project;
using Marco.Pms.Model.Employees;
using Marco.Pms.Model.Expenses;
using Marco.Pms.Model.Master;
using Marco.Pms.Model.MongoDBModels.Employees;
using Marco.Pms.Model.MongoDBModels.Expenses;
using Marco.Pms.Model.MongoDBModels.Masters;
using Marco.Pms.Model.MongoDBModels.Project;
using Marco.Pms.Model.Projects;
@ -36,6 +38,23 @@ namespace Marco.Pms.Services.MappingProfiles
opt => opt.MapFrom(src => new Guid(src.Id))
);
CreateMap<Project, ProjectBasicMongoDB>()
.ForMember(
dest => dest.Id,
// Explicitly and safely convert Guid Id to string Id
opt => opt.MapFrom(src => src.Id.ToString()))
.ForMember(
dest => dest.TenantId,
opt => opt.MapFrom(src => src.TenantId.ToString()));
CreateMap<ProjectBasicMongoDB, ProjectInfoVM>()
.ForMember(
dest => dest.Id,
opt => opt.MapFrom(src => new Guid(src.Id)))
.ForMember(
dest => dest.ProjectStatusId,
opt => opt.MapFrom(src => Guid.Empty)
);
CreateMap<ProjectMongoDB, Project>()
.ForMember(
dest => dest.Id,
@ -73,6 +92,27 @@ namespace Marco.Pms.Services.MappingProfiles
.ForMember(
dest => dest.JobRoleName,
opt => opt.MapFrom(src => src.JobRole != null ? src.JobRole.Name : ""));
CreateMap<Employee, BasicEmployeeMongoDB>()
.ForMember(
dest => dest.Id,
opt => opt.MapFrom(src => src.Id.ToString()))
.ForMember(
dest => dest.JobRoleName,
opt => opt.MapFrom(src => src.JobRole != null ? src.JobRole.Name : ""))
.ForMember(
dest => dest.JobRoleId,
opt => opt.MapFrom(src => src.JobRoleId.ToString()))
.ForMember(
dest => dest.TenantId,
opt => opt.MapFrom(src => src.TenantId.ToString()));
CreateMap<BasicEmployeeMongoDB, BasicEmployeeVM>()
.ForMember(
dest => dest.Id,
opt => opt.MapFrom(src => Guid.Parse(src.Id)))
.ForMember(
dest => dest.JobRoleId,
opt => opt.MapFrom(src => Guid.Parse(src.JobRoleId ?? "")));
#endregion
#region ======================================================= Expenses =======================================================
@ -80,6 +120,62 @@ namespace Marco.Pms.Services.MappingProfiles
CreateMap<Expenses, ExpenseList>();
CreateMap<CreateExpensesDto, Expenses>();
CreateMap<UpdateExpensesDto, Expenses>();
CreateMap<Expenses, ExpenseDetailsMongoDB>()
.ForMember(
dest => dest.Id,
opt => opt.MapFrom(src => src.Id.ToString()))
.ForMember(
dest => dest.ProjectId,
opt => opt.MapFrom(src => src.ProjectId.ToString()))
.ForMember(
dest => dest.ExpensesTypeId,
opt => opt.MapFrom(src => src.ExpensesTypeId.ToString()))
.ForMember(
dest => dest.PaymentModeId,
opt => opt.MapFrom(src => src.PaymentModeId.ToString()))
.ForMember(
dest => dest.PaidById,
opt => opt.MapFrom(src => src.PaidById.ToString()))
.ForMember(
dest => dest.CreatedById,
opt => opt.MapFrom(src => src.CreatedById.ToString()))
.ForMember(
dest => dest.StatusId,
opt => opt.MapFrom(src => src.StatusId.ToString()))
.ForMember(
dest => dest.TenantId,
opt => opt.MapFrom(src => src.TenantId.ToString()));
CreateMap<ExpenseDetailsMongoDB, Expenses>()
.ForMember(
dest => dest.Id,
opt => opt.MapFrom(src => Guid.Parse(src.Id)))
.ForMember(
dest => dest.ProjectId,
opt => opt.MapFrom(src => Guid.Parse(src.ProjectId)))
.ForMember(
dest => dest.ExpensesTypeId,
opt => opt.MapFrom(src => Guid.Parse(src.ExpensesTypeId)))
.ForMember(
dest => dest.PaymentModeId,
opt => opt.MapFrom(src => Guid.Parse(src.PaymentModeId)))
.ForMember(
dest => dest.PaidById,
opt => opt.MapFrom(src => Guid.Parse(src.PaidById)))
.ForMember(
dest => dest.CreatedById,
opt => opt.MapFrom(src => Guid.Parse(src.CreatedById)))
.ForMember(
dest => dest.StatusId,
opt => opt.MapFrom(src => Guid.Parse(src.StatusId)))
.ForMember(
dest => dest.TenantId,
opt => opt.MapFrom(src => Guid.Parse(src.TenantId)));
CreateMap<ExpenseDetailsMongoDB, ExpenseList>()
.ForMember(
dest => dest.Id,
opt => opt.MapFrom(src => Guid.Parse(src.Id)));
#endregion
@ -93,6 +189,44 @@ namespace Marco.Pms.Services.MappingProfiles
CreateMap<ExpensesTypeMaster, ExpensesTypeMasterVM>();
CreateMap<ExpensesStatusMaster, ExpensesStatusMasterVM>();
CreateMap<PaymentModeMatser, PaymentModeMatserVM>();
CreateMap<ExpensesTypeMaster, ExpensesTypeMasterMongoDB>()
.ForMember(
dest => dest.Id,
opt => opt.MapFrom(src => src.Id.ToString()))
.ForMember(
dest => dest.TenantId,
opt => opt.MapFrom(src => src.TenantId.ToString()));
CreateMap<ExpensesTypeMasterMongoDB, ExpensesTypeMasterVM>()
.ForMember(
dest => dest.Id,
opt => opt.MapFrom(src => Guid.Parse(src.Id)));
CreateMap<ExpensesStatusMaster, ExpensesStatusMasterMongoDB>()
.ForMember(
dest => dest.Id,
opt => opt.MapFrom(src => src.Id.ToString()))
.ForMember(
dest => dest.TenantId,
opt => opt.MapFrom(src => src.TenantId.ToString()));
CreateMap<ExpensesStatusMasterMongoDB, ExpensesStatusMasterVM>()
.ForMember(
dest => dest.Id,
opt => opt.MapFrom(src => Guid.Parse(src.Id)));
CreateMap<PaymentModeMatser, PaymentModeMatserMongoDB>()
.ForMember(
dest => dest.Id,
opt => opt.MapFrom(src => src.Id.ToString()))
.ForMember(
dest => dest.TenantId,
opt => opt.MapFrom(src => src.TenantId.ToString()));
CreateMap<PaymentModeMatserMongoDB, PaymentModeMatserVM>()
.ForMember(
dest => dest.Id,
opt => opt.MapFrom(src => Guid.Parse(src.Id)));
#endregion
}
}

View File

@ -1,7 +1,6 @@
using AutoMapper;
using Marco.Pms.Helpers;
using Marco.Pms.DataAccess.Data;
using Marco.Pms.Model.DocumentManager;
using Marco.Pms.Helpers;
using Marco.Pms.Model.Dtos.Expenses;
using Marco.Pms.Model.Employees;
using Marco.Pms.Model.Entitlements;
@ -12,10 +11,13 @@ using Marco.Pms.Model.ViewModels.Activities;
using Marco.Pms.Model.ViewModels.Expanses;
using Marco.Pms.Model.ViewModels.Master;
using Marco.Pms.Model.ViewModels.Projects;
using Marco.Pms.Services.Helpers;
using Marco.Pms.Services.Service.ServiceInterfaces;
using MarcoBMS.Services.Service;
using Microsoft.CodeAnalysis;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using Document = Marco.Pms.Model.DocumentManager.Document;
namespace Marco.Pms.Services.Service
{
@ -27,6 +29,7 @@ namespace Marco.Pms.Services.Service
private readonly S3UploadService _s3Service;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly UpdateLogHelper _updateLogHelper;
private readonly CacheUpdateHelper _cache;
private readonly IMapper _mapper;
private static readonly Guid Draft = Guid.Parse("297e0d8f-f668-41b5-bfea-e03b354251c8");
private static readonly Guid Rejected = Guid.Parse("d1ee5eec-24b6-4364-8673-a8f859c60729");
@ -36,6 +39,7 @@ namespace Marco.Pms.Services.Service
ApplicationDbContext context,
IServiceScopeFactory serviceScopeFactory,
UpdateLogHelper updateLogHelper,
CacheUpdateHelper cache,
ILoggingService logger,
S3UploadService s3Service,
IMapper mapper)
@ -43,6 +47,7 @@ namespace Marco.Pms.Services.Service
_dbContextFactory = dbContextFactory;
_context = context;
_logger = logger;
_cache = cache;
_serviceScopeFactory = serviceScopeFactory;
_updateLogHelper = updateLogHelper;
_s3Service = s3Service;
@ -73,7 +78,8 @@ namespace Marco.Pms.Services.Service
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 = Task.Run(async () =>
{
using var scope = _serviceScopeFactory.CreateScope();
@ -90,120 +96,103 @@ namespace Marco.Pms.Services.Service
await Task.WhenAll(hasViewSelfPermissionTask, hasViewAllPermissionTask);
// 2. --- Build Base Query and Apply Permissions ---
// Start with a base IQueryable. Filters will be chained onto this.
var expensesQuery = _context.Expenses
.Include(e => e.ExpensesType)
.Include(e => e.Project)
.Include(e => e.PaidBy)
.ThenInclude(e => e!.JobRole)
.Include(e => e.PaymentMode)
.Include(e => e.Status)
.Include(e => e.CreatedBy)
.Where(e => e.TenantId == tenantId); // Always filter by TenantId first.
// Apply permission-based filtering BEFORE any other filters or pagination.
if (hasViewAllPermissionTask.Result)
{
// User has 'View All' permission, no initial restriction on who created the expense.
_logger.LogInfo("User {EmployeeId} has 'View All' permission.", loggedInEmployeeId);
}
else if (hasViewSelfPermissionTask.Result)
{
// 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);
}
else
if (!hasViewAllPermissionTask.Result && !hasViewSelfPermissionTask.Result)
{
// User has neither required permission. Deny access.
_logger.LogWarning("Access DENIED for employee {EmployeeId} attempting to get expenses list.", loggedInEmployeeId);
return ApiResponse<object>.SuccessResponse(new List<ExpenseList>(), "You do not have permission to view any expenses.", 200);
}
// 3. --- Deserialize Filter and Apply ---
// 2. --- Deserialize Filter and Apply ---
ExpensesFilter? expenseFilter = TryDeserializeFilter(filter);
if (expenseFilter != null)
var (totalPages, totalCount, expenseList) = await _cache.GetExpenseListAsync(tenantId, loggedInEmployeeId, hasViewAllPermissionTask.Result, hasViewSelfPermissionTask.Result,
pageNumber, pageSize, expenseFilter);
if (expenseList == null)
{
// CRITICAL FIX: Apply filters cumulatively using multiple `if` statements, not `if-else if`.
if (expenseFilter.StartDate.HasValue && expenseFilter.EndDate.HasValue)
// 3. --- Build Base Query and Apply Permissions ---
// Start with a base IQueryable. Filters will be chained onto this.
var expensesQuery = _context.Expenses
.Where(e => e.TenantId == tenantId); // Always filter by TenantId first.
await _cache.AddExpensesListToCache(expenses: await expensesQuery.ToListAsync());
// Apply permission-based filtering BEFORE any other filters or pagination.
if (!hasViewAllPermissionTask.Result && hasViewSelfPermissionTask.Result)
{
expensesQuery = expensesQuery.Where(e => e.CreatedAt.Date >= expenseFilter.StartDate.Value.Date && e.CreatedAt.Date <= expenseFilter.EndDate.Value.Date);
// 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);
}
if (expenseFilter.ProjectIds?.Any() == true)
if (expenseFilter != null)
{
expensesQuery = expensesQuery.Where(e => expenseFilter.ProjectIds.Contains(e.ProjectId));
// CRITICAL FIX: Apply filters cumulatively using multiple `if` statements, not `if-else if`.
if (expenseFilter.StartDate.HasValue && expenseFilter.EndDate.HasValue)
{
expensesQuery = expensesQuery.Where(e => e.CreatedAt.Date >= expenseFilter.StartDate.Value.Date && e.CreatedAt.Date <= expenseFilter.EndDate.Value.Date);
}
if (expenseFilter.ProjectIds?.Any() == true)
{
expensesQuery = expensesQuery.Where(e => expenseFilter.ProjectIds.Contains(e.ProjectId));
}
if (expenseFilter.StatusIds?.Any() == true)
{
expensesQuery = expensesQuery.Where(e => expenseFilter.StatusIds.Contains(e.StatusId));
}
if (expenseFilter.PaidById?.Any() == true)
{
expensesQuery = expensesQuery.Where(e => expenseFilter.PaidById.Contains(e.PaidById));
}
// Only allow filtering by 'CreatedBy' if the user has 'View All' permission.
if (expenseFilter.CreatedByIds?.Any() == true && hasViewAllPermissionTask.Result)
{
expensesQuery = expensesQuery.Where(e => expenseFilter.CreatedByIds.Contains(e.CreatedById));
}
}
if (expenseFilter.StatusIds?.Any() == true)
// 4. --- Apply Ordering and Pagination ---
// This should be the last step before executing the query.
totalEntites = await expensesQuery.CountAsync();
var paginatedQuery = expensesQuery
.OrderByDescending(e => e.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize);
// 5. --- Execute Query and Map Results ---
var expensesList = await paginatedQuery.ToListAsync();
if (!expensesList.Any())
{
expensesQuery = expensesQuery.Where(e => expenseFilter.StatusIds.Contains(e.StatusId));
_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);
}
if (expenseFilter.PaidById?.Any() == true)
{
expensesQuery = expensesQuery.Where(e => expenseFilter.PaidById.Contains(e.PaidById));
}
expenseVM = await GetAllExpnesRelatedTables(expensesList);
// Only allow filtering by 'CreatedBy' if the user has 'View All' permission.
if (expenseFilter.CreatedByIds?.Any() == true && hasViewAllPermissionTask.Result)
{
expensesQuery = expensesQuery.Where(e => expenseFilter.CreatedByIds.Contains(e.CreatedById));
}
}
// 4. --- Apply Ordering and Pagination ---
// This should be the last step before executing the query.
var totalEntites = await expensesQuery.CountAsync();
var paginatedQuery = expensesQuery
.OrderByDescending(e => e.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize);
// 5. --- Execute Query and Map Results ---
var expensesList = await paginatedQuery.ToListAsync();
if (!expensesList.Any())
else
{
_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);
expenseVM = await GetAllExpnesRelatedTables(_mapper.Map<List<Expenses>>(expenseList));
totalEntites = (int)totalCount;
}
var expenseVM = _mapper.Map<List<ExpenseList>>(expensesList);
// 6. --- Efficiently Fetch and Append 'Next Status' Information ---
var statusIds = expensesList.Select(e => e.StatusId).Distinct().ToList();
var statusMappings = await _context.ExpensesStatusMapping
.Include(sm => sm.NextStatus)
.Where(sm => statusIds.Contains(sm.StatusId))
.ToListAsync();
// Use a Lookup for efficient O(1) mapping. This is much better than repeated `.Where()` in a loop.
var statusMapLookup = statusMappings.ToLookup(sm => sm.StatusId);
foreach (var expense in expenseVM)
{
if (expense.Status?.Id != null && statusMapLookup.Contains(expense.Status.Id))
{
expense.NextStatus = statusMapLookup[expense.Status.Id]
.Select(sm => _mapper.Map<ExpensesStatusMasterVM>(sm.NextStatus))
.ToList();
}
else
{
expense.NextStatus = new List<ExpensesStatusMasterVM>(); // Ensure it's never null
}
}
// 7. --- Return Final Success Response ---
var message = $"{expenseVM.Count} expense records fetched successfully.";
_logger.LogInfo(message);
var totalPages = (int)Math.Ceiling((double)totalEntites / pageSize);
var response = new
{
CurrentPage = pageNumber,
@ -211,7 +200,6 @@ namespace Marco.Pms.Services.Service
TotalEntites = totalEntites,
Data = expenseVM,
};
return ApiResponse<object>.SuccessResponse(response, message, 200);
}
catch (DbUpdateException dbEx)
@ -381,6 +369,8 @@ namespace Marco.Pms.Services.Service
// 6. Transaction Commit
await transaction.CommitAsync();
await _cache.AddExpenseByObjectAsync(expense);
var response = _mapper.Map<ExpenseList>(expense);
response.PaidBy = _mapper.Map<BasicEmployeeVM>(paidBy);
response.Project = _mapper.Map<ProjectInfoVM>(project);
@ -728,6 +718,86 @@ namespace Marco.Pms.Services.Service
}
#region =================================================================== Helper Functions ===================================================================
private async Task<List<ExpenseList>> GetAllExpnesRelatedTables(List<Expenses> model)
{
List<ExpenseList> expenseList = new List<ExpenseList>();
var projectIds = model.Select(m => m.ProjectId).ToList();
var statusIds = model.Select(m => m.StatusId).ToList();
var expensesTypeIds = model.Select(m => m.ExpensesTypeId).ToList();
var paymentModeIds = model.Select(m => m.PaymentModeId).ToList();
var createdByIds = model.Select(m => m.CreatedById).ToList();
var paidByIds = model.Select(m => m.PaidById).ToList();
var projectTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Projects.AsNoTracking().Where(p => projectIds.Contains(p.Id)).ToListAsync();
});
var paidByTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Employees.AsNoTracking().Where(e => paidByIds.Contains(e.Id)).ToListAsync();
});
var createdByTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Employees.AsNoTracking().Where(e => createdByIds.Contains(e.Id)).ToListAsync();
});
var expenseTypeTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.ExpensesTypeMaster.AsNoTracking().Where(et => expensesTypeIds.Contains(et.Id)).ToListAsync();
});
var paymentModeTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.PaymentModeMatser.AsNoTracking().Where(pm => paymentModeIds.Contains(pm.Id)).ToListAsync();
});
var statusMappingTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.ExpensesStatusMapping
.Include(s => s.Status)
.Include(s => s.NextStatus)
.AsNoTracking()
.Where(es => statusIds.Contains(es.StatusId) && es.Status != null)
.GroupBy(s => s.StatusId)
.Select(g => new
{
StatusId = g.Key,
Status = g.Select(s => s.Status).FirstOrDefault(),
NextStatus = g.Select(s => s.NextStatus).ToList()
}).ToListAsync();
});
// Await all prerequisite checks at once.
await Task.WhenAll(projectTask, expenseTypeTask, paymentModeTask, statusMappingTask, paidByTask, createdByTask);
var projects = await projectTask;
var expenseTypes = await expenseTypeTask;
var paymentModes = await paymentModeTask;
var statusMappings = await statusMappingTask;
var paidBys = await paidByTask;
var createdBys = await createdByTask;
expenseList = model.Select(m =>
{
var response = _mapper.Map<ExpenseList>(m);
response.Project = projects.Where(p => p.Id == m.ProjectId).Select(p => _mapper.Map<ProjectInfoVM>(p)).FirstOrDefault();
response.PaidBy = paidBys.Where(p => p.Id == m.PaidById).Select(p => _mapper.Map<BasicEmployeeVM>(p)).FirstOrDefault();
response.CreatedBy = createdBys.Where(e => e.Id == m.CreatedById).Select(e => _mapper.Map<BasicEmployeeVM>(e)).FirstOrDefault();
response.Status = statusMappings.Where(s => s.StatusId == m.StatusId).Select(s => _mapper.Map<ExpensesStatusMasterVM>(s.Status)).FirstOrDefault();
response.NextStatus = statusMappings.Where(s => s.StatusId == m.StatusId).Select(s => _mapper.Map<List<ExpensesStatusMasterVM>>(s.NextStatus)).FirstOrDefault();
response.PaymentMode = paymentModes.Where(pm => pm.Id == m.PaymentModeId).Select(pm => _mapper.Map<PaymentModeMatserVM>(pm)).FirstOrDefault();
response.ExpensesType = expenseTypes.Where(et => et.Id == m.ExpensesTypeId).Select(et => _mapper.Map<ExpensesTypeMasterVM>(et)).FirstOrDefault();
return response;
}).ToList();
return expenseList;
}
/// <summary>
/// Deserializes the filter string, handling multiple potential formats (e.g., direct JSON vs. escaped JSON string).
/// </summary>