Merge branch 'Finance_Dashboards' of https://git.marcoaiot.com/admin/marco.pms.api into SideMenu_Management
This commit is contained in:
commit
8470223f98
@ -3,10 +3,13 @@ using Marco.Pms.DataAccess.Data;
|
|||||||
using Marco.Pms.Model.Dtos.Attendance;
|
using Marco.Pms.Model.Dtos.Attendance;
|
||||||
using Marco.Pms.Model.Entitlements;
|
using Marco.Pms.Model.Entitlements;
|
||||||
using Marco.Pms.Model.Expenses;
|
using Marco.Pms.Model.Expenses;
|
||||||
|
using Marco.Pms.Model.OrganizationModel;
|
||||||
using Marco.Pms.Model.Utilities;
|
using Marco.Pms.Model.Utilities;
|
||||||
using Marco.Pms.Model.ViewModels.Activities;
|
using Marco.Pms.Model.ViewModels.Activities;
|
||||||
using Marco.Pms.Model.ViewModels.AttendanceVM;
|
using Marco.Pms.Model.ViewModels.AttendanceVM;
|
||||||
using Marco.Pms.Model.ViewModels.DashBoard;
|
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;
|
||||||
using Marco.Pms.Services.Service.ServiceInterfaces;
|
using Marco.Pms.Services.Service.ServiceInterfaces;
|
||||||
using MarcoBMS.Services.Helpers;
|
using MarcoBMS.Services.Helpers;
|
||||||
@ -23,11 +26,11 @@ namespace Marco.Pms.Services.Controllers
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
public class DashboardController : ControllerBase
|
public class DashboardController : ControllerBase
|
||||||
{
|
{
|
||||||
|
private readonly IDbContextFactory<ApplicationDbContext> _dbContextFactory;
|
||||||
private readonly ApplicationDbContext _context;
|
private readonly ApplicationDbContext _context;
|
||||||
private readonly UserHelper _userHelper;
|
private readonly UserHelper _userHelper;
|
||||||
private readonly IProjectServices _projectServices;
|
private readonly IProjectServices _projectServices;
|
||||||
private readonly ILoggingService _logger;
|
private readonly ILoggingService _logger;
|
||||||
private readonly PermissionServices _permissionServices;
|
|
||||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||||
private readonly IMapper _mapper;
|
private readonly IMapper _mapper;
|
||||||
|
|
||||||
@ -46,17 +49,17 @@ namespace Marco.Pms.Services.Controllers
|
|||||||
IProjectServices projectServices,
|
IProjectServices projectServices,
|
||||||
IServiceScopeFactory serviceScopeFactory,
|
IServiceScopeFactory serviceScopeFactory,
|
||||||
ILoggingService logger,
|
ILoggingService logger,
|
||||||
PermissionServices permissionServices,
|
IMapper mapper,
|
||||||
IMapper mapper)
|
IDbContextFactory<ApplicationDbContext> dbContextFactory)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
_userHelper = userHelper;
|
_userHelper = userHelper;
|
||||||
_projectServices = projectServices;
|
_projectServices = projectServices;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_serviceScopeFactory = serviceScopeFactory;
|
_serviceScopeFactory = serviceScopeFactory;
|
||||||
_permissionServices = permissionServices;
|
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
tenantId = userHelper.GetTenantId();
|
tenantId = userHelper.GetTenantId();
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -263,8 +266,10 @@ namespace Marco.Pms.Services.Controllers
|
|||||||
|
|
||||||
if (projectId.HasValue)
|
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.
|
// 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)
|
if (!hasPermission)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Access DENIED for user {UserId} on project {ProjectId} (not active or not accessible).", loggedInEmployee.Id, projectId.Value);
|
_logger.LogWarning("Access DENIED for user {UserId} on project {ProjectId} (not active or not accessible).", loggedInEmployee.Id, projectId.Value);
|
||||||
@ -349,9 +354,11 @@ namespace Marco.Pms.Services.Controllers
|
|||||||
if (projectId.HasValue)
|
if (projectId.HasValue)
|
||||||
{
|
{
|
||||||
// --- Logic for a SINGLE Project ---
|
// --- 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.
|
// 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)
|
if (!hasPermission)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Access DENIED for user {UserId} on project {ProjectId}.", loggedInEmployee.Id, projectId.Value);
|
_logger.LogWarning("Access DENIED for user {UserId} on project {ProjectId}.", loggedInEmployee.Id, projectId.Value);
|
||||||
@ -678,7 +685,11 @@ namespace Marco.Pms.Services.Controllers
|
|||||||
|
|
||||||
// Step 3: Check if logged-in employee has permission for this project
|
// Step 3: Check if logged-in employee has permission for this project
|
||||||
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
|
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)
|
if (!hasPermission)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Unauthorized access by EmployeeId: {EmployeeId} to ProjectId: {ProjectId}", loggedInEmployee.Id, projectId);
|
_logger.LogWarning("Unauthorized access by EmployeeId: {EmployeeId} to ProjectId: {ProjectId}", loggedInEmployee.Id, projectId);
|
||||||
@ -756,7 +767,6 @@ namespace Marco.Pms.Services.Controllers
|
|||||||
return Ok(ApiResponse<object>.SuccessResponse(sortedResult, $"{sortedResult.Count} records fetched for attendance overview", 200));
|
return Ok(ApiResponse<object>.SuccessResponse(sortedResult, $"{sortedResult.Count} records fetched for attendance overview", 200));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[HttpGet("expense/monthly")]
|
[HttpGet("expense/monthly")]
|
||||||
public async Task<IActionResult> GetExpenseReportByProjectsAsync([FromQuery] Guid? projectId, [FromQuery] Guid? categoryId, [FromQuery] int months)
|
public async Task<IActionResult> GetExpenseReportByProjectsAsync([FromQuery] Guid? projectId, [FromQuery] Guid? categoryId, [FromQuery] int months)
|
||||||
{
|
{
|
||||||
@ -1204,5 +1214,425 @@ namespace Marco.Pms.Services.Controllers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user