From 57b7f941e61204fea4d7e85e7d1224c76f93ca67 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Wed, 16 Jul 2025 15:08:53 +0530 Subject: [PATCH] Optimized the manage task API in projectController --- Marco.Pms.CacheHelper/ProjectCache.cs | 33 +- .../{WorkItemDot.cs => WorkItemDto.cs} | 2 +- Marco.Pms.Model/Mapper/InfraMapper.cs | 2 +- .../Controllers/ProjectController.cs | 298 ++-------- .../Helpers/CacheUpdateHelper.cs | 17 +- Marco.Pms.Services/Helpers/GeneralHelper.cs | 214 +++++++ Marco.Pms.Services/Helpers/ProjectsHelper.cs | 4 +- .../MappingProfiles/MappingProfile.cs | 5 + Marco.Pms.Services/Program.cs | 1 + Marco.Pms.Services/Service/ProjectServices.cs | 547 +++++++++++++++++- .../ServiceInterfaces/IProjectServices.cs | 4 + 11 files changed, 826 insertions(+), 301 deletions(-) rename Marco.Pms.Model/Dtos/Projects/{WorkItemDot.cs => WorkItemDto.cs} (94%) create mode 100644 Marco.Pms.Services/Helpers/GeneralHelper.cs diff --git a/Marco.Pms.CacheHelper/ProjectCache.cs b/Marco.Pms.CacheHelper/ProjectCache.cs index c7d7e84..833e1a0 100644 --- a/Marco.Pms.CacheHelper/ProjectCache.cs +++ b/Marco.Pms.CacheHelper/ProjectCache.cs @@ -406,45 +406,22 @@ namespace Marco.Pms.CacheHelper return workItems; } - public async Task ManageWorkItemDetailsToCache(List workItems) + public async Task ManageWorkItemDetailsToCache(List workItems) { - var activityIds = workItems.Select(wi => wi.ActivityId).ToList(); - var workCategoryIds = workItems.Select(wi => wi.WorkCategoryId).ToList(); - var workItemIds = workItems.Select(wi => wi.Id).ToList(); - // fetching Activity master - var activities = await _context.ActivityMasters.Where(a => activityIds.Contains(a.Id)).ToListAsync() ?? new List(); - - // Fetching Work Category - var workCategories = await _context.WorkCategoryMasters.Where(wc => workCategoryIds.Contains(wc.Id)).ToListAsync() ?? new List(); - var task = await _context.TaskAllocations.Where(t => workItemIds.Contains(t.WorkItemId) && t.AssignmentDate == DateTime.UtcNow).ToListAsync(); - var todaysAssign = task.Sum(t => t.PlannedTask); - foreach (WorkItem workItem in workItems) + foreach (WorkItemMongoDB workItem in workItems) { - var activity = activities.FirstOrDefault(a => a.Id == workItem.ActivityId) ?? new ActivityMaster(); - var workCategory = workCategories.FirstOrDefault(a => a.Id == workItem.WorkCategoryId) ?? new WorkCategoryMaster(); - var filter = Builders.Filter.Eq(p => p.Id, workItem.Id.ToString()); var updates = Builders.Update.Combine( Builders.Update.Set(r => r.WorkAreaId, workItem.WorkAreaId.ToString()), Builders.Update.Set(r => r.ParentTaskId, (workItem.ParentTaskId != null ? workItem.ParentTaskId.ToString() : null)), Builders.Update.Set(r => r.PlannedWork, workItem.PlannedWork), - Builders.Update.Set(r => r.TodaysAssigned, todaysAssign), + Builders.Update.Set(r => r.TodaysAssigned, workItem.TodaysAssigned), Builders.Update.Set(r => r.CompletedWork, workItem.CompletedWork), Builders.Update.Set(r => r.Description, workItem.Description), Builders.Update.Set(r => r.TaskDate, workItem.TaskDate), Builders.Update.Set(r => r.ExpireAt, DateTime.UtcNow.Date.AddDays(1)), - Builders.Update.Set(r => r.ActivityMaster, new ActivityMasterMongoDB - { - Id = activity.Id.ToString(), - ActivityName = activity.ActivityName, - UnitOfMeasurement = activity.UnitOfMeasurement - }), - Builders.Update.Set(r => r.WorkCategoryMaster, new WorkCategoryMasterMongoDB - { - Id = workCategory.Id.ToString(), - Name = workCategory.Name, - Description = workCategory.Description, - }) + Builders.Update.Set(r => r.ActivityMaster, workItem.ActivityMaster), + Builders.Update.Set(r => r.WorkCategoryMaster, workItem.WorkCategoryMaster) ); var options = new UpdateOptions { IsUpsert = true }; var result = await _taskCollection.UpdateOneAsync(filter, updates, options); diff --git a/Marco.Pms.Model/Dtos/Projects/WorkItemDot.cs b/Marco.Pms.Model/Dtos/Projects/WorkItemDto.cs similarity index 94% rename from Marco.Pms.Model/Dtos/Projects/WorkItemDot.cs rename to Marco.Pms.Model/Dtos/Projects/WorkItemDto.cs index e6ba436..7c98051 100644 --- a/Marco.Pms.Model/Dtos/Projects/WorkItemDot.cs +++ b/Marco.Pms.Model/Dtos/Projects/WorkItemDto.cs @@ -2,7 +2,7 @@ namespace Marco.Pms.Model.Dtos.Project { - public class WorkItemDot + public class WorkItemDto { [Key] public Guid? Id { get; set; } diff --git a/Marco.Pms.Model/Mapper/InfraMapper.cs b/Marco.Pms.Model/Mapper/InfraMapper.cs index 4ccb7c8..89097d1 100644 --- a/Marco.Pms.Model/Mapper/InfraMapper.cs +++ b/Marco.Pms.Model/Mapper/InfraMapper.cs @@ -48,7 +48,7 @@ namespace Marco.Pms.Model.Mapper } public static class WorkItemMapper { - public static WorkItem ToWorkItemFromWorkItemDto(this WorkItemDot model, Guid tenantId) + public static WorkItem ToWorkItemFromWorkItemDto(this WorkItemDto model, Guid tenantId) { return new WorkItem { diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index 82ce0dd..a10fc66 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -1,9 +1,7 @@ using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Dtos.Project; using Marco.Pms.Model.Employees; -using Marco.Pms.Model.Entitlements; using Marco.Pms.Model.Mapper; -using Marco.Pms.Model.MongoDBModels; using Marco.Pms.Model.Projects; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Projects; @@ -325,188 +323,36 @@ namespace MarcoBMS.Services.Controllers [HttpGet("infra-details/{projectId}")] public async Task GetInfraDetails(Guid projectId) { - _logger.LogInfo("GetInfraDetails called for ProjectId: {ProjectId}", projectId); - - // Step 1: Get logged-in employee - var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - - // Step 2: Check project-specific permission - var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId); - if (!hasProjectPermission) + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) { - _logger.LogWarning("Project access denied for EmployeeId: {EmployeeId} on ProjectId: {ProjectId}", loggedInEmployee.Id, projectId); - return StatusCode(403, ApiResponse.ErrorResponse("Access denied", "You don't have access to this project", 403)); + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("Get Project Infrastructure by ProjectId called with invalid model state \n Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - // Step 3: Check 'ViewInfra' permission - var hasViewInfraPermission = await _permission.HasPermission(PermissionsMaster.ViewProjectInfra, loggedInEmployee.Id); - if (!hasViewInfraPermission) - { - _logger.LogWarning("ViewInfra permission denied for EmployeeId: {EmployeeId}", loggedInEmployee.Id); - return StatusCode(403, ApiResponse.ErrorResponse("Access denied", "You don't have access to view infra", 403)); - } - var result = await _cache.GetBuildingInfra(projectId); - if (result == null) - { + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.GetInfraDetailsAsync(projectId, tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); - // Step 4: Fetch buildings for the project - var buildings = await _context.Buildings - .Where(b => b.ProjectId == projectId) - .ToListAsync(); - - var buildingIds = buildings.Select(b => b.Id).ToList(); - - // Step 5: Fetch floors associated with the buildings - var floors = await _context.Floor - .Where(f => buildingIds.Contains(f.BuildingId)) - .ToListAsync(); - - var floorIds = floors.Select(f => f.Id).ToList(); - - // Step 6: Fetch work areas associated with the floors - var workAreas = await _context.WorkAreas - .Where(wa => floorIds.Contains(wa.FloorId)) - .ToListAsync(); - var workAreaIds = workAreas.Select(wa => wa.Id).ToList(); - - // Step 7: Fetch work items associated with the work area - var workItems = await _context.WorkItems - .Where(wi => workAreaIds.Contains(wi.WorkAreaId)) - .ToListAsync(); - - // Step 8: Build the infra hierarchy (Building > Floors > Work Areas) - List Buildings = new List(); - foreach (var building in buildings) - { - double buildingPlannedWorks = 0; - double buildingCompletedWorks = 0; - - var selectedFloors = floors.Where(f => f.BuildingId == building.Id).ToList(); - List Floors = new List(); - foreach (var floor in selectedFloors) - { - double floorPlannedWorks = 0; - double floorCompletedWorks = 0; - var selectedWorkAreas = workAreas.Where(wa => wa.FloorId == floor.Id).ToList(); - List WorkAreas = new List(); - foreach (var workArea in selectedWorkAreas) - { - double workAreaPlannedWorks = 0; - double workAreaCompletedWorks = 0; - var selectedWorkItems = workItems.Where(wi => wi.WorkAreaId == workArea.Id).ToList(); - foreach (var workItem in selectedWorkItems) - { - workAreaPlannedWorks += workItem.PlannedWork; - workAreaCompletedWorks += workItem.CompletedWork; - } - WorkAreaMongoDB workAreaMongo = new WorkAreaMongoDB - { - Id = workArea.Id.ToString(), - AreaName = workArea.AreaName, - PlannedWork = workAreaPlannedWorks, - CompletedWork = workAreaCompletedWorks - }; - WorkAreas.Add(workAreaMongo); - floorPlannedWorks += workAreaPlannedWorks; - floorCompletedWorks += workAreaCompletedWorks; - } - FloorMongoDB floorMongoDB = new FloorMongoDB - { - Id = floor.Id.ToString(), - FloorName = floor.FloorName, - PlannedWork = floorPlannedWorks, - CompletedWork = floorCompletedWorks, - WorkAreas = WorkAreas - }; - Floors.Add(floorMongoDB); - buildingPlannedWorks += floorPlannedWorks; - buildingCompletedWorks += floorCompletedWorks; - } - - var buildingMongo = new BuildingMongoDB - { - Id = building.Id.ToString(), - BuildingName = building.Name, - Description = building.Description, - PlannedWork = buildingPlannedWorks, - CompletedWork = buildingCompletedWorks, - Floors = Floors - }; - Buildings.Add(buildingMongo); - } - result = Buildings; - } - - _logger.LogInfo("Infra details fetched successfully for ProjectId: {ProjectId}, EmployeeId: {EmployeeId}, Buildings: {Count}", - projectId, loggedInEmployee.Id, result.Count); - - return Ok(ApiResponse.SuccessResponse(result, "Infra details fetched successfully", 200)); } [HttpGet("tasks/{workAreaId}")] public async Task GetWorkItems(Guid workAreaId) { - _logger.LogInfo("GetWorkItems called for WorkAreaId: {WorkAreaId}", workAreaId); - - // Step 1: Get the currently logged-in employee - var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - - // Step 2: Check if the employee has ViewInfra permission - var hasViewInfraPermission = await _permission.HasPermission(PermissionsMaster.ViewProjectInfra, loggedInEmployee.Id); - if (!hasViewInfraPermission) + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) { - _logger.LogWarning("ViewInfra permission denied for EmployeeId: {EmployeeId}", loggedInEmployee.Id); - return StatusCode(403, ApiResponse.ErrorResponse("Access denied", "You don't have permission to view infrastructure", 403)); + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("Get Work Items by WorkAreaId called with invalid model state \n Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - // Step 3: Check if the specified Work Area exists - var isWorkAreaExist = await _context.WorkAreas.AnyAsync(wa => wa.Id == workAreaId); - if (!isWorkAreaExist) - { - _logger.LogWarning("Work Area not found for WorkAreaId: {WorkAreaId}", workAreaId); - return NotFound(ApiResponse.ErrorResponse("Work Area not found", "Work Area not found in database", 404)); - } - - // Step 4: Fetch WorkItems with related Activity and Work Category data - var workItemVMs = await _cache.GetWorkItemDetailsByWorkArea(workAreaId); - if (workItemVMs == null) - { - var workItems = await _context.WorkItems - .Include(wi => wi.ActivityMaster) - .Include(wi => wi.WorkCategoryMaster) - .Where(wi => wi.WorkAreaId == workAreaId) - .ToListAsync(); - - workItemVMs = workItems.Select(wi => new WorkItemMongoDB - { - Id = wi.Id.ToString(), - WorkAreaId = wi.WorkAreaId.ToString(), - ParentTaskId = wi.ParentTaskId.ToString(), - ActivityMaster = new ActivityMasterMongoDB - { - Id = wi.ActivityId.ToString(), - ActivityName = wi.ActivityMaster != null ? wi.ActivityMaster.ActivityName : null, - UnitOfMeasurement = wi.ActivityMaster != null ? wi.ActivityMaster.UnitOfMeasurement : null - }, - WorkCategoryMaster = new WorkCategoryMasterMongoDB - { - Id = wi.WorkCategoryId.ToString() ?? "", - Name = wi.WorkCategoryMaster != null ? wi.WorkCategoryMaster.Name : "", - Description = wi.WorkCategoryMaster != null ? wi.WorkCategoryMaster.Description : "" - }, - PlannedWork = wi.PlannedWork, - CompletedWork = wi.CompletedWork, - Description = wi.Description, - TaskDate = wi.TaskDate, - }).ToList(); - - await _cache.ManageWorkItemDetails(workItems); - } - - _logger.LogInfo("{Count} work items fetched successfully for WorkAreaId: {WorkAreaId}", workItemVMs.Count, workAreaId); - - // Step 5: Return result - return Ok(ApiResponse.SuccessResponse(workItemVMs, $"{workItemVMs.Count} records of tasks fetched successfully", 200)); + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.GetWorkItemsAsync(workAreaId, tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } #endregion @@ -514,107 +360,29 @@ namespace MarcoBMS.Services.Controllers #region =================================================================== Project Infrastructre Manage APIs =================================================================== [HttpPost("task")] - public async Task CreateProjectTask(List workItemDtos) + public async Task CreateProjectTask([FromBody] List workItemDtos) { - _logger.LogInfo("CreateProjectTask called with {Count} items", workItemDtos?.Count ?? 0); - - // Validate request - if (workItemDtos == null || !workItemDtos.Any()) + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) { - _logger.LogWarning("No work items provided in the request."); - return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "Work Item details are not valid.", 400)); + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("project Alocation called with invalid model state for list of projects. Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - var workItemsToCreate = new List(); - var workItemsToUpdate = new List(); - var responseList = new List(); - var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - string message = ""; - List workAreaIds = new List(); - var workItemIds = workItemDtos.Where(wi => wi.Id != null && wi.Id != Guid.Empty).Select(wi => wi.Id).ToList(); - var workItems = await _context.WorkItems.AsNoTracking().Where(wi => workItemIds.Contains(wi.Id)).ToListAsync(); - - foreach (var itemDto in workItemDtos) + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.CreateProjectTaskAsync(workItemDtos, tenantId, loggedInEmployee); + if (response.Success) { - var workItem = itemDto.ToWorkItemFromWorkItemDto(tenantId); - var workArea = await _context.WorkAreas.Include(a => a.Floor).FirstOrDefaultAsync(a => a.Id == workItem.WorkAreaId) ?? new WorkArea(); - - Building building = await _context.Buildings.FirstOrDefaultAsync(b => b.Id == (workArea.Floor != null ? workArea.Floor.BuildingId : Guid.Empty)) ?? new Building(); - - if (itemDto.Id != null && itemDto.Id != Guid.Empty) - { - // Update existing - workItemsToUpdate.Add(workItem); - message = $"Task Updated in Building: {building.Name}, on Floor: {workArea.Floor?.FloorName}, in Area: {workArea.AreaName} by {LoggedInEmployee.FirstName} {LoggedInEmployee.LastName}"; - var existingWorkItem = workItems.FirstOrDefault(wi => wi.Id == workItem.Id); - double plannedWork = 0; - double completedWork = 0; - if (existingWorkItem != null) - { - if (existingWorkItem.PlannedWork != workItem.PlannedWork && existingWorkItem.CompletedWork != workItem.CompletedWork) - { - plannedWork = workItem.PlannedWork - existingWorkItem.PlannedWork; - completedWork = workItem.CompletedWork - existingWorkItem.CompletedWork; - } - else if (existingWorkItem.PlannedWork == workItem.PlannedWork && existingWorkItem.CompletedWork != workItem.CompletedWork) - { - plannedWork = 0; - completedWork = workItem.CompletedWork - existingWorkItem.CompletedWork; - } - else if (existingWorkItem.PlannedWork != workItem.PlannedWork && existingWorkItem.CompletedWork == workItem.CompletedWork) - { - plannedWork = workItem.PlannedWork - existingWorkItem.PlannedWork; - completedWork = 0; - } - await _cache.UpdatePlannedAndCompleteWorksInBuilding(workArea.Id, plannedWork, completedWork); - } - } - else - { - // Create new - workItem.Id = Guid.NewGuid(); - workItemsToCreate.Add(workItem); - message = $"Task Added in Building: {building.Name}, on Floor: {workArea.Floor?.FloorName}, in Area: {workArea.AreaName} by {LoggedInEmployee.FirstName} {LoggedInEmployee.LastName}"; - await _cache.UpdatePlannedAndCompleteWorksInBuilding(workArea.Id, workItem.PlannedWork, workItem.CompletedWork); - } - - responseList.Add(new WorkItemVM - { - WorkItemId = workItem.Id, - WorkItem = workItem - }); - workAreaIds.Add(workItem.WorkAreaId); + List workAreaIds = response.Data.Select(pa => pa.WorkItem?.WorkAreaId ?? Guid.Empty).ToList(); + string message = response.Message; + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "WorkItem", WorkAreaIds = workAreaIds, Message = message }; + await _signalR.SendNotificationAsync(notification); } - string responseMessage = ""; - // Apply DB changes - if (workItemsToCreate.Any()) - { - _logger.LogInfo("Adding {Count} new work items", workItemsToCreate.Count); - await _context.WorkItems.AddRangeAsync(workItemsToCreate); - responseMessage = "Task Added Successfully"; - await _cache.ManageWorkItemDetails(workItemsToCreate); - } + return StatusCode(response.StatusCode, response); - if (workItemsToUpdate.Any()) - { - _logger.LogInfo("Updating {Count} existing work items", workItemsToUpdate.Count); - _context.WorkItems.UpdateRange(workItemsToUpdate); - responseMessage = "Task Updated Successfully"; - await _cache.ManageWorkItemDetails(workItemsToUpdate); - } - - await _context.SaveChangesAsync(); - - _logger.LogInfo("CreateProjectTask completed successfully. Created: {Created}, Updated: {Updated}", workItemsToCreate.Count, workItemsToUpdate.Count); - - - - var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "WorkItem", WorkAreaIds = workAreaIds, Message = message }; - - await _signalR.SendNotificationAsync(notification); - - return Ok(ApiResponse.SuccessResponse(responseList, responseMessage, 200)); } [HttpDelete("task/{id}")] diff --git a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs index aca439b..9a01b83 100644 --- a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs +++ b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs @@ -17,9 +17,10 @@ namespace Marco.Pms.Services.Helpers private readonly ILoggingService _logger; private readonly IDbContextFactory _dbContextFactory; private readonly ApplicationDbContext _context; + private readonly GeneralHelper _generalHelper; public CacheUpdateHelper(ProjectCache projectCache, EmployeeCache employeeCache, ReportCache reportCache, ILoggingService logger, - IDbContextFactory dbContextFactory, ApplicationDbContext context) + IDbContextFactory dbContextFactory, ApplicationDbContext context, GeneralHelper generalHelper) { _projectCache = projectCache; _employeeCache = employeeCache; @@ -27,6 +28,7 @@ namespace Marco.Pms.Services.Helpers _logger = logger; _dbContextFactory = dbContextFactory; _context = context; + _generalHelper = generalHelper; } // ------------------------------------ Project Details Cache --------------------------------------- @@ -563,6 +565,19 @@ namespace Marco.Pms.Services.Helpers } } public async Task ManageWorkItemDetails(List workItems) + { + try + { + var workAreaId = workItems.First().WorkAreaId; + var workItemDB = await _generalHelper.GetWorkItemsListFromDB(workAreaId); + await _projectCache.ManageWorkItemDetailsToCache(workItemDB); + } + catch (Exception ex) + { + _logger.LogWarning("Error occured while saving workItems form Cache: {Error}", ex.Message); + } + } + public async Task ManageWorkItemDetailsByVM(List workItems) { try { diff --git a/Marco.Pms.Services/Helpers/GeneralHelper.cs b/Marco.Pms.Services/Helpers/GeneralHelper.cs new file mode 100644 index 0000000..c2f8fe4 --- /dev/null +++ b/Marco.Pms.Services/Helpers/GeneralHelper.cs @@ -0,0 +1,214 @@ +using Marco.Pms.DataAccess.Data; +using Marco.Pms.Model.MongoDBModels; +using MarcoBMS.Services.Service; +using Microsoft.EntityFrameworkCore; + +namespace Marco.Pms.Services.Helpers +{ + public class GeneralHelper + { + private readonly IDbContextFactory _dbContextFactory; + private readonly ApplicationDbContext _context; // Keeping this for direct scoped context use where appropriate + private readonly ILoggingService _logger; + public GeneralHelper(IDbContextFactory dbContextFactory, + ApplicationDbContext context, + ILoggingService logger) + { + _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); + _context = context ?? throw new ArgumentNullException(nameof(context)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + public async Task> GetProjectInfraFromDB(Guid projectId) + { + // Each task uses its own DbContext instance for thread safety. Projections are used for efficiency. + + // Task to fetch Buildings, Floors, and WorkAreas using projections + var hierarchyTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + var buildings = await context.Buildings.AsNoTracking().Where(b => b.ProjectId == projectId).Select(b => new { b.Id, b.Name, b.Description }).ToListAsync(); + var buildingIds = buildings.Select(b => b.Id).ToList(); + var floors = await context.Floor.AsNoTracking().Where(f => buildingIds.Contains(f.BuildingId)).Select(f => new { f.Id, f.BuildingId, f.FloorName }).ToListAsync(); + var floorIds = floors.Select(f => f.Id).ToList(); + var workAreas = await context.WorkAreas.AsNoTracking().Where(wa => floorIds.Contains(wa.FloorId)).Select(wa => new { wa.Id, wa.FloorId, wa.AreaName }).ToListAsync(); + return (buildings, floors, workAreas); + }); + + // Task to get work summaries, AGGREGATED ON THE DATABASE SERVER + var workSummaryTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + // This is the most powerful optimization. It avoids pulling all WorkItem rows. + return await context.WorkItems.AsNoTracking() + .Where(wi => wi.WorkArea != null && wi.WorkArea.Floor != null && wi.WorkArea.Floor.Building != null && wi.WorkArea.Floor.Building.ProjectId == projectId) + .GroupBy(wi => wi.WorkAreaId) // Group by the parent WorkArea + .Select(g => new + { + WorkAreaId = g.Key, + PlannedWork = g.Sum(i => i.PlannedWork), + CompletedWork = g.Sum(i => i.CompletedWork) + }) + .ToDictionaryAsync(x => x.WorkAreaId); // Return a ready-to-use dictionary for fast lookups + }); + + await Task.WhenAll(hierarchyTask, workSummaryTask); + + var (buildings, floors, workAreas) = await hierarchyTask; + var workSummariesByWorkAreaId = await workSummaryTask; + + // --- Step 4: Build the hierarchy efficiently using Lookups --- + // Using lookups is much faster (O(1)) than repeated .Where() calls (O(n)). + var floorsByBuildingId = floors.ToLookup(f => f.BuildingId); + var workAreasByFloorId = workAreas.ToLookup(wa => wa.FloorId); + + var buildingMongoList = new List(); + foreach (var building in buildings) + { + double buildingPlanned = 0, buildingCompleted = 0; + var floorMongoList = new List(); + + foreach (var floor in floorsByBuildingId[building.Id]) // Fast lookup + { + double floorPlanned = 0, floorCompleted = 0; + var workAreaMongoList = new List(); + + foreach (var workArea in workAreasByFloorId[floor.Id]) // Fast lookup + { + // Get the pre-calculated summary from the dictionary. O(1) operation. + workSummariesByWorkAreaId.TryGetValue(workArea.Id, out var summary); + var waPlanned = summary?.PlannedWork ?? 0; + var waCompleted = summary?.CompletedWork ?? 0; + + workAreaMongoList.Add(new WorkAreaMongoDB + { + Id = workArea.Id.ToString(), + AreaName = workArea.AreaName, + PlannedWork = waPlanned, + CompletedWork = waCompleted + }); + + floorPlanned += waPlanned; + floorCompleted += waCompleted; + } + + floorMongoList.Add(new FloorMongoDB + { + Id = floor.Id.ToString(), + FloorName = floor.FloorName, + PlannedWork = floorPlanned, + CompletedWork = floorCompleted, + WorkAreas = workAreaMongoList + }); + + buildingPlanned += floorPlanned; + buildingCompleted += floorCompleted; + } + + buildingMongoList.Add(new BuildingMongoDB + { + Id = building.Id.ToString(), + BuildingName = building.Name, + Description = building.Description, + PlannedWork = buildingPlanned, + CompletedWork = buildingCompleted, + Floors = floorMongoList + }); + } + return buildingMongoList; + } + + /// + /// Retrieves a list of work items for a specific work area, including a summary of tasks assigned for the current day. + /// This method is highly optimized to run database operations in parallel and perform aggregations on the server. + /// + /// The ID of the work area. + /// A list of WorkItemMongoDB objects with calculated daily assignments. + public async Task> GetWorkItemsListFromDB(Guid workAreaId) + { + _logger.LogInfo("Fetching DB work items for WorkAreaId: {WorkAreaId}", workAreaId); + + try + { + // --- Step 1: Run independent database queries in PARALLEL --- + // We can fetch the WorkItems and the aggregated TaskAllocations at the same time. + + // Task 1: Fetch the WorkItem entities and their related data. + var workItemsTask = _context.WorkItems + .Include(wi => wi.ActivityMaster) + .Include(wi => wi.WorkCategoryMaster) + .Where(wi => wi.WorkAreaId == workAreaId) + .AsNoTracking() + .ToListAsync(); + + // Task 2: Fetch and AGGREGATE today's task allocations ON THE DATABASE SERVER. + var todaysAssignmentsTask = Task.Run(async () => + { + // Correctly define "today's" date range to avoid precision issues. + var today = DateTime.UtcNow.Date; + var tomorrow = today.AddDays(1); + + using var context = _dbContextFactory.CreateDbContext(); // Use a factory for thread safety + + // This is the most powerful optimization: + // 1. It filters by WorkAreaId directly, making it independent of the first query. + // 2. It filters by a correct date range. + // 3. It groups and sums on the DB server, returning only a small summary. + return await context.TaskAllocations + .Where(t => t.WorkItem != null && t.WorkItem.WorkAreaId == workAreaId && + t.AssignmentDate >= today && t.AssignmentDate < tomorrow) + .GroupBy(t => t.WorkItemId) + .Select(g => new + { + WorkItemId = g.Key, + TodaysAssigned = g.Sum(x => x.PlannedTask) + }) + // Return a dictionary for instant O(1) lookups later. + .ToDictionaryAsync(x => x.WorkItemId, x => x.TodaysAssigned); + }); + + // Await both parallel database operations to complete. + await Task.WhenAll(workItemsTask, todaysAssignmentsTask); + + // Retrieve the results from the completed tasks. + var workItemsFromDb = await workItemsTask; + var todaysAssignments = await todaysAssignmentsTask; + + // --- Step 2: Map to the ViewModel/MongoDB model efficiently --- + var workItemVMs = workItemsFromDb.Select(wi => new WorkItemMongoDB + { + Id = wi.Id.ToString(), + WorkAreaId = wi.WorkAreaId.ToString(), + ParentTaskId = wi.ParentTaskId.ToString(), + ActivityMaster = wi.ActivityMaster != null ? new ActivityMasterMongoDB + { + Id = wi.ActivityMaster.Id.ToString(), + ActivityName = wi.ActivityMaster.ActivityName, + UnitOfMeasurement = wi.ActivityMaster.UnitOfMeasurement + } : null, + WorkCategoryMaster = wi.WorkCategoryMaster != null ? new WorkCategoryMasterMongoDB + { + Id = wi.WorkCategoryMaster.Id.ToString(), + Name = wi.WorkCategoryMaster.Name, + Description = wi.WorkCategoryMaster.Description + } : null, + PlannedWork = wi.PlannedWork, + CompletedWork = wi.CompletedWork, + Description = wi.Description, + TaskDate = wi.TaskDate, + // Use the fast dictionary lookup instead of the slow in-memory Where/Sum. + TodaysAssigned = todaysAssignments.GetValueOrDefault(wi.Id, 0) + }).ToList(); + + _logger.LogInfo("Successfully processed {WorkItemCount} work items for WorkAreaId: {WorkAreaId}", workItemVMs.Count, workAreaId); + + return workItemVMs; + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while fetching DB work items for WorkAreaId: {WorkAreaId}", workAreaId); + // Return an empty list or re-throw, depending on your application's error handling strategy. + return new List(); + } + } + } +} diff --git a/Marco.Pms.Services/Helpers/ProjectsHelper.cs b/Marco.Pms.Services/Helpers/ProjectsHelper.cs index fe70a0a..e7e1dd6 100644 --- a/Marco.Pms.Services/Helpers/ProjectsHelper.cs +++ b/Marco.Pms.Services/Helpers/ProjectsHelper.cs @@ -11,14 +11,12 @@ namespace MarcoBMS.Services.Helpers public class ProjectsHelper { private readonly ApplicationDbContext _context; - private readonly RolesHelper _rolesHelper; private readonly CacheUpdateHelper _cache; private readonly PermissionServices _permission; - public ProjectsHelper(ApplicationDbContext context, RolesHelper rolesHelper, CacheUpdateHelper cache, PermissionServices permission) + public ProjectsHelper(ApplicationDbContext context, CacheUpdateHelper cache, PermissionServices permission) { _context = context; - _rolesHelper = rolesHelper; _cache = cache; _permission = permission; } diff --git a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs index ea42d16..50d2ea9 100644 --- a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs @@ -50,6 +50,11 @@ namespace Marco.Pms.Services.MappingProfiles opt => opt.MapFrom(src => src.EmpID)); CreateMap(); CreateMap(); + + CreateMap() + .ForMember( + dest => dest.Description, + opt => opt.MapFrom(src => src.Comment)); #endregion #region ======================================================= Projects ======================================================= diff --git a/Marco.Pms.Services/Program.cs b/Marco.Pms.Services/Program.cs index 26d8eba..3c73416 100644 --- a/Marco.Pms.Services/Program.cs +++ b/Marco.Pms.Services/Program.cs @@ -163,6 +163,7 @@ builder.Services.AddScoped(); #endregion #region Helpers +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Marco.Pms.Services/Service/ProjectServices.cs b/Marco.Pms.Services/Service/ProjectServices.cs index 9024112..6d811fc 100644 --- a/Marco.Pms.Services/Service/ProjectServices.cs +++ b/Marco.Pms.Services/Service/ProjectServices.cs @@ -29,6 +29,7 @@ namespace Marco.Pms.Services.Service private readonly PermissionServices _permission; private readonly CacheUpdateHelper _cache; private readonly IMapper _mapper; + private readonly GeneralHelper _generalHelper; public ProjectServices( IDbContextFactory dbContextFactory, ApplicationDbContext context, @@ -36,7 +37,8 @@ namespace Marco.Pms.Services.Service ProjectsHelper projectsHelper, PermissionServices permission, CacheUpdateHelper cache, - IMapper mapper) + IMapper mapper, + GeneralHelper generalHelper) { _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); _context = context ?? throw new ArgumentNullException(nameof(context)); @@ -45,6 +47,7 @@ namespace Marco.Pms.Services.Service _permission = permission ?? throw new ArgumentNullException(nameof(permission)); _cache = cache ?? throw new ArgumentNullException(nameof(cache)); _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); + _generalHelper = generalHelper ?? throw new ArgumentNullException(nameof(generalHelper)); } #region =================================================================== Project Get APIs =================================================================== @@ -898,6 +901,525 @@ namespace Marco.Pms.Services.Service #endregion + #region =================================================================== Project InfraStructure Get APIs =================================================================== + + /// + /// Retrieves the full infrastructure hierarchy (Buildings, Floors, Work Areas) for a project, + /// including aggregated work summaries. + /// + public async Task> GetInfraDetailsAsync(Guid projectId, Guid tenantId, Employee loggedInEmployee) + { + _logger.LogInfo("GetInfraDetails called for ProjectId: {ProjectId}", projectId); + + try + { + // --- Step 1: Run independent permission checks in PARALLEL --- + var projectPermissionTask = _permission.HasProjectPermission(loggedInEmployee, projectId); + var viewInfraPermissionTask = _permission.HasPermission(PermissionsMaster.ViewProjectInfra, loggedInEmployee.Id); + + await Task.WhenAll(projectPermissionTask, viewInfraPermissionTask); + + if (!await projectPermissionTask) + { + _logger.LogWarning("Project access denied for EmployeeId: {EmployeeId} on ProjectId: {ProjectId}", loggedInEmployee.Id, projectId); + return ApiResponse.ErrorResponse("Access denied", "You don't have access to this project", 403); + } + if (!await viewInfraPermissionTask) + { + _logger.LogWarning("ViewInfra permission denied for EmployeeId: {EmployeeId}", loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Access denied", "You don't have access to view this project's infrastructure", 403); + } + + // --- Step 2: Cache-First Strategy --- + var cachedResult = await _cache.GetBuildingInfra(projectId); + if (cachedResult != null) + { + _logger.LogInfo("Cache HIT for infra details for ProjectId: {ProjectId}", projectId); + return ApiResponse.SuccessResponse(cachedResult, "Infra details fetched successfully from cache.", 200); + } + + _logger.LogInfo("Cache MISS for infra details for ProjectId: {ProjectId}. Fetching from database.", projectId); + + // --- Step 3: Fetch all required data from the database --- + + var buildingMongoList = await _generalHelper.GetProjectInfraFromDB(projectId); + // --- Step 5: Proactively update the cache --- + //await _cache.SetBuildingInfra(projectId, buildingMongoList); + + _logger.LogInfo("Infra details fetched successfully for ProjectId: {ProjectId}, Buildings: {Count}", projectId, buildingMongoList.Count); + return ApiResponse.SuccessResponse(buildingMongoList, "Infra details fetched successfully", 200); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while fetching infra details for ProjectId: {ProjectId}", projectId); + return ApiResponse.ErrorResponse("An internal server error occurred.", "An error occurred while processing your request.", 500); + } + } + + /// + /// Retrieves a list of work items for a specific work area, ensuring the user has appropriate permissions. + /// + /// The ID of the work area. + /// The ID of the current tenant. + /// The current authenticated employee for permission checks. + /// An ApiResponse containing a list of work items or an error. + public async Task> GetWorkItemsAsync(Guid workAreaId, Guid tenantId, Employee loggedInEmployee) + { + _logger.LogInfo("GetWorkItems called for WorkAreaId: {WorkAreaId} by User: {UserId}", workAreaId, loggedInEmployee.Id); + + try + { + // --- Step 1: Cache-First Strategy --- + var cachedWorkItems = await _cache.GetWorkItemDetailsByWorkArea(workAreaId); + if (cachedWorkItems != null) + { + _logger.LogInfo("Cache HIT for WorkAreaId: {WorkAreaId}. Returning {Count} items from cache.", workAreaId, cachedWorkItems.Count); + return ApiResponse.SuccessResponse(cachedWorkItems, $"{cachedWorkItems.Count} tasks retrieved successfully from cache.", 200); + } + + _logger.LogInfo("Cache MISS for WorkAreaId: {WorkAreaId}. Fetching from database.", workAreaId); + + // --- Step 2: Security Check First --- + // This pattern remains the most robust: verify permissions before fetching a large list. + var projectInfo = await _context.WorkAreas + .Where(wa => wa.Id == workAreaId && wa.TenantId == tenantId && wa.Floor != null && wa.Floor.Building != null) + .Select(wa => new { wa.Floor!.Building!.ProjectId }) + .FirstOrDefaultAsync(); + + if (projectInfo == null) + { + _logger.LogWarning("Work Area not found for WorkAreaId: {WorkAreaId}", workAreaId); + return ApiResponse.ErrorResponse("Not Found", $"Work Area with ID {workAreaId} not found.", 404); + } + + var hasProjectAccess = await _permission.HasProjectPermission(loggedInEmployee, projectInfo.ProjectId); + var hasGenericViewInfraPermission = await _permission.HasPermission(PermissionsMaster.ViewProjectInfra, loggedInEmployee.Id); + + if (!hasProjectAccess || !hasGenericViewInfraPermission) + { + _logger.LogWarning("Access DENIED for user {UserId} on WorkAreaId {WorkAreaId}.", loggedInEmployee.Id, workAreaId); + return ApiResponse.ErrorResponse("Access Denied", "You do not have sufficient permissions to view these work items.", 403); + } + + // --- Step 3: Fetch Full Entities for Caching and Mapping --- + var workItemVMs = await _generalHelper.GetWorkItemsListFromDB(workAreaId); + + // --- Step 5: Proactively Update the Cache with the Correct Object Type --- + // We now pass the 'workItemsFromDb' list, which is the required List. + + try + { + await _cache.ManageWorkItemDetailsByVM(workItemVMs); + _logger.LogInfo("Successfully queued cache update for WorkAreaId: {WorkAreaId}", workAreaId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Background cache update failed for WorkAreaId: {WorkAreaId}", workAreaId); + } + + + _logger.LogInfo("{Count} work items fetched successfully for WorkAreaId: {WorkAreaId}", workItemVMs.Count, workAreaId); + return ApiResponse.SuccessResponse(workItemVMs, $"{workItemVMs.Count} tasks fetched successfully.", 200); + } + catch (Exception ex) + { + // --- Step 6: Graceful Error Handling --- + _logger.LogError(ex, "An unexpected error occurred while getting work items for WorkAreaId: {WorkAreaId}", workAreaId); + return ApiResponse.ErrorResponse("An internal server error occurred.", null, 500); + } + } + + #endregion + + #region =================================================================== Project Infrastructre Manage APIs =================================================================== + + public async Task> CreateProjectTask1(List workItemDtos, Guid tenantId, Employee loggedInEmployee) + { + _logger.LogInfo("CreateProjectTask called with {Count} items", workItemDtos?.Count ?? 0); + + // Validate request + if (workItemDtos == null || !workItemDtos.Any()) + { + _logger.LogWarning("No work items provided in the request."); + return ApiResponse.ErrorResponse("Invalid details.", "Work Item details are not valid.", 400); + } + + var workItemsToCreate = new List(); + var workItemsToUpdate = new List(); + var responseList = new List(); + string message = ""; + List workAreaIds = new List(); + var workItemIds = workItemDtos.Where(wi => wi.Id != null && wi.Id != Guid.Empty).Select(wi => wi.Id).ToList(); + var workItems = await _context.WorkItems.AsNoTracking().Where(wi => workItemIds.Contains(wi.Id)).ToListAsync(); + + foreach (var itemDto in workItemDtos) + { + var workItem = _mapper.Map(itemDto); + workItem.TenantId = tenantId; + var workArea = await _context.WorkAreas.Include(a => a.Floor).FirstOrDefaultAsync(a => a.Id == workItem.WorkAreaId) ?? new WorkArea(); + + Building building = await _context.Buildings.FirstOrDefaultAsync(b => b.Id == (workArea.Floor != null ? workArea.Floor.BuildingId : Guid.Empty)) ?? new Building(); + + if (itemDto.Id != null && itemDto.Id != Guid.Empty) + { + // Update existing + workItemsToUpdate.Add(workItem); + message = $"Task Updated in Building: {building.Name}, on Floor: {workArea.Floor?.FloorName}, in Area: {workArea.AreaName} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; + var existingWorkItem = workItems.FirstOrDefault(wi => wi.Id == workItem.Id); + if (existingWorkItem != null) + { + double plannedWork = workItem.PlannedWork - existingWorkItem.PlannedWork; + double completedWork = workItem.CompletedWork - existingWorkItem.CompletedWork; + await _cache.UpdatePlannedAndCompleteWorksInBuilding(workArea.Id, plannedWork, completedWork); + } + } + else + { + // Create new + workItem.Id = Guid.NewGuid(); + workItemsToCreate.Add(workItem); + message = $"Task Added in Building: {building.Name}, on Floor: {workArea.Floor?.FloorName}, in Area: {workArea.AreaName} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; + await _cache.UpdatePlannedAndCompleteWorksInBuilding(workArea.Id, workItem.PlannedWork, workItem.CompletedWork); + } + + responseList.Add(new WorkItemVM + { + WorkItemId = workItem.Id, + WorkItem = workItem + }); + workAreaIds.Add(workItem.WorkAreaId); + + } + // Apply DB changes + if (workItemsToCreate.Any()) + { + _logger.LogInfo("Adding {Count} new work items", workItemsToCreate.Count); + await _context.WorkItems.AddRangeAsync(workItemsToCreate); + await _cache.ManageWorkItemDetails(workItemsToCreate); + } + + if (workItemsToUpdate.Any()) + { + _logger.LogInfo("Updating {Count} existing work items", workItemsToUpdate.Count); + _context.WorkItems.UpdateRange(workItemsToUpdate); + await _cache.ManageWorkItemDetails(workItemsToUpdate); + } + + await _context.SaveChangesAsync(); + + _logger.LogInfo("CreateProjectTask completed successfully. Created: {Created}, Updated: {Updated}", workItemsToCreate.Count, workItemsToUpdate.Count); + + return ApiResponse.SuccessResponse(responseList, message, 200); + } + + /// + /// Creates or updates a batch of work items. + /// This method is optimized to perform all database operations in a single, atomic transaction. + /// + public async Task>> CreateProjectTaskAsync(List workItemDtos, Guid tenantId, Employee loggedInEmployee) + { + _logger.LogInfo("CreateProjectTask called with {Count} items by user {UserId}", workItemDtos?.Count ?? 0, loggedInEmployee.Id); + + // --- Step 1: Input Validation --- + if (workItemDtos == null || !workItemDtos.Any()) + { + _logger.LogWarning("No work items provided in the request."); + return ApiResponse>.ErrorResponse("Invalid details.", "Work Item details list cannot be empty.", 400); + } + + // --- Step 2: Fetch all required existing data in bulk --- + var workAreaIds = workItemDtos.Select(d => d.WorkAreaID).Distinct().ToList(); + var workItemIdsToUpdate = workItemDtos.Where(d => d.Id.HasValue).Select(d => d.Id!.Value).ToList(); + + // Fetch all relevant WorkAreas and their parent hierarchy in ONE query + var workAreasFromDb = await _context.WorkAreas + .Where(wa => wa.Floor != null && wa.Floor.Building != null && workAreaIds.Contains(wa.Id) && wa.TenantId == tenantId) + .Include(wa => wa.Floor!.Building) // Eagerly load the entire path + .ToDictionaryAsync(wa => wa.Id); // Dictionary for fast lookups + + // Fetch all existing WorkItems that need updating in ONE query + var existingWorkItemsToUpdate = await _context.WorkItems + .Where(wi => workItemIdsToUpdate.Contains(wi.Id) && wi.TenantId == tenantId) + .ToDictionaryAsync(wi => wi.Id); // Dictionary for fast lookups + + // --- (Placeholder) Security Check --- + // You MUST verify the user has permission to modify ALL WorkAreas in the batch. + var projectIdsInBatch = workAreasFromDb.Values.Select(wa => wa.Floor!.Building!.ProjectId).Distinct(); + var hasPermission = await _permission.HasPermission(PermissionsMaster.ManageProjectInfra, loggedInEmployee.Id); + if (!hasPermission) + { + _logger.LogWarning("Access DENIED for user {UserId} trying to create/update tasks.", loggedInEmployee.Id); + return ApiResponse>.ErrorResponse("Access Denied.", "You do not have permission to modify tasks in one or more of the specified work areas.", 403); + } + + var workItemsToCreate = new List(); + var workItemsToModify = new List(); + var workDeltaForCache = new Dictionary(); // WorkAreaId -> (Delta) + string message = ""; + + // --- Step 3: Process all logic IN MEMORY, tracking changes --- + foreach (var dto in workItemDtos) + { + if (!workAreasFromDb.TryGetValue(dto.WorkAreaID, out var workArea)) + { + _logger.LogWarning("Skipping item because WorkAreaId {WorkAreaId} was not found or is invalid.", dto.WorkAreaID); + continue; // Skip this item as its parent WorkArea is invalid + } + + if (dto.Id.HasValue && existingWorkItemsToUpdate.TryGetValue(dto.Id.Value, out var existingWorkItem)) + { + // --- UPDATE Logic --- + var plannedDelta = dto.PlannedWork - existingWorkItem.PlannedWork; + var completedDelta = dto.CompletedWork - existingWorkItem.CompletedWork; + + // Apply changes from DTO to the fetched entity to prevent data loss + _mapper.Map(dto, existingWorkItem); + workItemsToModify.Add(existingWorkItem); + + // Track the change in work for cache update + workDeltaForCache[workArea.Id] = ( + workDeltaForCache.GetValueOrDefault(workArea.Id).Planned + plannedDelta, + workDeltaForCache.GetValueOrDefault(workArea.Id).Completed + completedDelta + ); + message = $"Task Updated in Building: {workArea.Floor?.Building?.Name}, on Floor: {workArea.Floor?.FloorName}, in Area: {workArea.AreaName} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; + } + else + { + // --- CREATE Logic --- + var newWorkItem = _mapper.Map(dto); + newWorkItem.Id = Guid.NewGuid(); // Ensure new GUID is set + newWorkItem.TenantId = tenantId; + workItemsToCreate.Add(newWorkItem); + + // Track the change in work for cache update + workDeltaForCache[workArea.Id] = ( + workDeltaForCache.GetValueOrDefault(workArea.Id).Planned + newWorkItem.PlannedWork, + workDeltaForCache.GetValueOrDefault(workArea.Id).Completed + newWorkItem.CompletedWork + ); + message = $"Task Added in Building: {workArea.Floor?.Building?.Name}, on Floor: {workArea.Floor?.FloorName}, in Area: {workArea.AreaName} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; + } + } + + try + { + // --- Step 4: Save all database changes in a SINGLE TRANSACTION --- + if (workItemsToCreate.Any()) _context.WorkItems.AddRange(workItemsToCreate); + if (workItemsToModify.Any()) _context.WorkItems.UpdateRange(workItemsToModify); // EF Core handles individual updates correctly here + + if (workItemsToCreate.Any() || workItemsToModify.Any()) + { + await _context.SaveChangesAsync(); + _logger.LogInfo("Successfully saved {CreatedCount} new and {UpdatedCount} updated work items.", workItemsToCreate.Count, workItemsToModify.Count); + + // --- Step 5: Update Cache and SignalR AFTER successful DB save (non-blocking) --- + var allAffectedItems = workItemsToCreate.Concat(workItemsToModify).ToList(); + _ = Task.Run(async () => + { + await UpdateCacheAndNotify(workDeltaForCache, allAffectedItems); + }); + } + } + catch (DbUpdateException ex) + { + _logger.LogError(ex, "A database error occurred while creating/updating tasks."); + return ApiResponse>.ErrorResponse("Database Error", "Failed to save changes.", 500); + } + + // --- Step 6: Prepare and return the response --- + var allProcessedItems = workItemsToCreate.Concat(workItemsToModify).ToList(); + var responseList = allProcessedItems.Select(wi => new WorkItemVM + { + WorkItemId = wi.Id, + WorkItem = wi + }).ToList(); + + + return ApiResponse>.SuccessResponse(responseList, message, 200); + } + + + //public async Task DeleteProjectTask(Guid id) + //{ + // var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + // List workAreaIds = new List(); + // WorkItem? task = await _context.WorkItems.AsNoTracking().Include(t => t.WorkArea).FirstOrDefaultAsync(t => t.Id == id && t.TenantId == tenantId); + // if (task != null) + // { + // if (task.CompletedWork == 0) + // { + // var assignedTask = await _context.TaskAllocations.Where(t => t.WorkItemId == id).ToListAsync(); + // if (assignedTask.Count == 0) + // { + // _context.WorkItems.Remove(task); + // await _context.SaveChangesAsync(); + // _logger.LogInfo("Task with ID {WorkItemId} has been successfully deleted.", task.Id); + + // var floorId = task.WorkArea?.FloorId; + // var floor = await _context.Floor.Include(f => f.Building).FirstOrDefaultAsync(f => f.Id == floorId); + + + // workAreaIds.Add(task.WorkAreaId); + // var projectId = floor?.Building?.ProjectId; + + // var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "WorkItem", WorkAreaIds = workAreaIds, Message = $"Task Deleted in Building: {floor?.Building?.Name}, on Floor: {floor?.FloorName}, in Area: {task.WorkArea?.AreaName} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}" }; + // await _signalR.SendNotificationAsync(notification); + // await _cache.DeleteWorkItemByIdAsync(task.Id); + // if (projectId != null) + // { + // await _cache.DeleteProjectByIdAsync(projectId.Value); + // } + // } + // else + // { + // _logger.LogWarning("Task with ID {WorkItemId} is currently assigned and cannot be deleted.", task.Id); + // return BadRequest(ApiResponse.ErrorResponse("Task is currently assigned and cannot be deleted.", "Task is currently assigned and cannot be deleted.", 400)); + // } + // } + // else + // { + // double percentage = (task.CompletedWork / task.PlannedWork) * 100; + // percentage = Math.Round(percentage, 2); + // _logger.LogWarning("Task with ID {WorkItemId} is {CompletionPercentage}% complete and cannot be deleted", task.Id, percentage); + // return BadRequest(ApiResponse.ErrorResponse(System.String.Format("Task is {0}% complete and cannot be deleted", percentage), System.String.Format("Task is {0}% complete and cannot be deleted", percentage), 400)); + + // } + // } + // else + // { + // _logger.LogWarning("Task with ID {WorkItemId} not found ID database", id); + // } + // return Ok(ApiResponse.SuccessResponse(new { }, "Task deleted successfully", 200)); + //} + + //public async Task ManageProjectInfra(List infraDots) + //{ + // var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + + // var responseData = new InfraVM { }; + // string responseMessage = ""; + // string message = ""; + // List projectIds = new List(); + // if (infraDots != null) + // { + // foreach (var item in infraDots) + // { + // if (item.Building != null) + // { + + // Building building = item.Building.ToBuildingFromBuildingDto(tenantId); + // building.TenantId = tenantId; + + // if (item.Building.Id == null) + // { + // //create + // _context.Buildings.Add(building); + // await _context.SaveChangesAsync(); + // responseData.building = building; + // responseMessage = "Buliding Added Successfully"; + // message = "Building Added"; + // await _cache.AddBuildngInfra(building.ProjectId, building); + // } + // else + // { + // //update + // _context.Buildings.Update(building); + // await _context.SaveChangesAsync(); + // responseData.building = building; + // responseMessage = "Buliding Updated Successfully"; + // message = "Building Updated"; + // await _cache.UpdateBuildngInfra(building.ProjectId, building); + // } + // projectIds.Add(building.ProjectId); + // } + // if (item.Floor != null) + // { + // Floor floor = item.Floor.ToFloorFromFloorDto(tenantId); + // floor.TenantId = tenantId; + // bool isCreated = false; + + // if (item.Floor.Id == null) + // { + // //create + // _context.Floor.Add(floor); + // await _context.SaveChangesAsync(); + // responseData.floor = floor; + // responseMessage = "Floor Added Successfully"; + // message = "Floor Added"; + // isCreated = true; + // } + // else + // { + // //update + // _context.Floor.Update(floor); + // await _context.SaveChangesAsync(); + // responseData.floor = floor; + // responseMessage = "Floor Updated Successfully"; + // message = "Floor Updated"; + // } + // Building? building = await _context.Buildings.FirstOrDefaultAsync(b => b.Id == floor.BuildingId); + // var projectId = building?.ProjectId ?? Guid.Empty; + // projectIds.Add(projectId); + // message = $"{message} in Building: {building?.Name}"; + // if (isCreated) + // { + // await _cache.AddBuildngInfra(projectId, floor: floor); + // } + // else + // { + // await _cache.UpdateBuildngInfra(projectId, floor: floor); + // } + // } + // if (item.WorkArea != null) + // { + // WorkArea workArea = item.WorkArea.ToWorkAreaFromWorkAreaDto(tenantId); + // workArea.TenantId = tenantId; + // bool isCreated = false; + + // if (item.WorkArea.Id == null) + // { + // //create + // _context.WorkAreas.Add(workArea); + // await _context.SaveChangesAsync(); + // responseData.workArea = workArea; + // responseMessage = "Work Area Added Successfully"; + // message = "Work Area Added"; + // isCreated = true; + // } + // else + // { + // //update + // _context.WorkAreas.Update(workArea); + // await _context.SaveChangesAsync(); + // responseData.workArea = workArea; + // responseMessage = "Work Area Updated Successfully"; + // message = "Work Area Updated"; + // } + // Floor? floor = await _context.Floor.Include(f => f.Building).FirstOrDefaultAsync(f => f.Id == workArea.FloorId); + // var projectId = floor?.Building?.ProjectId ?? Guid.Empty; + // projectIds.Add(projectId); + // message = $"{message} in Building: {floor?.Building?.Name}, on Floor: {floor?.FloorName}"; + // if (isCreated) + // { + // await _cache.AddBuildngInfra(projectId, workArea: workArea, buildingId: floor?.BuildingId); + // } + // else + // { + // await _cache.UpdateBuildngInfra(projectId, workArea: workArea, buildingId: floor?.BuildingId); + // } + // } + // } + // message = $"{message} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; + // var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Infra", ProjectIds = projectIds, Message = message }; + + // await _signalR.SendNotificationAsync(notification); + // return Ok(ApiResponse.SuccessResponse(responseData, responseMessage, 200)); + // } + // return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "Infra Details are not valid.", 400)); + + //} + + #endregion + #region =================================================================== Helper Functions =================================================================== /// @@ -1101,7 +1623,6 @@ namespace Marco.Pms.Services.Service return dbProject; } - // Helper method for background cache update private async Task UpdateCacheInBackground(Project project) { try @@ -1120,6 +1641,28 @@ namespace Marco.Pms.Services.Service } } + private async Task UpdateCacheAndNotify(Dictionary workDelta, List affectedItems) + { + try + { + // Update planned/completed work totals + var cacheUpdateTasks = workDelta.Select(kvp => + _cache.UpdatePlannedAndCompleteWorksInBuilding(kvp.Key, kvp.Value.Planned, kvp.Value.Completed)); + await Task.WhenAll(cacheUpdateTasks); + _logger.LogInfo("Background cache work totals update completed for {AreaCount} areas.", workDelta.Count); + + // Update the details of the individual work items in the cache + await _cache.ManageWorkItemDetails(affectedItems); + _logger.LogInfo("Background cache work item details update completed for {ItemCount} items.", affectedItems.Count); + + // Add SignalR notification logic here if needed + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred during background cache update/notification."); + } + } + #endregion } } diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs index bafa582..2db004d 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs @@ -19,5 +19,9 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces Task>> ManageAllocationAsync(List projectAllocationDots, Guid tenantId, Employee loggedInEmployee); Task> GetProjectsByEmployeeAsync(Guid employeeId, Guid tenantId, Employee loggedInEmployee); Task>> AssigneProjectsToEmployeeAsync(List projectAllocationDtos, Guid employeeId, Guid tenantId, Employee loggedInEmployee); + Task> GetInfraDetailsAsync(Guid projectId, Guid tenantId, Employee loggedInEmployee); + Task> GetWorkItemsAsync(Guid workAreaId, Guid tenantId, Employee loggedInEmployee); + Task>> CreateProjectTaskAsync(List workItemDtos, Guid tenantId, Employee loggedInEmployee); + } }