using AutoMapper; using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Dtos.Attendance; using Marco.Pms.Model.Entitlements; using Marco.Pms.Model.Expenses; using Marco.Pms.Model.OrganizationModel; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Activities; using Marco.Pms.Model.ViewModels.AttendanceVM; using Marco.Pms.Model.ViewModels.DashBoard; using Marco.Pms.Model.ViewModels.Organization; using Marco.Pms.Model.ViewModels.Projects; using Marco.Pms.Services.Service; using Marco.Pms.Services.Service.ServiceInterfaces; using MarcoBMS.Services.Helpers; using MarcoBMS.Services.Service; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.Globalization; namespace Marco.Pms.Services.Controllers { [Authorize] [Route("api/[controller]")] [ApiController] public class DashboardController : ControllerBase { private readonly IDbContextFactory _dbContextFactory; private readonly ApplicationDbContext _context; private readonly UserHelper _userHelper; private readonly IProjectServices _projectServices; private readonly ILoggingService _logger; private readonly IServiceScopeFactory _serviceScopeFactory; private readonly IMapper _mapper; public static readonly Guid ActiveId = Guid.Parse("b74da4c2-d07e-46f2-9919-e75e49b12731"); private static readonly Guid Draft = Guid.Parse("297e0d8f-f668-41b5-bfea-e03b354251c8"); private static readonly Guid Review = Guid.Parse("6537018f-f4e9-4cb3-a210-6c3b2da999d7"); private static readonly Guid Approve = Guid.Parse("4068007f-c92f-4f37-a907-bc15fe57d4d8"); private static readonly Guid ProcessPending = Guid.Parse("f18c5cfd-7815-4341-8da2-2c2d65778e27"); private static readonly Guid Processed = Guid.Parse("61578360-3a49-4c34-8604-7b35a3787b95"); private static readonly Guid RejectedByReviewer = Guid.Parse("965eda62-7907-4963-b4a1-657fb0b2724b"); private static readonly Guid RejectedByApprover = Guid.Parse("d1ee5eec-24b6-4364-8673-a8f859c60729"); private readonly Guid tenantId; public DashboardController(ApplicationDbContext context, UserHelper userHelper, IProjectServices projectServices, IServiceScopeFactory serviceScopeFactory, ILoggingService logger, IMapper mapper, IDbContextFactory dbContextFactory) { _context = context; _userHelper = userHelper; _projectServices = projectServices; _logger = logger; _serviceScopeFactory = serviceScopeFactory; _mapper = mapper; tenantId = userHelper.GetTenantId(); _dbContextFactory = dbContextFactory; } /// /// Fetches project progression data (planned and completed tasks) in graph form for a tenant and specified (or all) projects over a date range. /// /// Number of days back to fetch data for /// Starting date for the graph, or today if null /// Optionally, the project to filter on [HttpGet("progression")] public async Task GetGraphAsync([FromQuery] double days, [FromQuery] string FromDate, [FromQuery] Guid? projectId) { if (tenantId == Guid.Empty) { _logger.LogWarning("Invalid request: TenantId is empty on progression endpoint"); return BadRequest(ApiResponse.ErrorResponse("Invalid TenantId", "Provided Invalid TenantId", 400)); } var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // Step 1: Parse and validate incoming date, fallback to today if null. if (!string.IsNullOrWhiteSpace(FromDate) && !DateTime.TryParse(FromDate, out DateTime fromDate)) { _logger.LogWarning("Invalid starting date provided for progression graph by employee {EmployeeId}", loggedInEmployee.Id); return BadRequest(ApiResponse.ErrorResponse("Invalid starting date.", "Invalid starting date.", 400)); } DateTime fromDateValue = string.IsNullOrWhiteSpace(FromDate) ? DateTime.UtcNow.Date : DateTime.Parse(FromDate); // Step 2: Get earliest task date for tenant, fallback to today var firstTask = await _context.TaskAllocations .Where(t => t.TenantId == tenantId) .OrderBy(t => t.AssignmentDate) .Select(t => t.AssignmentDate) .FirstOrDefaultAsync(); if (firstTask == default) firstTask = DateTime.UtcNow; // Step 3: Determine toDate (the oldest date in range) double negativeDays = -Math.Abs(days); DateTime toDate = fromDateValue.AddDays(negativeDays); if (firstTask.Date >= toDate.Date) toDate = firstTask; var projectProgressionVMs = new List(); // Step 4: Query depends on whether filtering for specific project if (projectId == null) { // All projects for the tenant in range var tasks = await _context.TaskAllocations .Where(t => t.TenantId == tenantId && t.AssignmentDate.Date <= fromDateValue.Date && t.AssignmentDate.Date >= toDate.Date) .ToListAsync(); for (double flagDays = 0; negativeDays < flagDays; flagDays -= 1) { var date = fromDateValue.AddDays(flagDays); if (date >= firstTask.Date) { var todayTasks = tasks.Where(t => t.AssignmentDate.Date == date.Date); projectProgressionVMs.Add(new ProjectProgressionVM { ProjectId = Guid.Empty, ProjectName = "", Date = date, PlannedTask = todayTasks.Sum(t => t.PlannedTask), CompletedTask = todayTasks.Sum(t => t.CompletedTask), }); } } _logger.LogInfo("Project progression report for all projects fetched successfully by employee {EmployeeId}", loggedInEmployee.Id); } else { // Specified project: Fetch hierarchical data efficiently var project = await _context.Projects.FirstOrDefaultAsync(p => p.Id == projectId && p.TenantId == tenantId); if (project == null) { _logger.LogWarning("Project not found. ProjectId: {ProjectId}, TenantId: {TenantId}, Requested by: {EmployeeId}", projectId, tenantId, loggedInEmployee.Id); return NotFound(ApiResponse.ErrorResponse("Project not found", "Specified project not found", 404)); } var tasks = await _context.TaskAllocations .Include(t => t.WorkItem) .ThenInclude(wi => wi!.WorkArea) .ThenInclude(wa => wa!.Floor) .ThenInclude(f => f!.Building) .Where(t => t.WorkItem != null && t.WorkItem.WorkArea != null && t.WorkItem.WorkArea.Floor != null && t.WorkItem.WorkArea.Floor.Building != null && t.WorkItem.WorkArea.Floor.Building.ProjectId == projectId && t.TenantId == tenantId && t.AssignmentDate.Date <= fromDateValue.Date && t.AssignmentDate.Date >= toDate.Date) .ToListAsync(); for (double flagDays = 0; negativeDays < flagDays; flagDays -= 1) { var date = fromDateValue.AddDays(flagDays); if (date >= firstTask.Date) { var todayTasks = tasks.Where(t => t.AssignmentDate.Date == date.Date); projectProgressionVMs.Add(new ProjectProgressionVM { ProjectId = projectId.Value, ProjectName = project.Name, Date = date, PlannedTask = todayTasks.Sum(t => t.PlannedTask), CompletedTask = todayTasks.Sum(t => t.CompletedTask), }); } } _logger.LogInfo("Project progression report for project {ProjectId} fetched successfully by employee {EmployeeId}", projectId, loggedInEmployee.Id); } return Ok(ApiResponse.SuccessResponse(projectProgressionVMs, "Project progression data fetched successfully.", 200)); } /// /// Gets the count of total and ongoing projects for the current tenant, /// using properly optimized queries and structured logging. /// [HttpGet("projects")] public async Task GetProjectCountAsync() { // Step 1: Tenant validation (defensive coding for multi-tenancy) if (tenantId == Guid.Empty) { _logger.LogWarning("Project count request denied: Empty TenantId."); return BadRequest(ApiResponse.ErrorResponse("Invalid TenantId", "Provided Invalid TenantId", 400)); } // Step 2: Get logged-in employee for logging/auditing purposes var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // Step 3: Fetch relevant status IDs only (query optimized for DB filtering) var projectStatusIds = await _context.StatusMasters .Where(s => s.Status == "Active" || s.Status == "In Progress") .Select(s => s.Id) .ToListAsync(); // Step 4: Query total and ongoing project counts directly (avoid .ToList()) var totalProjectsCount = await _context.Projects .CountAsync(p => p.TenantId == tenantId); var ongoingProjectsCount = await _context.Projects .CountAsync(p => p.TenantId == tenantId && projectStatusIds.Contains(p.ProjectStatusId)); var dashboardVM = new ProjectDashboardVM { TotalProjects = totalProjectsCount, OngoingProjects = ongoingProjectsCount }; _logger.LogInfo("Fetched project counts: {TotalProjects} total, {OngoingProjects} ongoing. Employee: {EmployeeId}, TenantId: {TenantId}", totalProjectsCount, ongoingProjectsCount, loggedInEmployee.Id, tenantId); return Ok(ApiResponse.SuccessResponse(dashboardVM, "Project counts fetched successfully.", 200)); } [HttpGet("project-completion-status")] public async Task GetAllProjectsAsync() { var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); var response = await _projectServices.GetAllProjectsAsync(string.Empty, 0, 0, loggedInEmployee, tenantId); return StatusCode(response.StatusCode, response); } /// /// Retrieves a dashboard summary of total employees and today's attendance. /// If a projectId is provided, it returns totals for that project; otherwise, for all accessible active projects. /// /// Optional. The ID of a specific project to get totals for. [HttpGet("teams")] public async Task GetTotalEmployees([FromQuery] Guid? projectId) { try { if (tenantId == Guid.Empty) { _logger.LogWarning("Invalid request: TenantId is empty on progression endpoint"); return BadRequest(ApiResponse.ErrorResponse("Invalid TenantId", "Provided Invalid TenantId", 400)); } var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); _logger.LogInfo("GetTotalEmployees called by user {UserId} for ProjectId: {ProjectId}", loggedInEmployee.Id, projectId ?? Guid.Empty); // --- Step 1: Get the list of projects the user can access --- // This query is more efficient as it only selects the IDs needed. var projects = await _projectServices.GetMyProjectIdsAsync(loggedInEmployee, tenantId); var accessibleActiveProjectIds = await _context.Projects .Where(p => p.ProjectStatusId == ActiveId && projects.Contains(p.Id)) .Select(p => p.Id) .ToListAsync(); if (!accessibleActiveProjectIds.Any()) { _logger.LogInfo("User {UserId} has no accessible active projects.", loggedInEmployee.Id); return Ok(ApiResponse.SuccessResponse(new TeamDashboardVM(), "No accessible active projects found.", 200)); } // --- Step 2: Build the list of project IDs to query against --- List finalProjectIds; if (projectId.HasValue) { using var scope = _serviceScopeFactory.CreateScope(); var _permission = scope.ServiceProvider.GetRequiredService(); // Security Check: Ensure the requested project is in the user's accessible list. var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId.Value); if (!hasPermission) { _logger.LogWarning("Access DENIED for user {UserId} on project {ProjectId} (not active or not accessible).", loggedInEmployee.Id, projectId.Value); return StatusCode(403, ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to view this project, or it is not active.", 403)); } finalProjectIds = new List { projectId.Value }; } else { finalProjectIds = accessibleActiveProjectIds; } // --- Step 3: Run efficient aggregation queries SEQUENTIALLY --- // Since we only have one DbContext instance, we await each query one by one. // Query 1: Count total distinct employees allocated to the final project list int totalEmployees = await _context.ProjectAllocations .Where(pa => pa.TenantId == tenantId && finalProjectIds.Contains(pa.ProjectId) && pa.IsActive) .Select(pa => pa.EmployeeId) .Distinct() .CountAsync(); // Query 2: Count total distinct employees who checked in today // Use an efficient date range check var today = DateTime.UtcNow.Date; var tomorrow = today.AddDays(1); int inTodays = await _context.Attendes .Where(a => a.InTime >= today && a.InTime < tomorrow && finalProjectIds.Contains(a.ProjectID)) .Select(a => a.EmployeeId) .Distinct() .CountAsync(); // --- Step 4: Assemble the response --- var teamDashboardVM = new TeamDashboardVM { TotalEmployees = totalEmployees, InToday = inTodays }; _logger.LogInfo("Successfully fetched team dashboard for user {UserId}. Total: {TotalEmployees}, InToday: {InToday}", loggedInEmployee.Id, teamDashboardVM.TotalEmployees, teamDashboardVM.InToday); return Ok(ApiResponse.SuccessResponse(teamDashboardVM, "Dashboard data retrieved successfully.", 200)); } catch (Exception ex) { _logger.LogError(ex, "An unexpected error occurred in GetTotalEmployees for projectId {ProjectId}", projectId ?? Guid.Empty); return StatusCode(500, ApiResponse.ErrorResponse("An internal server error occurred.", null, 500)); } } /// /// Retrieves a dashboard summary of total planned and completed tasks. /// If a projectId is provided, it returns totals for that project; otherwise, for all accessible projects. /// /// Optional. The ID of a specific project to get totals for. /// An ApiResponse containing the task dashboard summary. [HttpGet("tasks")] // Example route public async Task GetTotalTasks([FromQuery] Guid? projectId) // Changed to FromQuery as it's optional { try { if (tenantId == Guid.Empty) { _logger.LogWarning("Invalid request: TenantId is empty on progression endpoint"); return BadRequest(ApiResponse.ErrorResponse("Invalid TenantId", "Provided Invalid TenantId", 400)); } var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); _logger.LogInfo("GetTotalTasks called by user {UserId} for ProjectId: {ProjectId}", loggedInEmployee.Id, projectId ?? Guid.Empty); // --- Step 1: Build the base IQueryable for WorkItems --- // This query is NOT executed yet. We will add more filters to it. var baseWorkItemQuery = _context.WorkItems.Where(t => t.TenantId == tenantId); // --- Step 2: Apply Filters based on the request (Project or All Accessible) --- if (projectId.HasValue) { // --- Logic for a SINGLE Project --- using var scope = _serviceScopeFactory.CreateScope(); var _permission = scope.ServiceProvider.GetRequiredService(); // 2a. Security Check: Verify permission for the specific project. var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId.Value); if (!hasPermission) { _logger.LogWarning("Access DENIED for user {UserId} on project {ProjectId}.", loggedInEmployee.Id, projectId.Value); return StatusCode(403, ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to view this project.", 403)); } // 2b. Add project-specific filter to the base query. // This is more efficient than fetching workAreaIds separately. baseWorkItemQuery = baseWorkItemQuery .Where(wi => wi.WorkArea != null && wi.WorkArea.Floor != null && wi.WorkArea.Floor.Building != null && wi.WorkArea.Floor.Building.ProjectId == projectId.Value); } else { // --- Logic for ALL Accessible Projects --- // 2c. Get a list of all projects the user is allowed to see. var accessibleProjectIds = await _projectServices.GetMyProjectIdsAsync(loggedInEmployee, tenantId); if (!accessibleProjectIds.Any()) { _logger.LogInfo("User {UserId} has no accessible projects.", loggedInEmployee.Id); // Return a zeroed-out dashboard if the user has no projects. return Ok(ApiResponse.SuccessResponse(new TasksDashboardVM(), "No accessible projects found.", 200)); } // 2d. Add a filter to include all work items from all accessible projects. baseWorkItemQuery = baseWorkItemQuery .Where(wi => wi.WorkArea != null && wi.WorkArea.Floor != null && wi.WorkArea.Floor.Building != null && accessibleProjectIds.Contains(wi.WorkArea.Floor.Building.ProjectId)); } // --- Step 3: Execute the Aggregation Query ON THE DATABASE SERVER --- // This is the most powerful optimization. The database does all the summing. // EF Core translates this into a single, efficient SQL query like: // SELECT SUM(PlannedWork), SUM(CompletedWork) FROM WorkItems WHERE ... var tasksDashboardVM = await baseWorkItemQuery .GroupBy(x => 1) // Group by a constant to aggregate all rows into one result. .Select(g => new TasksDashboardVM { TotalTasks = g.Sum(wi => wi.PlannedWork), CompletedTasks = g.Sum(wi => wi.CompletedWork) }) .FirstOrDefaultAsync(); // Use FirstOrDefaultAsync as GroupBy might return no rows. // If the query returned no work items, the result will be null. Default to a zeroed object. tasksDashboardVM ??= new TasksDashboardVM(); _logger.LogInfo("Successfully fetched task dashboard for user {UserId}. Total: {TotalTasks}, Completed: {CompletedTasks}", loggedInEmployee.Id, tasksDashboardVM.TotalTasks, tasksDashboardVM.CompletedTasks); return Ok(ApiResponse.SuccessResponse(tasksDashboardVM, "Dashboard data retrieved successfully.", 200)); } catch (Exception ex) { _logger.LogError(ex, "An unexpected error occurred in GetTotalTasks for projectId {ProjectId}", projectId ?? Guid.Empty); return StatusCode(500, ApiResponse.ErrorResponse("An internal server error occurred.", null, 500)); } } /// /// Retrieves counts of pending attendance regularizations and check-outs for the logged-in employee. /// [HttpGet("pending-attendance")] public async Task GetPendingAttendanceAsync() { // Step 1: Validate tenant context to prevent processing invalid requests if (tenantId == Guid.Empty) { _logger.LogWarning("Invalid request: TenantId is empty when fetching pending attendance"); return BadRequest(ApiResponse.ErrorResponse("Invalid TenantId", "Provided Invalid TenantId", 400)); } // Step 2: Get currently logged-in employee for scoped attendance query var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // Step 3: Query attendance entries for employee scoped to tenant var attendanceEntries = await _context.Attendes .Where(a => a.EmployeeId == loggedInEmployee.Id && a.TenantId == tenantId) .ToListAsync(); if (!attendanceEntries.Any()) { // Step 4: No attendance entries found for this employee, log and return 404 _logger.LogWarning("No attendance entries found for employee {EmployeeId}", loggedInEmployee.Id); return NotFound(ApiResponse.ErrorResponse("No attendance entry found.", "No attendance entry was found for this employee.", 404)); } // Step 5: Calculate counts for pending regularizations and check-outs efficiently int pendingRegularizationCount = attendanceEntries.Count(a => a.Activity == ATTENDANCE_MARK_TYPE.REQUEST_REGULARIZE); int pendingCheckOutCount = attendanceEntries.Count(a => a.OutTime == null); var response = new { PendingRegularization = pendingRegularizationCount, PendingCheckOut = pendingCheckOutCount }; _logger.LogInfo("Pending regularization: {PendingRegularization}, Pending check-out: {PendingCheckOut} for employee {EmployeeId}", pendingRegularizationCount, pendingCheckOutCount, loggedInEmployee.Id); return Ok(ApiResponse.SuccessResponse(response, "Pending regularization and pending check-out fetched successfully.", 200)); } /// /// Retrieves attendance records for a specific project on a given date. /// /// The project identifier /// Optional date filter (defaults to current UTC date if null or invalid) [HttpGet("project-attendance/{projectId}")] public async Task GetProjectAttendance(Guid projectId, [FromQuery] string? date) { // Step 1: Validate tenant context if (tenantId == Guid.Empty) { _logger.LogWarning("GetProjectAttendance failed: TenantId is empty."); return BadRequest(ApiResponse.ErrorResponse("Invalid TenantId", "Provided Invalid TenantId", 400)); } // Step 2: Get logged-in employee for audit logging var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // Step 3: Parse and validate date parameter, default to UTC today if invalid or null DateTime currentDate = DateTime.UtcNow.Date; if (!string.IsNullOrWhiteSpace(date) && !DateTime.TryParse(date, out currentDate)) { _logger.LogWarning("Invalid date parameter '{Date}' sent by employee {EmployeeId}", date, loggedInEmployee.Id); return BadRequest(ApiResponse.ErrorResponse("Invalid date.", "Invalid date format.", 400)); } // Step 4: Verify project existence within tenant context var project = await _context.Projects .FirstOrDefaultAsync(p => p.Id == projectId && p.TenantId == tenantId); if (project == null) { _logger.LogWarning("Project {ProjectId} not found for employee {EmployeeId} on date {Date}", projectId, loggedInEmployee.Id, currentDate); return NotFound(ApiResponse.ErrorResponse("Project not found", "Specified project does not exist.", 404)); } // Step 5: Fetch active employee assignments for the project and tenant var employeeIds = await _context.ProjectAllocations .Where(pa => pa.ProjectId == projectId && pa.IsActive && pa.TenantId == tenantId) .Select(pa => pa.EmployeeId) .Distinct() .ToListAsync(); if (!employeeIds.Any()) { _logger.LogInfo("No active employee assignments found for project {ProjectId} on date {Date}", projectId, currentDate); return Ok(ApiResponse.SuccessResponse( new ProjectAttendanceVM { AttendanceTable = new List(), CheckedInEmployee = 0, AssignedEmployee = 0 }, $"No active employee assignments found for project {project.Name} on {currentDate:d}.", 200)); } // Step 6: Bulk-fetch employees and their attendance on the specified date var employees = await _context.Employees .Where(e => employeeIds.Contains(e.Id)) .ToListAsync(); var attendances = await _context.Attendes .Where(a => employeeIds.Contains(a.EmployeeId) && a.ProjectID == projectId && a.InTime.HasValue && a.InTime.Value.Date == currentDate) .ToListAsync(); // Step 7: Map attendance data to VM, joining attendance with employee info efficiently var employeeAttendanceVMs = attendances .Join(employees, attendance => attendance.EmployeeId, employee => employee.Id, (attendance, employee) => new DashBoardEmployeeAttendanceVM { FirstName = employee.FirstName, LastName = employee.LastName, MiddleName = employee.MiddleName, Comment = attendance.Comment, InTime = attendance.InTime, OutTime = attendance.OutTime }) .ToList(); // Step 8: Prepare response VM with attendance counts var projectAttendanceVM = new ProjectAttendanceVM { AttendanceTable = employeeAttendanceVMs, CheckedInEmployee = employeeAttendanceVMs.Count, AssignedEmployee = employeeIds.Count }; _logger.LogInfo("Attendance record retrieved for project {ProjectId} on date {Date} by employee {EmployeeId}", projectId, currentDate, loggedInEmployee.Id); return Ok(ApiResponse.SuccessResponse(projectAttendanceVM, $"Attendance record for project {project.Name} on {currentDate:d} fetched successfully.", 200)); } /// /// Retrieves detailed performed activity records for a specific project on a given date. /// /// The project identifier /// Optional date filter (defaults to current UTC date if null or invalid) [HttpGet("activities/{projectId}")] public async Task GetActivities(Guid projectId, [FromQuery] string? date) { // Step 1: Validate tenant context for security and data isolation if (tenantId == Guid.Empty) { _logger.LogWarning("GetActivities request failed with empty TenantId."); return BadRequest(ApiResponse.ErrorResponse("Invalid TenantId", "Provided Invalid TenantId", 400)); } // Step 2: Fetch logged-in employee for audit var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // Step 3: Parse and validate date parameter, defaulting to UTC today if invalid or not provided DateTime currentDate = DateTime.UtcNow.Date; if (!string.IsNullOrWhiteSpace(date) && !DateTime.TryParse(date, out currentDate)) { _logger.LogWarning("Invalid date parameter '{Date}' supplied by employee {EmployeeId}", date, loggedInEmployee.Id); return BadRequest(ApiResponse.ErrorResponse("Invalid date.", "Invalid date format.", 400)); } // Step 4: Verify project existence and tenant scope var project = await _context.Projects .FirstOrDefaultAsync(p => p.Id == projectId && p.TenantId == tenantId); if (project == null) { _logger.LogWarning("ProjectId {ProjectId} not found for employee {EmployeeId} during activity retrieval on {Date}", projectId, loggedInEmployee.Id, currentDate); return NotFound(ApiResponse.ErrorResponse("Project not found", "Specified project does not exist.", 404)); } // Step 5: Fetch tasks for the filtered work items on the specified date var tasks = await _context.TaskAllocations .Include(t => t.WorkItem) .ThenInclude(wi => wi!.WorkArea) .ThenInclude(wa => wa!.Floor) .ThenInclude(f => f!.Building) .Include(t => t.WorkItem) .ThenInclude(wi => wi!.ActivityMaster) .Where(t => t.WorkItem != null && t.WorkItem.WorkArea != null && t.WorkItem.WorkArea.Floor != null && t.WorkItem.WorkArea.Floor.Building != null && t.WorkItem.WorkArea.Floor.Building.ProjectId == projectId && t.WorkItem.ActivityMaster != null && t.AssignmentDate.Date == currentDate.Date && t.TenantId == tenantId) .ToListAsync(); // Step 6: Aggregate totals and prepare performed activities list double totalPlannedTask = 0; double totalCompletedTask = 0; var performedActivities = new List(); foreach (var task in tasks) { totalPlannedTask += task.PlannedTask; totalCompletedTask += task.CompletedTask; performedActivities.Add(new PerformedActivites { ActivityName = task.WorkItem?.ActivityMaster?.ActivityName, BuldingName = task.WorkItem?.WorkArea?.Floor?.Building?.Name, FloorName = task.WorkItem?.WorkArea?.Floor?.FloorName, WorkAreaName = task.WorkItem?.WorkArea?.AreaName, AssignedToday = task.PlannedTask, CompletedToday = task.CompletedTask, }); } // Step 7: Count tasks with no reported date int pendingReportCount = tasks.Count(t => t.ReportedDate == null); // Step 8: Assemble final activity report var activityReport = new ActivityReport { PerformedActivites = performedActivities, TotalCompletedWork = totalCompletedTask, TotalPlannedWork = totalPlannedTask, ReportPending = pendingReportCount, TodaysAssigned = tasks.Count }; // Step 9: Log success and respond _logger.LogInfo("Activities retrieved for project {ProjectId} on {Date} by employee {EmployeeId}", projectId, currentDate, loggedInEmployee.Id); return Ok(ApiResponse.SuccessResponse(activityReport, $"Performed activities for project {project.Name} on {currentDate:d} retrieved successfully.", 200)); } /// /// Retrieves attendance overview grouped by job roles for a project over a specified number of days. /// /// The project identifier /// Number of past days to include (string input, parsed as int) [HttpGet("attendance-overview/{projectId}")] public async Task GetAttendanceOverView(Guid projectId, [FromQuery] string days) { // Step 1: Validate tenant context for multi-tenant data isolation if (tenantId == Guid.Empty) { _logger.LogWarning("Empty TenantId in AttendanceOverview request."); return BadRequest(ApiResponse.ErrorResponse("Invalid TenantId", "Provided Invalid TenantId", 400)); } _logger.LogInfo("GetAttendanceOverView called for ProjectId: {ProjectId}, Days: {Days}", projectId, days); // Step 2: Validate project existence under tenant scope var project = await _context.Projects .AsNoTracking() .FirstOrDefaultAsync(p => p.Id == projectId && p.TenantId == tenantId); if (project == null) { _logger.LogWarning("Project not found: {ProjectId}", projectId); return BadRequest(ApiResponse.ErrorResponse("Project not found", "Project not found", 400)); } // Step 3: Check if logged-in employee has permission for this project var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); using var scope = _serviceScopeFactory.CreateScope(); var _permission = scope.ServiceProvider.GetRequiredService(); bool hasPermission = await _permission.HasProjectPermission(loggedInEmployee!, projectId); if (!hasPermission) { _logger.LogWarning("Unauthorized access by EmployeeId: {EmployeeId} to ProjectId: {ProjectId}", loggedInEmployee.Id, projectId); return StatusCode(403, ApiResponse.ErrorResponse( "Permission Denied", "You don't have permission to access this feature", 403)); } // Step 4: Validate and parse 'days' query parameter if (!int.TryParse(days, out int dayCount) || dayCount <= 0) { _logger.LogWarning("Invalid 'days' parameter: {Days}", days); return BadRequest(ApiResponse.ErrorResponse("Invalid number of days", "Days must be a positive integer", 400)); } // Step 5: Define date range for attendance fetching DateTime today = DateTime.UtcNow.Date; DateTime startDate = today.AddDays(-dayCount); // Step 6: Retrieve allocations and job roles for project and tenant var allocations = await _context.ProjectAllocations .Where(pa => pa.ProjectId == projectId && pa.TenantId == tenantId) .ToListAsync(); if (!allocations.Any()) { _logger.LogInfo("No allocations found for ProjectId: {ProjectId}", projectId); return Ok(ApiResponse.SuccessResponse(new List(), "No allocations found", 200)); } var jobRoleIds = allocations.Select(pa => pa.JobRoleId).Distinct().ToList(); var jobRoles = await _context.JobRoles .Where(jr => jobRoleIds.Contains(jr.Id) && jr.TenantId == tenantId) .ToListAsync(); // Step 7: Fetch attendance records within date range filtered by project and tenant var attendances = await _context.Attendes .Where(a => a.ProjectID == projectId && a.InTime.HasValue && a.InTime.Value.Date >= startDate && a.InTime.Value.Date <= today && a.TenantId == tenantId) .ToListAsync(); // Step 8: Group attendance by date and job role var overviewList = new List(); for (DateTime date = today; date > startDate; date = date.AddDays(-1)) { foreach (var jobRole in jobRoles) { var employeeIdsByRole = allocations .Where(pa => pa.JobRoleId == jobRole.Id) .Select(pa => pa.EmployeeId) .ToList(); int presentCount = attendances .Count(a => employeeIdsByRole.Contains(a.EmployeeId) && a.InTime!.Value.Date == date); overviewList.Add(new AttendanceOverviewVM { Role = jobRole.Name, Date = date.ToString("yyyy-MM-dd"), Present = presentCount }); } } // Step 9: Sort results by date desc and present count desc var sortedResult = overviewList .OrderByDescending(r => r.Date) .ThenByDescending(r => r.Present) .ToList(); _logger.LogInfo("Attendance overview fetched: ProjectId: {ProjectId}, Records: {Count}", projectId, sortedResult.Count); return Ok(ApiResponse.SuccessResponse(sortedResult, $"{sortedResult.Count} records fetched for attendance overview", 200)); } [HttpGet("expense/monthly")] public async Task GetExpenseReportByProjectsAsync([FromQuery] Guid? projectId, [FromQuery] Guid? categoryId, [FromQuery] int months) { try { if (tenantId == Guid.Empty) { _logger.LogWarning("Invalid request: TenantId is empty on progression endpoint"); return BadRequest(ApiResponse.ErrorResponse("Invalid TenantId", "Provided Invalid TenantId", 400)); } // Read-only base filter with tenant scope and non-draft var baseQuery = _context.Expenses .AsNoTracking() .Where(e => e.TenantId == tenantId && e.IsActive && e.StatusId != Draft); // [Server Filters] if (months != 0) { months = 0 - months; var end = DateTime.UtcNow.Date; var start = end.AddMonths(months); // inclusive EOD baseQuery = baseQuery.Where(e => e.TransactionDate >= start && e.TransactionDate <= end); } if (projectId.HasValue) baseQuery = baseQuery.Where(e => e.ProjectId == projectId); if (categoryId.HasValue) baseQuery = baseQuery.Where(e => e.ExpenseCategoryId == categoryId); // Single server-side group/aggregate by project var report = await baseQuery .AsNoTracking() .GroupBy(e => new { e.TransactionDate.Year, e.TransactionDate.Month }) .Select(g => new { Year = g.Key.Year, Month = g.Key.Month, Total = g.Sum(x => x.Amount), Count = g.Count() }) .OrderBy(x => x.Year).ThenBy(x => x.Month) .ToListAsync(); var culture = CultureInfo.GetCultureInfo("en-IN"); // pick desired locale var response = report .Select(x => new { MonthName = culture.DateTimeFormat.GetMonthName(x.Month), // e.g., "January" Year = x.Year, Total = x.Total, Count = x.Count }).ToList(); _logger.LogInfo( "GetExpenseReportByProjects completed. TenantId={TenantId}, Rows={Rows}", tenantId, report.Count); // [Completion Log] return Ok(ApiResponse.SuccessResponse(response, "Expense report by project fetched successfully", 200)); // [Success Response] } catch (OperationCanceledException) { _logger.LogWarning("GetExpenseReportByProjects canceled by client. TenantId={TenantId}", tenantId); // [Cancel Log] return StatusCode(499, ApiResponse.ErrorResponse("Client has canceled the opration", "Client has canceled the opration", 499)); // [Cancel Response] } catch (Exception ex) { _logger.LogError(ex, "GetExpenseReportByProjects failed. TenantId={TenantId}", tenantId); // [Error Log] return StatusCode(500, ApiResponse.ErrorResponse("An error occurred while fetching the expense report.", 500)); // [Error Response] } } [HttpGet("expense/type")] public async Task GetExpenseReportByExpenseTypeAsync([FromQuery] Guid? projectId, [FromQuery] DateTime startDate, [FromQuery] DateTime endDate) { // Structured log: entering action with filters _logger.LogDebug( "GetExpenseReportByExpenseType started. TenantId={TenantId}, ProjectId={ProjectId}, StartDate={StartDate}, EndDate={EndDate}", tenantId, projectId ?? Guid.Empty, startDate, endDate); // [Start Log] if (tenantId == Guid.Empty) { _logger.LogWarning("Invalid request: TenantId is empty on progression endpoint"); return BadRequest(ApiResponse.ErrorResponse("Invalid TenantId", "Provided Invalid TenantId", 400)); } try { // Compose base query: push filters to DB, avoid client evaluation IQueryable baseQuery = _context.Expenses .AsNoTracking() // Reduce tracking overhead for read-only endpoint .Where(e => e.TenantId == tenantId && e.IsActive && e.StatusId != Draft && e.TransactionDate >= startDate && e.TransactionDate <= endDate.AddDays(1).AddTicks(-1)); if (projectId.HasValue) baseQuery = baseQuery.Where(e => e.ProjectId == projectId.Value); // [Filter] // Project to a minimal shape before grouping to avoid loading navigation graphs // Group by expense type name; adjust to the correct key if ExpensesCategory is an enum or navigation var query = baseQuery .Where(e => e.ExpenseCategory != null) .Select(e => new { ExpenseTypeName = e.ExpenseCategory!.Name, // If enum, use e.ExpensesCategory.ToString() Amount = e.Amount, StatusId = e.StatusId }) .GroupBy(x => x.ExpenseTypeName) .Select(g => new { ProjectName = g.Key, // Original code used g.Key!.Name; here the grouping key is already a string TotalApprovedAmount = g.Where(x => x.StatusId == Processed || x.StatusId == ProcessPending).Sum(x => x.Amount), TotalPendingAmount = g.Where(x => x.StatusId != Processed && x.StatusId != RejectedByReviewer && x.StatusId != RejectedByApprover) .Sum(x => x.Amount), TotalRejectedAmount = g.Where(x => x.StatusId == RejectedByReviewer || x.StatusId == RejectedByApprover) .Sum(x => x.Amount), TotalProcessedAmount = g.Where(x => x.StatusId == Processed) .Sum(x => x.Amount) }) .OrderBy(r => r.ProjectName); // Server-side order var report = await query.ToListAsync(); // Single round-trip var response = new { Report = report, TotalAmount = report.Sum(r => r.TotalApprovedAmount) }; _logger.LogInfo( "GetExpenseReportByExpenseType completed. TenantId={TenantId}, Filters: ProjectId={ProjectId}, StartDate={StartDate}, EndDate={EndDate}, Rows={RowCount}, TotalAmount={TotalAmount}", tenantId, projectId ?? Guid.Empty, startDate, endDate, report.Count, response.TotalAmount); // [Completion Log] return Ok(ApiResponse.SuccessResponse(response, "Expense report by expense type fetched successfully", 200)); // [Success Response] } catch (OperationCanceledException) { _logger.LogWarning("GetExpenseReportByExpenseType canceled by client. TenantId={TenantId}", tenantId); // [Cancel Log] [memory:4] return StatusCode(499, ApiResponse.ErrorResponse("Client has canceled the opration", "Client has canceled the opration", 499)); // [Cancel Response] } catch (Exception ex) { _logger.LogError(ex, "GetExpenseReportByExpenseType failed. TenantId={TenantId}, ProjectId={ProjectId}, StartDate={StartDate}, EndDate={EndDate}", tenantId, projectId ?? Guid.Empty, startDate, endDate); // [Error Log] return StatusCode(StatusCodes.Status500InternalServerError, ApiResponse.ErrorResponse("An error occurred while fetching the expense report.", 500)); // [Error Response] } } [HttpGet("expense/pendings")] public async Task GetPendingExpenseListAsync([FromQuery] Guid? projectId) { // Start log with correlation fields _logger.LogDebug( "GetPendingExpenseListAsync started. Project={ProjectId} TenantId={TenantId}", projectId ?? Guid.Empty, tenantId); // [Start Log] try { if (tenantId == Guid.Empty) { _logger.LogWarning("Invalid request: TenantId is empty on progression endpoint"); return BadRequest(ApiResponse.ErrorResponse("Invalid TenantId", "Provided Invalid TenantId", 400)); } // Resolve current employee once; avoid using scoped services inside Task.Run var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // [User Context] // Resolve permission service from current scope once using var scope = _serviceScopeFactory.CreateScope(); // Fire permission checks concurrently without Task.Run; these are async I/O methods var hasReviewPermissionTask = Task.Run(async () => { var _permission = scope.ServiceProvider.GetRequiredService(); return await _permission.HasPermission(PermissionsMaster.ExpenseReview, loggedInEmployee.Id); }); var hasApprovePermissionTask = Task.Run(async () => { var _permission = scope.ServiceProvider.GetRequiredService(); return await _permission.HasPermission(PermissionsMaster.ExpenseApprove, loggedInEmployee.Id); }); var hasProcessPermissionTask = Task.Run(async () => { var _permission = scope.ServiceProvider.GetRequiredService(); return await _permission.HasPermission(PermissionsMaster.ExpenseProcess, loggedInEmployee.Id); }); var hasManagePermissionTask = Task.Run(async () => { var _permission = scope.ServiceProvider.GetRequiredService(); return await _permission.HasPermission(PermissionsMaster.ExpenseManage, loggedInEmployee.Id); }); await Task.WhenAll(hasReviewPermissionTask, hasApprovePermissionTask, hasProcessPermissionTask, hasManagePermissionTask); // [Parallel Await] var hasReviewPermission = hasReviewPermissionTask.Result; var hasApprovePermission = hasApprovePermissionTask.Result; var hasProcessPermission = hasProcessPermissionTask.Result; var hasManagePermission = hasManagePermissionTask.Result; _logger.LogInfo( "Permissions resolved: Review={Review}, Approve={Approve}, Process={Process}", hasReviewPermission, hasApprovePermission, hasProcessPermission); // [Permissions Log] // Build base query: read-only, tenant-scoped var baseQuery = _context.Expenses .Include(e => e.Status) .AsNoTracking() // Reduce tracking overhead for read-only list .Where(e => e.IsActive && e.TenantId == tenantId && e.StatusId != Processed && e.Status != null); // [Base Filter] // Project to DTO in SQL to avoid heavy Include graph. if (projectId.HasValue) baseQuery = baseQuery.Where(e => e.ProjectId == projectId); // Prefer ProjectTo when profiles exist; otherwise project minimal fields var expenses = await baseQuery .ToListAsync(); // Single round-trip; no Include needed for this shape var draftExpenses = expenses.Where(e => e.StatusId == Draft && e.CreatedById == loggedInEmployee.Id).ToList(); var reviewExpenses = expenses.Where(e => (hasReviewPermission || e.CreatedById == loggedInEmployee.Id) && e.StatusId == Review).ToList(); var approveExpenses = expenses.Where(e => (hasApprovePermission || e.CreatedById == loggedInEmployee.Id) && e.StatusId == Approve).ToList(); var processPendingExpenses = expenses.Where(e => (hasProcessPermission || e.CreatedById == loggedInEmployee.Id) && e.StatusId == ProcessPending).ToList(); var submitedExpenses = expenses.Where(e => e.StatusId != Draft && e.CreatedById == loggedInEmployee.Id).ToList(); var totalAmount = expenses.Where(e => e.StatusId != Draft).Sum(e => e.Amount); if (hasManagePermission) { var response = new { Draft = new { Count = draftExpenses.Count, TotalAmount = draftExpenses.Sum(e => e.Amount) }, ReviewPending = new { Count = reviewExpenses.Count, TotalAmount = reviewExpenses.Sum(e => e.Amount) }, ApprovePending = new { Count = approveExpenses.Count, TotalAmount = approveExpenses.Sum(e => e.Amount) }, ProcessPending = new { Count = processPendingExpenses.Count, TotalAmount = processPendingExpenses.Sum(e => e.Amount) }, Submited = new { Count = submitedExpenses.Count, TotalAmount = submitedExpenses.Sum(e => e.Amount) }, TotalAmount = totalAmount }; _logger.LogInfo( "GetPendingExpenseListAsync completed. TenantId={TenantId}", tenantId); // [Completion Log] return Ok(ApiResponse.SuccessResponse(response, "Pending Expenses fetched successfully", 200)); // [Success Response] } else { var response = new { Draft = new { Count = draftExpenses.Count }, ReviewPending = new { Count = reviewExpenses.Count }, ApprovePending = new { Count = approveExpenses.Count }, ProcessPending = new { Count = processPendingExpenses.Count }, Submited = new { Count = submitedExpenses.Count }, TotalAmount = totalAmount }; _logger.LogInfo( "GetPendingExpenseListAsync completed. TenantId={TenantId}", tenantId); // [Completion Log] return Ok(ApiResponse.SuccessResponse(response, "Pending Expenses fetched successfully", 200)); // [Success Response] } } catch (OperationCanceledException) { _logger.LogWarning("GetPendingExpenseListAsync canceled by client. TenantId={TenantId}", tenantId); // [Cancel Log] return StatusCode(499, ApiResponse.ErrorResponse("Client has canceled the opration", "Client has canceled the opration", 499)); // [Cancel Response] } catch (Exception ex) { _logger.LogError(ex, "GetPendingExpenseListAsync failed. TenantId={TenantId}", tenantId); // [Error Log] return StatusCode(500, ApiResponse.ErrorResponse("An error occurred while fetching pending expenses.", "An error occurred while fetching pending expenses.", 500)); // [Error Response] } } /// /// Retrieves today's attendance details for a specific employee on a given project, /// defaulting to the currently logged-in employee when no employeeId is provided. /// Includes related project and employee information for UI display. /// /// The project identifier whose attendance is requested. /// /// Optional employee identifier. When null, the currently logged-in employee is used. /// /// /// 200 OK with an payload on success, or a standardized /// error envelope on validation or processing failure. /// [HttpGet("get/attendance/employee/{projectId}")] public async Task GetAttendanceByEmployeeAsync(Guid projectId, [FromQuery] Guid? employeeId) { // TenantId is assumed to come from a base controller, HttpContext, or similar. if (tenantId == Guid.Empty) { _logger.LogWarning("GetAttendanceByEmployeeAsync called with empty TenantId. ProjectId={ProjectId}", projectId); return BadRequest( ApiResponse.ErrorResponse("Invalid tenant information.", "TenantId is empty in GetAttendanceByEmployeeAsync.", 400)); } if (projectId == Guid.Empty) { _logger.LogWarning("GetAttendanceByEmployeeAsync called with empty ProjectId. TenantId={TenantId}", tenantId); return BadRequest( ApiResponse.ErrorResponse("Project reference is required.", "ProjectId is empty in GetAttendanceByEmployeeAsync.", 400)); } // Resolve the currently logged-in employee (e.g., from token or session). var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); var attendanceEmployeeId = employeeId ?? loggedInEmployee.Id; try { // Step 1: Ensure employee is allocated to the project for this tenant. var projectAllocation = await _context.ProjectAllocations .Include(pa => pa.Employee)!.ThenInclude(e => e!.JobRole) .Include(pa => pa.Employee)!.ThenInclude(e => e!.Organization) .Include(pa => pa.Project) .FirstOrDefaultAsync(pa => pa.ProjectId == projectId && pa.EmployeeId == attendanceEmployeeId && pa.IsActive && pa.TenantId == tenantId); if (projectAllocation == null) { _logger.LogWarning( "GetAttendanceByEmployeeAsync failed: Employee not allocated to project. TenantId={TenantId}, ProjectId={ProjectId}, EmployeeId={EmployeeId}, RequestedById={RequestedById}", tenantId, projectId, attendanceEmployeeId, loggedInEmployee.Id); return BadRequest(ApiResponse.ErrorResponse("The employee is not allocated to the selected project.", "Project allocation not found for given ProjectId, EmployeeId, and TenantId.", 400)); } // Step 2: Fetch today's attendance (if any) for the selected employee and project. var today = DateTime.UtcNow.Date; // Prefer UTC for server-side comparisons. var attendance = await _context.Attendes .Include(a => a.Approver) .Include(a => a.RequestedBy) .FirstOrDefaultAsync(a => a.TenantId == tenantId && a.EmployeeId == attendanceEmployeeId && a.ProjectID == projectId && a.AttendanceDate.Date == today); // Step 3: Map to view model with defensive null handling. var attendanceVm = new EmployeeAttendanceVM { Id = attendance?.Id ?? Guid.Empty, EmployeeAvatar = null, // Can be filled from a file service or CDN later. EmployeeId = projectAllocation.EmployeeId, FirstName = projectAllocation.Employee?.FirstName, OrganizationName = projectAllocation.Employee?.Organization?.Name, LastName = projectAllocation.Employee?.LastName, JobRoleName = projectAllocation.Employee?.JobRole?.Name, ProjectId = projectId, ProjectName = projectAllocation.Project?.Name, CheckInTime = attendance?.InTime, CheckOutTime = attendance?.OutTime, Activity = attendance?.Activity ?? ATTENDANCE_MARK_TYPE.CHECK_IN, ApprovedAt = attendance?.ApprovedAt, Approver = attendance == null ? null : _mapper.Map(attendance.Approver), RequestedAt = attendance?.RequestedAt, RequestedBy = attendance == null ? null : _mapper.Map(attendance.RequestedBy) }; _logger.LogInfo("GetAttendanceByEmployeeAsync completed successfully. TenantId={TenantId}, ProjectId={ProjectId}, EmployeeId={EmployeeId}, HasAttendance={HasAttendance}", tenantId, projectId, attendanceEmployeeId, attendance != null); return Ok(ApiResponse.SuccessResponse(attendanceVm, "Attendance fetched successfully.", 200)); } catch (OperationCanceledException) { _logger.LogWarning("GetAttendanceByEmployeeAsync was canceled. TenantId={TenantId}, ProjectId={ProjectId}, EmployeeId={EmployeeId}", tenantId, projectId, attendanceEmployeeId); return StatusCode(499, ApiResponse.ErrorResponse("The request was canceled.", "GetAttendanceByEmployeeAsync was canceled by the client.", 499)); } catch (Exception ex) { _logger.LogError(ex, "GetAttendanceByEmployeeAsync failed with an unexpected error. TenantId={TenantId}, ProjectId={ProjectId}, EmployeeId={EmployeeId}", tenantId, projectId, attendanceEmployeeId); return StatusCode(500, ApiResponse.ErrorResponse("An error occurred while fetching attendance.", "Unhandled exception in GetAttendanceByEmployeeAsync.", 500)); } } /// /// Returns a high-level collection overview (aging buckets, due vs collected, top client) /// for invoices of the current tenant, optionally filtered by project. /// /// Optional project identifier to filter invoices. /// Standardized API response with collection KPIs. [HttpGet("collection-overview")] public async Task GetCollectionOverviewAsync([FromQuery] Guid? projectId) { // Correlation ID pattern for distributed tracing (if you use one) var correlationId = HttpContext.TraceIdentifier; if (tenantId == Guid.Empty) { _logger.LogWarning("Invalid request: TenantId is empty on progression endpoint"); return BadRequest(ApiResponse.ErrorResponse("Invalid TenantId", "Provided Invalid TenantId", 400)); } _logger.LogInfo("Started GetCollectionOverviewAsync. CorrelationId: {CorrelationId}, ProjectId: {ProjectId}", correlationId, projectId ?? Guid.Empty); try { // Validate and identify current employee/tenant context var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // Base invoice query for this tenant; AsNoTracking for read-only performance [web:1][web:5] var invoiceQuery = _context.Invoices .Where(i => i.TenantId == tenantId && i.IsActive) .Include(i => i.BilledTo) .AsNoTracking(); // Fetch infra and service projects in parallel using factory-created contexts // NOTE: Avoid Task.Run over async IO where possible. Here each uses its own context instance. [web:6][web:15] var infraProjectTask = GetInfraProjectsAsync(tenantId); var serviceProjectTask = GetServiceProjectsAsync(tenantId); await Task.WhenAll(infraProjectTask, serviceProjectTask); var projects = infraProjectTask.Result .Union(serviceProjectTask.Result) .ToList(); // Optional project filter: validate existence in cached list first if (projectId.HasValue) { var project = projects.FirstOrDefault(p => p.Id == projectId.Value); if (project == null) { _logger.LogWarning( "Project {ProjectId} not found for tenant {TenantId} in GetCollectionOverviewAsync. CorrelationId: {CorrelationId}", projectId, tenantId, correlationId); return StatusCode( StatusCodes.Status404NotFound, ApiResponse.ErrorResponse( "Project Not Found", "The requested project does not exist or is not associated with the current tenant.", StatusCodes.Status404NotFound)); } invoiceQuery = invoiceQuery.Where(i => i.ProjectId == projectId.Value); } var invoices = await invoiceQuery.ToListAsync(); if (invoices.Count == 0) { _logger.LogInfo( "No invoices found for tenant {TenantId} in GetCollectionOverviewAsync. CorrelationId: {CorrelationId}", tenantId, correlationId); // Return an empty but valid overview instead of 404 – endpoint is conceptually valid var emptyResponse = new { TotalDueAmount = 0d, TotalCollectedAmount = 0d, TotalValue = 0d, PendingPercentage = 0d, CollectedPercentage = 0d, Bucket0To30Invoices = 0, Bucket30To60Invoices = 0, Bucket60To90Invoices = 0, Bucket90PlusInvoices = 0, Bucket0To30Amount = 0d, Bucket30To60Amount = 0d, Bucket60To90Amount = 0d, Bucket90PlusAmount = 0d, TopClientBalance = 0d, TopClient = new BasicOrganizationVm() }; return Ok(ApiResponse.SuccessResponse(emptyResponse, "No invoices found for the current tenant and filters; returning empty collection overview.", 200)); } var invoiceIds = invoices.Select(i => i.Id).ToList(); // Pre-aggregate payments per invoice in the DB where possible [web:1][web:17] var paymentGroups = await _context.ReceivedInvoicePayments .AsNoTracking() .Where(p => invoiceIds.Contains(p.InvoiceId) && p.TenantId == tenantId) .GroupBy(p => p.InvoiceId) .Select(g => new { InvoiceId = g.Key, PaidAmount = g.Sum(p => p.Amount) }) .ToListAsync(); // Create a lookup to avoid repeated LINQ Where on each iteration var paymentsLookup = paymentGroups.ToDictionary(p => p.InvoiceId, p => p.PaidAmount); double totalDueAmount = 0; var today = DateTime.UtcNow.Date; // use UTC for consistency [web:17] var bucketOneInvoices = 0; double bucketOneAmount = 0; var bucketTwoInvoices = 0; double bucketTwoAmount = 0; var bucketThreeInvoices = 0; double bucketThreeAmount = 0; var bucketFourInvoices = 0; double bucketFourAmount = 0; // Main aging calculation loop foreach (var invoice in invoices) { var total = invoice.BasicAmount + invoice.TaxAmount; var paid = paymentsLookup.TryGetValue(invoice.Id, out var paidAmount) ? paidAmount : 0d; var balance = total - paid; // Skip fully paid or explicitly completed invoices if (balance <= 0 || invoice.MarkAsCompleted) continue; totalDueAmount += balance; // Only consider invoices with expected payment date up to today for aging var expectedDate = invoice.ExceptedPaymentDate.Date; if (expectedDate > today) continue; var days = (today - expectedDate).Days; if (days <= 30 && days > 0) { bucketOneInvoices++; bucketOneAmount += balance; } else if (days > 30 && days <= 60) { bucketTwoInvoices++; bucketTwoAmount += balance; } else if (days > 60 && days <= 90) { bucketThreeInvoices++; bucketThreeAmount += balance; } else if (days > 90) { bucketFourInvoices++; bucketFourAmount += balance; } } var totalCollectedAmount = paymentGroups.Sum(p => p.PaidAmount); var totalValue = totalDueAmount + totalCollectedAmount; var pendingPercentage = totalValue > 0 ? (totalDueAmount / totalValue) * 100 : 0; var collectedPercentage = totalValue > 0 ? (totalCollectedAmount / totalValue) * 100 : 0; // Determine top client by outstanding balance double topClientBalance = 0; Organization topClient = new Organization(); var groupedByClient = invoices .Where(i => i.BilledToId.HasValue && i.BilledTo != null) .GroupBy(i => i.BilledToId); foreach (var group in groupedByClient) { var clientInvoiceIds = group.Select(i => i.Id).ToList(); var totalForClient = group.Sum(i => i.BasicAmount + i.TaxAmount); var paidForClient = paymentGroups .Where(pg => clientInvoiceIds.Contains(pg.InvoiceId)) .Sum(pg => pg.PaidAmount); var clientBalance = totalForClient - paidForClient; if (clientBalance > topClientBalance) { topClientBalance = clientBalance; topClient = group.First()!.BilledTo!; } } BasicOrganizationVm topClientVm = new BasicOrganizationVm(); if (topClient != null) { topClientVm = new BasicOrganizationVm { Id = topClient.Id, Name = topClient.Name, Email = topClient.Email, ContactPerson = topClient.ContactPerson, ContactNumber = topClient.ContactNumber, Address = topClient.Address, GSTNumber = topClient.GSTNumber, SPRID = topClient.SPRID }; } var response = new { TotalDueAmount = totalDueAmount, TotalCollectedAmount = totalCollectedAmount, TotalValue = totalValue, PendingPercentage = Math.Round(pendingPercentage, 2), CollectedPercentage = Math.Round(collectedPercentage, 2), Bucket0To30Invoices = bucketOneInvoices, Bucket30To60Invoices = bucketTwoInvoices, Bucket60To90Invoices = bucketThreeInvoices, Bucket90PlusInvoices = bucketFourInvoices, Bucket0To30Amount = bucketOneAmount, Bucket30To60Amount = bucketTwoAmount, Bucket60To90Amount = bucketThreeAmount, Bucket90PlusAmount = bucketFourAmount, TopClientBalance = topClientBalance, TopClient = topClientVm }; _logger.LogInfo("Successfully completed GetCollectionOverviewAsync for tenant {TenantId}. CorrelationId: {CorrelationId}, TotalInvoices: {InvoiceCount}, TotalValue: {TotalValue}", tenantId, correlationId, invoices.Count, totalValue); return Ok(ApiResponse.SuccessResponse(response, "Collection overview fetched successfully.", 200)); } catch (Exception ex) { // Centralized logging for unhandled exceptions with context, no sensitive data [web:1][web:5][web:10] _logger.LogError(ex, "Unhandled exception in GetCollectionOverviewAsync. CorrelationId: {CorrelationId}", correlationId); // Generic but consistent error payload; let global exception handler standardize if you use ProblemDetails [web:10][web:13][web:16] return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An unexpected error occurred while generating the collection overview. Please try again or contact support with the correlation identifier.", 500)); } } [HttpGet("purchase-invoice-overview")] public async Task GetPurchaseInvoiceOverview() { // Correlation id for tracing this request across services/logs. var correlationId = HttpContext.TraceIdentifier; _logger.LogInfo("GetPurchaseInvoiceOverview started. TenantId: {TenantId}, CorrelationId: {CorrelationId}", tenantId, correlationId); // Basic guard: invalid tenant. if (tenantId == Guid.Empty) { _logger.LogWarning("GetPurchaseInvoiceOverview rejected due to empty TenantId. CorrelationId: {CorrelationId}", correlationId); return BadRequest(ApiResponse.ErrorResponse("Invalid TenantId", "The tenant identifier provided is invalid or missing.", 400)); } try { // Fetch current employee context (if needed for authorization/audit). var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // Run project queries in parallel to reduce latency. var infraProjectTask = GetInfraProjectsAsync(tenantId); var serviceProjectTask = GetServiceProjectsAsync(tenantId); await Task.WhenAll(infraProjectTask, serviceProjectTask); var projects = infraProjectTask.Result .Union(serviceProjectTask.Result) .ToList(); _logger.LogDebug("GetPurchaseInvoiceOverview loaded projects. Count: {ProjectCount}, TenantId: {TenantId}, CorrelationId: {CorrelationId}", projects.Count, tenantId, correlationId); // Query purchase invoices for the tenant. var purchaseInvoices = await _context.PurchaseInvoiceDetails .Include(pid => pid.Supplier) .Include(pid => pid.Status) .AsNoTracking() .Where(pid => pid.TenantId == tenantId && pid.IsActive) .ToListAsync(); _logger.LogInfo("GetPurchaseInvoiceOverview loaded invoices. InvoiceCount: {InvoiceCount}, TenantId: {TenantId}, CorrelationId: {CorrelationId}", purchaseInvoices.Count, tenantId, correlationId); if (!purchaseInvoices.Any()) { // No invoices is not an error; return an empty but well-structured overview. _logger.LogInfo("GetPurchaseInvoiceOverview: No active purchase invoices found. TenantId: {TenantId}, CorrelationId: {CorrelationId}", tenantId, correlationId); var emptyResponse = new { TotalInvoices = 0, TotalValue = 0m, AverageValue = 0m, StatusBreakdown = Array.Empty(), ProjectBreakdown = Array.Empty(), TopSupplier = (object?)null }; return Ok(ApiResponse.SuccessResponse( emptyResponse, "No active purchase invoices found for the specified tenant.", StatusCodes.Status200OK)); } var totalInvoices = purchaseInvoices.Count; var totalValue = purchaseInvoices.Sum(pid => pid.BaseAmount); // Guard against divide-by-zero (in case BaseAmount is all zero). var averageValue = totalInvoices > 0 ? totalValue / totalInvoices : 0; // Status-wise aggregation var statusBreakdown = purchaseInvoices .Where(pid => pid.Status != null) .GroupBy(pid => pid.StatusId) .Select(g => new { Id = g.Key, Name = g.First().Status!.DisplayName, Count = g.Count(), TotalValue = g.Sum(pid => pid.BaseAmount), Percentage = totalValue > 0 ? Math.Round(g.Sum(pid => pid.BaseAmount) / totalValue * 100, 2) : 0 }) .OrderByDescending(x => x.TotalValue) .ToList(); // Project-wise aggregation (top 3 by value) var projectBreakdown = purchaseInvoices .GroupBy(pid => pid.ProjectId) .Select(g => new { Id = g.Key, Name = projects.FirstOrDefault(p => p.Id == g.Key)?.Name ?? "Unknown Project", Count = g.Count(), TotalValue = g.Sum(pid => pid.BaseAmount), Percentage = totalValue > 0 ? Math.Round(g.Sum(pid => pid.BaseAmount) / totalValue * 100, 2) : 0 }) .OrderByDescending(pid => pid.TotalValue) .Take(3) .ToList(); // Top supplier by total value var supplierBreakdown = purchaseInvoices .Where(pid => pid.Supplier != null) .GroupBy(pid => pid.SupplierId) .Select(g => new { Id = g.Key, Name = g.First().Supplier!.Name, Count = g.Count(), TotalValue = g.Sum(pid => pid.BaseAmount), Percentage = totalValue > 0 ? Math.Round(g.Sum(pid => pid.BaseAmount) / totalValue * 100, 2) : 0 }) .OrderByDescending(pid => pid.TotalValue) .FirstOrDefault(); var response = new { TotalInvoices = totalInvoices, TotalValue = Math.Round(totalValue, 2), AverageValue = Math.Round(averageValue, 2), StatusBreakdown = statusBreakdown, ProjectBreakdown = projectBreakdown, TopSupplier = supplierBreakdown }; _logger.LogInfo("GetPurchaseInvoiceOverview completed successfully. TenantId: {TenantId}, TotalInvoices: {TotalInvoices}, TotalValue: {TotalValue}, CorrelationId: {CorrelationId}", tenantId, totalInvoices, totalValue, correlationId); return Ok(ApiResponse.SuccessResponse(response, "Purchase invoice overview retrieved successfully.", 200)); } catch (Exception ex) { // Capture complete context for diagnostics, but ensure no sensitive data is logged. _logger.LogError(ex, "Error occurred while processing GetPurchaseInvoiceOverview. TenantId: {TenantId}, CorrelationId: {CorrelationId}", tenantId, correlationId); // Do not expose internal details to clients. Return a generic 500 response. return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An unexpected error occurred while processing the purchase invoice overview.", 500)); } } /// /// Gets infrastructure projects for a tenant as a lightweight view model. /// private async Task> GetInfraProjectsAsync(Guid tenantId) { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.Projects .AsNoTracking() .Where(p => p.TenantId == tenantId) .Select(p => new BasicProjectVM { Id = p.Id, Name = p.Name }) .ToListAsync(); } /// /// Gets service projects for a tenant as a lightweight view model. /// private async Task> GetServiceProjectsAsync(Guid tenantId) { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.ServiceProjects .AsNoTracking() .Where(sp => sp.TenantId == tenantId) .Select(sp => new BasicProjectVM { Id = sp.Id, Name = sp.Name }) .ToListAsync(); } } }