SideMenu_Management #156

Merged
ashutosh.nehete merged 8 commits from SideMenu_Management into Purchase_Invoice_Management 2025-12-06 10:02:21 +00:00
Showing only changes of commit 94e2e4f18b - Show all commits

View File

@ -2,8 +2,11 @@
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.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;
@ -20,11 +23,11 @@ namespace Marco.Pms.Services.Controllers
[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 PermissionServices _permissionServices;
private readonly IServiceScopeFactory _serviceScopeFactory;
public static readonly Guid ActiveId = Guid.Parse("b74da4c2-d07e-46f2-9919-e75e49b12731");
private static readonly Guid Draft = Guid.Parse("297e0d8f-f668-41b5-bfea-e03b354251c8");
@ -40,16 +43,17 @@ namespace Marco.Pms.Services.Controllers
IProjectServices projectServices,
IServiceScopeFactory serviceScopeFactory,
ILoggingService logger,
PermissionServices permissionServices)
IDbContextFactory<ApplicationDbContext> dbContextFactory)
{
_context = context;
_userHelper = userHelper;
_projectServices = projectServices;
_logger = logger;
_serviceScopeFactory = serviceScopeFactory;
_permissionServices = permissionServices;
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>
@ -254,8 +258,10 @@ namespace Marco.Pms.Services.Controllers
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 _permissionServices.HasProjectPermission(loggedInEmployee, projectId.Value);
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);
@ -340,9 +346,11 @@ namespace Marco.Pms.Services.Controllers
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 _permissionServices.HasProjectPermission(loggedInEmployee, projectId.Value);
var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId.Value);
if (!hasPermission)
{
_logger.LogWarning("Access DENIED for user {UserId} on project {ProjectId}.", loggedInEmployee.Id, projectId.Value);
@ -669,7 +677,11 @@ namespace Marco.Pms.Services.Controllers
// Step 3: Check if logged-in employee has permission for this project
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
bool hasPermission = await _permissionServices.HasProjectPermission(loggedInEmployee!, projectId);
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);
@ -747,7 +759,6 @@ namespace Marco.Pms.Services.Controllers
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)
{
@ -1074,5 +1085,274 @@ namespace Marco.Pms.Services.Controllers
ApiResponse<object>.ErrorResponse("An error occurred while fetching pending expenses.", "An error occurred while fetching pending expenses.", 500)); // [Error Response]
}
}
[HttpGet("collection/overview")]
/// <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;
_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));
}
}
/// <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();
}
}
}