1639 lines
84 KiB
C#
1639 lines
84 KiB
C#
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<ApplicationDbContext> _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<ApplicationDbContext> dbContextFactory)
|
||
{
|
||
_context = context;
|
||
_userHelper = userHelper;
|
||
_projectServices = projectServices;
|
||
_logger = logger;
|
||
_serviceScopeFactory = serviceScopeFactory;
|
||
_mapper = mapper;
|
||
tenantId = userHelper.GetTenantId();
|
||
_dbContextFactory = dbContextFactory;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Fetches project progression data (planned and completed tasks) in graph form for a tenant and specified (or all) projects over a date range.
|
||
/// </summary>
|
||
/// <param name="days">Number of days back to fetch data for</param>
|
||
/// <param name="FromDate">Starting date for the graph, or today if null</param>
|
||
/// <param name="projectId">Optionally, the project to filter on</param>
|
||
[HttpGet("progression")]
|
||
public async Task<IActionResult> 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<object>.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<object>.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<ProjectProgressionVM>();
|
||
|
||
// 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<object>.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<object>.SuccessResponse(projectProgressionVMs, "Project progression data fetched successfully.", 200));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gets the count of total and ongoing projects for the current tenant,
|
||
/// using properly optimized queries and structured logging.
|
||
/// </summary>
|
||
[HttpGet("projects")]
|
||
public async Task<IActionResult> 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<object>.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<object>.SuccessResponse(dashboardVM, "Project counts fetched successfully.", 200));
|
||
}
|
||
|
||
[HttpGet("project-completion-status")]
|
||
public async Task<IActionResult> GetAllProjectsAsync()
|
||
{
|
||
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
|
||
var response = await _projectServices.GetAllProjectsAsync(string.Empty, 0, 0, loggedInEmployee, tenantId);
|
||
return StatusCode(response.StatusCode, response);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
/// <param name="projectId">Optional. The ID of a specific project to get totals for.</param>
|
||
[HttpGet("teams")]
|
||
public async Task<IActionResult> GetTotalEmployees([FromQuery] Guid? projectId)
|
||
{
|
||
try
|
||
{
|
||
if (tenantId == Guid.Empty)
|
||
{
|
||
_logger.LogWarning("Invalid request: TenantId is empty on progression endpoint");
|
||
return BadRequest(ApiResponse<object>.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<TeamDashboardVM>.SuccessResponse(new TeamDashboardVM(), "No accessible active projects found.", 200));
|
||
}
|
||
|
||
// --- Step 2: Build the list of project IDs to query against ---
|
||
List<Guid> finalProjectIds;
|
||
|
||
if (projectId.HasValue)
|
||
{
|
||
using var scope = _serviceScopeFactory.CreateScope();
|
||
var _permission = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
||
// 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<object>.ErrorResponse("Access Denied.", "You do not have permission to view this project, or it is not active.", 403));
|
||
}
|
||
finalProjectIds = new List<Guid> { 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<TeamDashboardVM>.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<object>.ErrorResponse("An internal server error occurred.", null, 500));
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
/// <param name="projectId">Optional. The ID of a specific project to get totals for.</param>
|
||
/// <returns>An ApiResponse containing the task dashboard summary.</returns>
|
||
[HttpGet("tasks")] // Example route
|
||
public async Task<IActionResult> 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<object>.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<PermissionServices>();
|
||
|
||
// 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<object>.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<TasksDashboardVM>.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<TasksDashboardVM>.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<object>.ErrorResponse("An internal server error occurred.", null, 500));
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Retrieves counts of pending attendance regularizations and check-outs for the logged-in employee.
|
||
/// </summary>
|
||
[HttpGet("pending-attendance")]
|
||
public async Task<IActionResult> 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<object>.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<object>.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<object>.SuccessResponse(response, "Pending regularization and pending check-out fetched successfully.", 200));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Retrieves attendance records for a specific project on a given date.
|
||
/// </summary>
|
||
/// <param name="projectId">The project identifier</param>
|
||
/// <param name="date">Optional date filter (defaults to current UTC date if null or invalid)</param>
|
||
[HttpGet("project-attendance/{projectId}")]
|
||
public async Task<IActionResult> 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<object>.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<object>.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<object>.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<object>.SuccessResponse(
|
||
new ProjectAttendanceVM
|
||
{
|
||
AttendanceTable = new List<DashBoardEmployeeAttendanceVM>(),
|
||
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<object>.SuccessResponse(projectAttendanceVM, $"Attendance record for project {project.Name} on {currentDate:d} fetched successfully.", 200));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Retrieves detailed performed activity records for a specific project on a given date.
|
||
/// </summary>
|
||
/// <param name="projectId">The project identifier</param>
|
||
/// <param name="date">Optional date filter (defaults to current UTC date if null or invalid)</param>
|
||
[HttpGet("activities/{projectId}")]
|
||
public async Task<IActionResult> 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<object>.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<object>.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<object>.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<PerformedActivites>();
|
||
|
||
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<object>.SuccessResponse(activityReport, $"Performed activities for project {project.Name} on {currentDate:d} retrieved successfully.", 200));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Retrieves attendance overview grouped by job roles for a project over a specified number of days.
|
||
/// </summary>
|
||
/// <param name="projectId">The project identifier</param>
|
||
/// <param name="days">Number of past days to include (string input, parsed as int)</param>
|
||
[HttpGet("attendance-overview/{projectId}")]
|
||
public async Task<IActionResult> 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<object>.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<object>.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<PermissionServices>();
|
||
|
||
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<object>.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<object>.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<object>.SuccessResponse(new List<AttendanceOverviewVM>(), "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<AttendanceOverviewVM>();
|
||
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<object>.SuccessResponse(sortedResult, $"{sortedResult.Count} records fetched for attendance overview", 200));
|
||
}
|
||
|
||
[HttpGet("expense/monthly")]
|
||
public async Task<IActionResult> 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<object>.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<object>.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<object>.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<object>.ErrorResponse("An error occurred while fetching the expense report.", 500)); // [Error Response]
|
||
}
|
||
}
|
||
|
||
[HttpGet("expense/type")]
|
||
public async Task<IActionResult> 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<object>.ErrorResponse("Invalid TenantId", "Provided Invalid TenantId", 400));
|
||
}
|
||
|
||
try
|
||
{
|
||
// Compose base query: push filters to DB, avoid client evaluation
|
||
IQueryable<Expenses> 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<object>.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<object>.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<object>.ErrorResponse("An error occurred while fetching the expense report.", 500)); // [Error Response]
|
||
}
|
||
}
|
||
|
||
[HttpGet("expense/pendings")]
|
||
public async Task<IActionResult> 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<object>.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<PermissionServices>();
|
||
return await _permission.HasPermission(PermissionsMaster.ExpenseReview, loggedInEmployee.Id);
|
||
});
|
||
|
||
var hasApprovePermissionTask = Task.Run(async () =>
|
||
{
|
||
var _permission = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
||
return await _permission.HasPermission(PermissionsMaster.ExpenseApprove, loggedInEmployee.Id);
|
||
});
|
||
|
||
var hasProcessPermissionTask = Task.Run(async () =>
|
||
{
|
||
var _permission = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
||
return await _permission.HasPermission(PermissionsMaster.ExpenseProcess, loggedInEmployee.Id);
|
||
});
|
||
|
||
var hasManagePermissionTask = Task.Run(async () =>
|
||
{
|
||
var _permission = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
||
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<object>.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<object>.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<object>.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<object>.ErrorResponse("An error occurred while fetching pending expenses.", "An error occurred while fetching pending expenses.", 500)); // [Error Response]
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
/// <param name="projectId">The project identifier whose attendance is requested.</param>
|
||
/// <param name="employeeId">
|
||
/// Optional employee identifier. When null, the currently logged-in employee is used.
|
||
/// </param>
|
||
/// <returns>
|
||
/// 200 OK with an <see cref="EmployeeAttendanceVM"/> payload on success, or a standardized
|
||
/// error envelope on validation or processing failure.
|
||
/// </returns>
|
||
[HttpGet("get/attendance/employee/{projectId}")]
|
||
public async Task<IActionResult> 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<object>.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<object>.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<object>.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<BasicEmployeeVM>(attendance.Approver),
|
||
RequestedAt = attendance?.RequestedAt,
|
||
RequestedBy = attendance == null
|
||
? null
|
||
: _mapper.Map<BasicEmployeeVM>(attendance.RequestedBy)
|
||
};
|
||
|
||
_logger.LogInfo("GetAttendanceByEmployeeAsync completed successfully. TenantId={TenantId}, ProjectId={ProjectId}, EmployeeId={EmployeeId}, HasAttendance={HasAttendance}",
|
||
tenantId, projectId, attendanceEmployeeId, attendance != null);
|
||
|
||
return Ok(ApiResponse<object>.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<object>.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<object>.ErrorResponse("An error occurred while fetching attendance.", "Unhandled exception in GetAttendanceByEmployeeAsync.", 500));
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns a high-level collection overview (aging buckets, due vs collected, top client)
|
||
/// for invoices of the current tenant, optionally filtered by project.
|
||
/// </summary>
|
||
/// <param name="projectId">Optional project identifier to filter invoices.</param>
|
||
/// <returns>Standardized API response with collection KPIs.</returns>
|
||
[HttpGet("collection-overview")]
|
||
public async Task<IActionResult> 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<object>.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<object>.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<object>.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<object>.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<object>.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<IActionResult> 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<object>.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<object>(),
|
||
ProjectBreakdown = Array.Empty<object>(),
|
||
TopSupplier = (object?)null
|
||
};
|
||
|
||
return Ok(ApiResponse<object>.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<object>.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<object>.ErrorResponse("Internal Server Error", "An unexpected error occurred while processing the purchase invoice overview.", 500));
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gets infrastructure projects for a tenant as a lightweight view model.
|
||
/// </summary>
|
||
private async Task<List<BasicProjectVM>> 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();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gets service projects for a tenant as a lightweight view model.
|
||
/// </summary>
|
||
private async Task<List<BasicProjectVM>> 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();
|
||
}
|
||
}
|
||
}
|