diff --git a/Marco.Pms.Helpers/CacheHelper/EmployeeCache.cs b/Marco.Pms.Helpers/CacheHelper/EmployeeCache.cs index 560748c..3e08484 100644 --- a/Marco.Pms.Helpers/CacheHelper/EmployeeCache.cs +++ b/Marco.Pms.Helpers/CacheHelper/EmployeeCache.cs @@ -27,6 +27,7 @@ namespace Marco.Pms.Helpers.CacheHelper var update = Builders.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.Filter.Eq(e => e.Id, employeeId.ToString()); var update = Builders.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.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(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); } } diff --git a/Marco.Pms.Helpers/CacheHelper/ExpenseCache.cs b/Marco.Pms.Helpers/CacheHelper/ExpenseCache.cs index 8036c5f..5e63178 100644 --- a/Marco.Pms.Helpers/CacheHelper/ExpenseCache.cs +++ b/Marco.Pms.Helpers/CacheHelper/ExpenseCache.cs @@ -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 _collection; + private readonly IMongoCollection _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("Expenses"); + _collection = mongoDB.GetCollection("Expenses"); } - public async Task AddExpenseToCacheAsync() + public async Task AddExpenseToCacheAsync(ExpenseDetailsMongoDB expense) { + await _collection.InsertOneAsync(expense); + await InitializeCollectionAsync(); + } + public async Task AddExpensesListToCacheAsync(List 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 expenseList)> GetExpenseListFromCacheAsync(Guid tenantId, Guid loggedInEmployeeId, bool viewAll, + bool viewSelf, int pageNumber, int pageSize, ExpensesFilter? expenseFilter) + { + var filterBuilder = Builders.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.IndexKeys.Ascending(x => x.ExpireAt); + var indexOptions = new CreateIndexOptions + { + ExpireAfter = TimeSpan.Zero // required for fixed expiration time + }; + var indexModel = new CreateIndexModel(indexKeys, indexOptions); + await _collection.Indexes.CreateOneAsync(indexModel); } } } diff --git a/Marco.Pms.Helpers/CacheHelper/ProjectCache.cs b/Marco.Pms.Helpers/CacheHelper/ProjectCache.cs index f5721aa..bee37af 100644 --- a/Marco.Pms.Helpers/CacheHelper/ProjectCache.cs +++ b/Marco.Pms.Helpers/CacheHelper/ProjectCache.cs @@ -30,13 +30,7 @@ namespace Marco.Pms.Helpers { await _projectCollection.InsertOneAsync(projectDetails); - var indexKeys = Builders.IndexKeys.Ascending(x => x.ExpireAt); - var indexOptions = new CreateIndexOptions - { - ExpireAfter = TimeSpan.Zero // required for fixed expiration time - }; - var indexModel = new CreateIndexModel(indexKeys, indexOptions); - await _projectCollection.Indexes.CreateOneAsync(indexModel); + await InitializeCollectionAsync(); } public async Task AddProjectDetailsListToCache(List 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.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(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 UpdateProjectDetailsOnlyToCache(Project project, StatusMaster projectStatus) diff --git a/Marco.Pms.Model/Dtos/Projects/ProjectAllocationDot.cs b/Marco.Pms.Model/Dtos/Projects/ProjectAllocationDot.cs index 7a1fd91..0805e62 100644 --- a/Marco.Pms.Model/Dtos/Projects/ProjectAllocationDot.cs +++ b/Marco.Pms.Model/Dtos/Projects/ProjectAllocationDot.cs @@ -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; } } } diff --git a/Marco.Pms.Model/MongoDBModels/Employees/BasicEmployeeMongoDB.cs b/Marco.Pms.Model/MongoDBModels/Employees/BasicEmployeeMongoDB.cs new file mode 100644 index 0000000..bff2e62 --- /dev/null +++ b/Marco.Pms.Model/MongoDBModels/Employees/BasicEmployeeMongoDB.cs @@ -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; + } +} diff --git a/Marco.Pms.Model/MongoDBModels/Expenses/ExpenseDetailsMongoDB.cs b/Marco.Pms.Model/MongoDBModels/Expenses/ExpenseDetailsMongoDB.cs index e4a1c5c..c0ffdd9 100644 --- a/Marco.Pms.Model/MongoDBModels/Expenses/ExpenseDetailsMongoDB.cs +++ b/Marco.Pms.Model/MongoDBModels/Expenses/ExpenseDetailsMongoDB.cs @@ -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 S3Key { get; set; } = new List(); + public List? 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; } } diff --git a/Marco.Pms.Model/MongoDBModels/Masters/ExpensesStatusMasterMongoDB.cs b/Marco.Pms.Model/MongoDBModels/Masters/ExpensesStatusMasterMongoDB.cs new file mode 100644 index 0000000..8fe3910 --- /dev/null +++ b/Marco.Pms.Model/MongoDBModels/Masters/ExpensesStatusMasterMongoDB.cs @@ -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; + } +} diff --git a/Marco.Pms.Model/MongoDBModels/Masters/ExpensesTypeMasterMongoDB.cs b/Marco.Pms.Model/MongoDBModels/Masters/ExpensesTypeMasterMongoDB.cs new file mode 100644 index 0000000..d4b80ad --- /dev/null +++ b/Marco.Pms.Model/MongoDBModels/Masters/ExpensesTypeMasterMongoDB.cs @@ -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; + } +} diff --git a/Marco.Pms.Model/MongoDBModels/Masters/PaymentModeMatserMongoDB.cs b/Marco.Pms.Model/MongoDBModels/Masters/PaymentModeMatserMongoDB.cs new file mode 100644 index 0000000..0d7a74b --- /dev/null +++ b/Marco.Pms.Model/MongoDBModels/Masters/PaymentModeMatserMongoDB.cs @@ -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; + } +} diff --git a/Marco.Pms.Model/MongoDBModels/Project/ProjectBasicMongoDB.cs b/Marco.Pms.Model/MongoDBModels/Project/ProjectBasicMongoDB.cs new file mode 100644 index 0000000..4d2bd6b --- /dev/null +++ b/Marco.Pms.Model/MongoDBModels/Project/ProjectBasicMongoDB.cs @@ -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; + } +} diff --git a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs index 1c4deb0..bbec308 100644 --- a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs +++ b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs @@ -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 _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 _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 dbContextFactory, ApplicationDbContext context, GeneralHelper generalHelper) + public CacheUpdateHelper( + IMapper mapper, + ProjectCache projectCache, + EmployeeCache employeeCache, + ReportCache reportCache, + ExpenseCache expenseCache, + ILoggingService logger, + IDbContextFactory 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?> GetWorkItemsByWorkAreaIds(List 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 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(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(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) + { + var expensesCache = _mapper.Map>(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(); + 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? 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?> GetProjectReportMail(bool IsSend) { @@ -866,5 +1028,7 @@ namespace Marco.Pms.Services.Helpers _logger.LogError(ex, "Error occured while adding project report mail bodys"); } } + + #endregion } } diff --git a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs index 33c64bb..6e4ca8e 100644 --- a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs @@ -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() + .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() + .ForMember( + dest => dest.Id, + opt => opt.MapFrom(src => new Guid(src.Id))) + .ForMember( + dest => dest.ProjectStatusId, + opt => opt.MapFrom(src => Guid.Empty) + ); + CreateMap() .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() + .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() + .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(); CreateMap(); CreateMap(); + CreateMap() + .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() + .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() + .ForMember( + dest => dest.Id, + opt => opt.MapFrom(src => Guid.Parse(src.Id))); #endregion @@ -93,6 +189,44 @@ namespace Marco.Pms.Services.MappingProfiles CreateMap(); CreateMap(); CreateMap(); + + CreateMap() + .ForMember( + dest => dest.Id, + opt => opt.MapFrom(src => src.Id.ToString())) + .ForMember( + dest => dest.TenantId, + opt => opt.MapFrom(src => src.TenantId.ToString())); + CreateMap() + .ForMember( + dest => dest.Id, + opt => opt.MapFrom(src => Guid.Parse(src.Id))); + + CreateMap() + .ForMember( + dest => dest.Id, + opt => opt.MapFrom(src => src.Id.ToString())) + .ForMember( + dest => dest.TenantId, + opt => opt.MapFrom(src => src.TenantId.ToString())); + CreateMap() + .ForMember( + dest => dest.Id, + opt => opt.MapFrom(src => Guid.Parse(src.Id))); + + CreateMap() + .ForMember( + dest => dest.Id, + opt => opt.MapFrom(src => src.Id.ToString())) + .ForMember( + dest => dest.TenantId, + opt => opt.MapFrom(src => src.TenantId.ToString())); + CreateMap() + .ForMember( + dest => dest.Id, + opt => opt.MapFrom(src => Guid.Parse(src.Id))); + + #endregion } } diff --git a/Marco.Pms.Services/Service/ExpensesService.cs b/Marco.Pms.Services/Service/ExpensesService.cs index 26f3b21..d55b915 100644 --- a/Marco.Pms.Services/Service/ExpensesService.cs +++ b/Marco.Pms.Services/Service/ExpensesService.cs @@ -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.ErrorResponse("User not found or not authenticated.", 403); } Guid loggedInEmployeeId = loggedInEmployee.Id; - + List expenseVM = new List(); + 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.SuccessResponse(new List(), "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.SuccessResponse(new List(), "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.SuccessResponse(new List(), "No expenses found for the given criteria.", 200); + expenseVM = await GetAllExpnesRelatedTables(_mapper.Map>(expenseList)); + totalEntites = (int)totalCount; } - - var expenseVM = _mapper.Map>(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(sm.NextStatus)) - .ToList(); - } - else - { - expense.NextStatus = new List(); // 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.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(expense); response.PaidBy = _mapper.Map(paidBy); response.Project = _mapper.Map(project); @@ -728,6 +718,86 @@ namespace Marco.Pms.Services.Service } #region =================================================================== Helper Functions =================================================================== + private async Task> GetAllExpnesRelatedTables(List model) + { + List expenseList = new List(); + 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(m); + + response.Project = projects.Where(p => p.Id == m.ProjectId).Select(p => _mapper.Map(p)).FirstOrDefault(); + response.PaidBy = paidBys.Where(p => p.Id == m.PaidById).Select(p => _mapper.Map(p)).FirstOrDefault(); + response.CreatedBy = createdBys.Where(e => e.Id == m.CreatedById).Select(e => _mapper.Map(e)).FirstOrDefault(); + response.Status = statusMappings.Where(s => s.StatusId == m.StatusId).Select(s => _mapper.Map(s.Status)).FirstOrDefault(); + response.NextStatus = statusMappings.Where(s => s.StatusId == m.StatusId).Select(s => _mapper.Map>(s.NextStatus)).FirstOrDefault(); + response.PaymentMode = paymentModes.Where(pm => pm.Id == m.PaymentModeId).Select(pm => _mapper.Map(pm)).FirstOrDefault(); + response.ExpensesType = expenseTypes.Where(et => et.Id == m.ExpensesTypeId).Select(et => _mapper.Map(et)).FirstOrDefault(); + + return response; + }).ToList(); + + return expenseList; + } + /// /// Deserializes the filter string, handling multiple potential formats (e.g., direct JSON vs. escaped JSON string). ///