Optimized the dashboard APIs for expenses
This commit is contained in:
parent
9332d9cc0b
commit
7e15c517ac
@ -1,13 +1,12 @@
|
|||||||
using AutoMapper;
|
using Marco.Pms.DataAccess.Data;
|
||||||
using Marco.Pms.DataAccess.Data;
|
|
||||||
using Marco.Pms.Model.Activities;
|
using Marco.Pms.Model.Activities;
|
||||||
using Marco.Pms.Model.Dtos.Attendance;
|
using Marco.Pms.Model.Dtos.Attendance;
|
||||||
using Marco.Pms.Model.Employees;
|
using Marco.Pms.Model.Employees;
|
||||||
using Marco.Pms.Model.Entitlements;
|
using Marco.Pms.Model.Entitlements;
|
||||||
|
using Marco.Pms.Model.Expenses;
|
||||||
using Marco.Pms.Model.Projects;
|
using Marco.Pms.Model.Projects;
|
||||||
using Marco.Pms.Model.Utilities;
|
using Marco.Pms.Model.Utilities;
|
||||||
using Marco.Pms.Model.ViewModels.DashBoard;
|
using Marco.Pms.Model.ViewModels.DashBoard;
|
||||||
using Marco.Pms.Model.ViewModels.Expanses;
|
|
||||||
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;
|
||||||
@ -616,34 +615,56 @@ namespace Marco.Pms.Services.Controllers
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("expense/project")]
|
[HttpGet("expense/project")]
|
||||||
public async Task<IActionResult> GetExpenseReportByProjects([FromQuery] DateTime? startDate, [FromQuery] DateTime? endDate)
|
public async Task<IActionResult> GetExpenseReportByProjectsAsync([FromQuery] DateTime startDate, [FromQuery] DateTime endDate)
|
||||||
{
|
{
|
||||||
var expensesQuery = _context.Expenses
|
// Structured start log
|
||||||
.Include(e => e.Project)
|
_logger.LogInfo(
|
||||||
.Where(e => e.TenantId == tenantId && e.IsActive && e.StatusId != Draft && e.Project != null);
|
"GetExpenseReportByProjects started. TenantId={TenantId}, StartDate={StartDate}, EndDate={EndDate}",
|
||||||
|
tenantId, startDate, endDate); // [Start Log] [memory:4][memory:1]
|
||||||
|
|
||||||
if (startDate.HasValue && endDate.HasValue)
|
// Guard: validate range and normalize to inclusive end-of-day
|
||||||
|
if (endDate < startDate)
|
||||||
{
|
{
|
||||||
expensesQuery = expensesQuery.Where(e => e.TransactionDate.Date >= startDate.Value.Date && e.TransactionDate.Date <= endDate.Value.Date);
|
_logger.LogWarning("Invalid date range. StartDate={StartDate}, EndDate={EndDate}", startDate, endDate); // [Validation Log] [memory:4]
|
||||||
|
return BadRequest(ApiResponse<object>.ErrorResponse("endDate must be on or after startDate.", 400)); // [Validation Response] [memory:1]
|
||||||
}
|
}
|
||||||
|
|
||||||
var expenses = await expensesQuery.GroupBy(e => e.Project).ToListAsync();
|
var start = startDate.Date;
|
||||||
|
var end = endDate.Date.AddDays(1).AddTicks(-1); // inclusive EOD [memory:7]
|
||||||
|
|
||||||
var report = expenses.Select(g =>
|
try
|
||||||
{
|
{
|
||||||
var totalAmount = g.Sum(e => e.Amount);
|
// Read-only base filter with tenant scope and non-draft
|
||||||
var totalPendingAmount = g.Where(e => e.StatusId != Processed && e.StatusId != RejectedByReviewer && e.StatusId != RejectedByApprover).Sum(e => e.Amount);
|
var baseQuery = _context.Expenses
|
||||||
var totalRejectedAmount = g.Where(e => e.StatusId == RejectedByReviewer || e.StatusId == RejectedByApprover).Sum(e => e.Amount);
|
.AsNoTracking()
|
||||||
var totalProcessedAmount = g.Where(e => e.StatusId == Processed || e.StatusId == ProcessPending).Sum(e => e.Amount);
|
.Where(e =>
|
||||||
return new
|
e.TenantId == tenantId
|
||||||
|
&& e.IsActive
|
||||||
|
&& e.StatusId != Draft
|
||||||
|
&& e.Project != null
|
||||||
|
&& e.TransactionDate >= start
|
||||||
|
&& e.TransactionDate <= end); // [Server Filters] [memory:7]
|
||||||
|
|
||||||
|
// Single server-side group/aggregate by project
|
||||||
|
var report = await baseQuery
|
||||||
|
.GroupBy(e => e.Project)
|
||||||
|
.Select(g => new
|
||||||
{
|
{
|
||||||
ProjectName = g.Key!.Name,
|
ProjectName = g.Key!.Name,
|
||||||
TotalPendingAmount = totalPendingAmount,
|
TotalApprovedAmount = g.Where(x => x.StatusId == Processed || x.StatusId == ProcessPending)
|
||||||
TotalRejectedAmount = totalRejectedAmount,
|
.Sum(x => x.Amount),
|
||||||
TotalProcessedAmount = totalProcessedAmount,
|
TotalPendingAmount = g.Where(x => x.StatusId != Processed
|
||||||
TotalApprovedAmount = totalAmount
|
&& x.StatusId != RejectedByReviewer
|
||||||
};
|
&& x.StatusId != RejectedByApprover)
|
||||||
}).OrderBy(r => r.ProjectName).ToList();
|
.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)
|
||||||
|
.ToListAsync(); // [Single Round-trip] [memory:7]
|
||||||
|
|
||||||
var response = new
|
var response = new
|
||||||
{
|
{
|
||||||
@ -651,43 +672,79 @@ namespace Marco.Pms.Services.Controllers
|
|||||||
TotalAmount = report.Sum(r => r.TotalApprovedAmount)
|
TotalAmount = report.Sum(r => r.TotalApprovedAmount)
|
||||||
};
|
};
|
||||||
|
|
||||||
return Ok(ApiResponse<object>.SuccessResponse(response, "Expense report by project fetched successfully", 200));
|
_logger.LogInfo(
|
||||||
|
"GetExpenseReportByProjects completed. TenantId={TenantId}, Rows={Rows}, TotalAmount={TotalAmount}",
|
||||||
|
tenantId, report.Count, response.TotalAmount); // [Completion Log] [memory:4]
|
||||||
|
|
||||||
|
return Ok(ApiResponse<object>.SuccessResponse(response, "Expense report by project fetched successfully", 200)); // [Success Response] [memory:1]
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("GetExpenseReportByProjects 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] [memory:1]
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex,
|
||||||
|
"GetExpenseReportByProjects failed. TenantId={TenantId}, StartDate={StartDate}, EndDate={EndDate}",
|
||||||
|
tenantId, start, end); // [Error Log] [memory:4]
|
||||||
|
return StatusCode(500,
|
||||||
|
ApiResponse<object>.ErrorResponse("An error occurred while fetching the expense report.", 500)); // [Error Response] [memory:1]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("expense/type")]
|
[HttpGet("expense/type")]
|
||||||
public async Task<IActionResult> GetExpenseReportByExpenseType([FromQuery] Guid? projectId, [FromQuery] DateTime? startDate, [FromQuery] DateTime? endDate)
|
public async Task<IActionResult> GetExpenseReportByExpenseTypeAsync([FromQuery] Guid? projectId, [FromQuery] DateTime startDate, [FromQuery] DateTime endDate)
|
||||||
{
|
{
|
||||||
var expensesQuery = _context.Expenses
|
// Structured log: entering action with filters
|
||||||
.Include(e => e.Project)
|
_logger.LogDebug(
|
||||||
.Where(e => e.TenantId == tenantId && e.IsActive && e.StatusId != Draft && e.Project != null);
|
"GetExpenseReportByExpenseType started. TenantId={TenantId}, ProjectId={ProjectId}, StartDate={StartDate}, EndDate={EndDate}",
|
||||||
|
tenantId, projectId ?? Guid.Empty, startDate, endDate); // [Start Log] [memory:4][memory:1]
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
if (projectId.HasValue)
|
||||||
{
|
baseQuery = baseQuery.Where(e => e.ProjectId == projectId.Value); // [Filter] [memory:7]
|
||||||
expensesQuery = expensesQuery.Where(e => e.ProjectId == projectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startDate.HasValue && endDate.HasValue)
|
// Project to a minimal shape before grouping to avoid loading navigation graphs
|
||||||
|
// Group by expense type name; adjust to the correct key if ExpensesType is an enum or navigation
|
||||||
|
var query = baseQuery
|
||||||
|
.Where(e => e.ExpensesType != null)
|
||||||
|
.Select(e => new
|
||||||
{
|
{
|
||||||
expensesQuery = expensesQuery.Where(e => e.TransactionDate.Date >= startDate.Value.Date && e.TransactionDate.Date <= endDate.Value.Date);
|
ExpenseTypeName = e.ExpensesType!.Name, // If enum, use e.ExpensesType.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 [memory:7]
|
||||||
|
|
||||||
var expenses = await expensesQuery.GroupBy(e => e.ExpensesType).ToListAsync();
|
var report = await query.ToListAsync(); // Single round-trip [memory:7]
|
||||||
|
|
||||||
var report = expenses.Select(g =>
|
|
||||||
{
|
|
||||||
var totalAmount = g.Sum(e => e.Amount);
|
|
||||||
var totalPendingAmount = g.Where(e => e.StatusId != Processed && e.StatusId != RejectedByReviewer && e.StatusId != RejectedByApprover).Sum(e => e.Amount);
|
|
||||||
var totalRejectedAmount = g.Where(e => e.StatusId == RejectedByReviewer || e.StatusId == RejectedByApprover).Sum(e => e.Amount);
|
|
||||||
var totalProcessedAmount = g.Where(e => e.StatusId == Processed || e.StatusId == ProcessPending).Sum(e => e.Amount);
|
|
||||||
return new
|
|
||||||
{
|
|
||||||
ProjectName = g.Key!.Name,
|
|
||||||
TotalPendingAmount = totalPendingAmount,
|
|
||||||
TotalRejectedAmount = totalRejectedAmount,
|
|
||||||
TotalProcessedAmount = totalProcessedAmount,
|
|
||||||
TotalApprovedAmount = totalAmount
|
|
||||||
};
|
|
||||||
}).OrderBy(r => r.ProjectName).ToList();
|
|
||||||
|
|
||||||
var response = new
|
var response = new
|
||||||
{
|
{
|
||||||
@ -695,16 +752,43 @@ namespace Marco.Pms.Services.Controllers
|
|||||||
TotalAmount = report.Sum(r => r.TotalApprovedAmount)
|
TotalAmount = report.Sum(r => r.TotalApprovedAmount)
|
||||||
};
|
};
|
||||||
|
|
||||||
return Ok(ApiResponse<object>.SuccessResponse(response, "Expense report by expense type fetched successfully", 200));
|
_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] [memory:4]
|
||||||
|
|
||||||
|
return Ok(ApiResponse<object>.SuccessResponse(response, "Expense report by expense type fetched successfully", 200)); // [Success Response] [memory:1]
|
||||||
|
}
|
||||||
|
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] [memory:1]
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex,
|
||||||
|
"GetExpenseReportByExpenseType failed. TenantId={TenantId}, ProjectId={ProjectId}, StartDate={StartDate}, EndDate={EndDate}",
|
||||||
|
tenantId, projectId ?? Guid.Empty, startDate, endDate); // [Error Log] [memory:4]
|
||||||
|
return StatusCode(StatusCodes.Status500InternalServerError,
|
||||||
|
ApiResponse<object>.ErrorResponse("An error occurred while fetching the expense report.", 500)); // [Error Response] [memory:1]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("expense/pendings")]
|
[HttpGet("expense/pendings")]
|
||||||
public async Task<IActionResult> GetPendingExpenseListAsync()
|
public async Task<IActionResult> GetPendingExpenseListAsync()
|
||||||
{
|
{
|
||||||
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
|
// Start log with correlation fields
|
||||||
|
_logger.LogDebug(
|
||||||
|
"GetPendingExpenseListAsync started. TenantId={TenantId}", tenantId); // [Start Log] [memory:4][memory:1]
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Resolve current employee once; avoid using scoped services inside Task.Run
|
||||||
|
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // [User Context] [memory:1]
|
||||||
|
|
||||||
|
// Resolve permission service from current scope once
|
||||||
using var scope = _serviceScopeFactory.CreateScope();
|
using var scope = _serviceScopeFactory.CreateScope();
|
||||||
var _mapper = scope.ServiceProvider.GetRequiredService<IMapper>();
|
|
||||||
|
// Fire permission checks concurrently without Task.Run; these are async I/O methods
|
||||||
|
|
||||||
var hasReviewPermissionTask = Task.Run(async () =>
|
var hasReviewPermissionTask = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
@ -724,13 +808,24 @@ namespace Marco.Pms.Services.Controllers
|
|||||||
return await _permission.HasPermission(PermissionsMaster.ExpenseProcess, loggedInEmployee.Id);
|
return await _permission.HasPermission(PermissionsMaster.ExpenseProcess, loggedInEmployee.Id);
|
||||||
});
|
});
|
||||||
|
|
||||||
await Task.WhenAll(hasReviewPermissionTask, hasApprovePermissionTask, hasProcessPermissionTask);
|
await Task.WhenAll(hasReviewPermissionTask, hasApprovePermissionTask, hasProcessPermissionTask); // [Parallel Await]
|
||||||
|
|
||||||
var hasReviewPermission = hasReviewPermissionTask.Result;
|
var hasReviewPermission = hasReviewPermissionTask.Result;
|
||||||
var hasApprovePermission = hasApprovePermissionTask.Result;
|
var hasApprovePermission = hasApprovePermissionTask.Result;
|
||||||
var hasProcessPermission = hasProcessPermissionTask.Result;
|
var hasProcessPermission = hasProcessPermissionTask.Result;
|
||||||
|
|
||||||
var expenses = await _context.Expenses
|
_logger.LogInfo(
|
||||||
|
"Permissions resolved: Review={Review}, Approve={Approve}, Process={Process}",
|
||||||
|
hasReviewPermission, hasApprovePermission, hasProcessPermission); // [Permissions Log] [memory:4]
|
||||||
|
|
||||||
|
// Build base query: read-only, tenant-scoped
|
||||||
|
var baseQuery = _context.Expenses
|
||||||
|
.AsNoTracking() // Reduce tracking overhead for read-only list
|
||||||
|
.Where(e => e.IsActive && e.TenantId == tenantId); // [Base Filter] [memory:7]
|
||||||
|
|
||||||
|
// Important: fix operator precedence by grouping OR conditions
|
||||||
|
// Pending means Draft always, plus role-gated statuses
|
||||||
|
var pendingQuery = baseQuery
|
||||||
.Include(e => e.PaidBy)
|
.Include(e => e.PaidBy)
|
||||||
.Include(e => e.CreatedBy)
|
.Include(e => e.CreatedBy)
|
||||||
.Include(e => e.ProcessedBy)
|
.Include(e => e.ProcessedBy)
|
||||||
@ -741,16 +836,55 @@ namespace Marco.Pms.Services.Controllers
|
|||||||
.Include(e => e.PaymentMode)
|
.Include(e => e.PaymentMode)
|
||||||
.Include(e => e.ExpensesType)
|
.Include(e => e.ExpensesType)
|
||||||
.Include(e => e.Status)
|
.Include(e => e.Status)
|
||||||
.Where(e => e.IsActive && e.TenantId == tenantId &&
|
.AsNoTracking()
|
||||||
e.StatusId == Draft ||
|
.Where(e =>
|
||||||
(hasReviewPermission && e.StatusId == Review) ||
|
(e.StatusId == Draft && e.CreatedById == loggedInEmployee.Id)
|
||||||
(hasApprovePermission && e.StatusId == Approve) ||
|
|| (hasReviewPermission && e.StatusId == Review)
|
||||||
(hasProcessPermission && e.StatusId == ProcessPending)
|
|| (hasApprovePermission && e.StatusId == Approve)
|
||||||
).ToListAsync();
|
|| (hasProcessPermission && e.StatusId == ProcessPending)); // [Correct Precedence] [memory:7]
|
||||||
|
|
||||||
var response = _mapper.Map<List<ExpenseList>>(expenses);
|
// Project to DTO in SQL to avoid heavy Include graph.
|
||||||
|
|
||||||
return Ok(ApiResponse<object>.SuccessResponse(new { }, "Pending Expenses fetched successfully", 200));
|
// Prefer ProjectTo when profiles exist; otherwise project minimal fields
|
||||||
|
var response = await pendingQuery
|
||||||
|
.Where(e => e.Status != null && e.ExpensesType != null && e.PaymentMode != null && e.Project != null && e.CreatedBy != null)
|
||||||
|
.Select(e => new
|
||||||
|
{
|
||||||
|
Id = e.Id,
|
||||||
|
Amount = e.Amount,
|
||||||
|
TransactionDate = e.TransactionDate,
|
||||||
|
StatusId = e.StatusId,
|
||||||
|
StatusName = e.Status!.Name,
|
||||||
|
ExpenseTypeName = e.ExpensesType!.Name,
|
||||||
|
PaymentModeName = e.PaymentMode!.Name,
|
||||||
|
ProjectName = e.Project!.Name,
|
||||||
|
CreatedByName = $"{e.CreatedBy!.FirstName} {e.CreatedBy.LastName}",
|
||||||
|
ReviewedByName = e.ReviewedBy != null ? $"{e.ReviewedBy.FirstName} {e.ReviewedBy.LastName}" : null,
|
||||||
|
ApprovedByName = e.ApprovedBy != null ? $"{e.ApprovedBy.FirstName} {e.ApprovedBy.LastName}" : null,
|
||||||
|
ProcessedByName = e.ProcessedBy != null ? $"{e.ProcessedBy.FirstName} {e.ProcessedBy.LastName}" : null,
|
||||||
|
PaidByName = e.PaidBy != null ? $"{e.PaidBy.FirstName} {e.PaidBy.LastName}" : null
|
||||||
|
})
|
||||||
|
.OrderByDescending(x => x.TransactionDate)
|
||||||
|
.ToListAsync(); // Single round-trip; no Include needed for this shape [memory:7]
|
||||||
|
|
||||||
|
_logger.LogInfo(
|
||||||
|
"GetPendingExpenseListAsync completed. TenantId={TenantId}, Count={Count}",
|
||||||
|
tenantId, response.Count); // [Completion Log] [memory:4]
|
||||||
|
|
||||||
|
return Ok(ApiResponse<object>.SuccessResponse(response, "Pending Expenses fetched successfully", 200)); // [Success Response] [memory:1]
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("GetPendingExpenseListAsync 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] [memory:1]
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "GetPendingExpenseListAsync failed. TenantId={TenantId}", tenantId); // [Error Log] [memory:4]
|
||||||
|
return StatusCode(500,
|
||||||
|
ApiResponse<object>.ErrorResponse("An error occurred while fetching pending expenses.", "An error occurred while fetching pending expenses.", 500)); // [Error Response] [memory:1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user