1337 lines
68 KiB
C#
1337 lines
68 KiB
C#
using AutoMapper;
|
|
using Marco.Pms.DataAccess.Data;
|
|
using Marco.Pms.Helpers.Utility;
|
|
using Marco.Pms.Model.Dtos.Expenses;
|
|
using Marco.Pms.Model.Employees;
|
|
using Marco.Pms.Model.Entitlements;
|
|
using Marco.Pms.Model.Expenses;
|
|
using Marco.Pms.Model.MongoDBModels.Expenses;
|
|
using Marco.Pms.Model.MongoDBModels.Utility;
|
|
using Marco.Pms.Model.Utilities;
|
|
using Marco.Pms.Model.ViewModels.Activities;
|
|
using Marco.Pms.Model.ViewModels.Expanses;
|
|
using Marco.Pms.Model.ViewModels.Expenses;
|
|
using Marco.Pms.Model.ViewModels.Master;
|
|
using Marco.Pms.Model.ViewModels.Projects;
|
|
using Marco.Pms.Services.Helpers;
|
|
using Marco.Pms.Services.Service.ServiceInterfaces;
|
|
using MarcoBMS.Services.Service;
|
|
using Microsoft.CodeAnalysis;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using System.Text.Json;
|
|
using Document = Marco.Pms.Model.DocumentManager.Document;
|
|
|
|
namespace Marco.Pms.Services.Service
|
|
{
|
|
public class ExpensesService : IExpensesService
|
|
{
|
|
private readonly IDbContextFactory<ApplicationDbContext> _dbContextFactory;
|
|
private readonly ApplicationDbContext _context;
|
|
private readonly ILoggingService _logger;
|
|
private readonly S3UploadService _s3Service;
|
|
private readonly IServiceScopeFactory _serviceScopeFactory;
|
|
private readonly UtilityMongoDBHelper _updateLogHelper;
|
|
private readonly CacheUpdateHelper _cache;
|
|
private readonly IMapper _mapper;
|
|
private static readonly Guid Draft = Guid.Parse("297e0d8f-f668-41b5-bfea-e03b354251c8");
|
|
private static readonly Guid Rejected = Guid.Parse("d1ee5eec-24b6-4364-8673-a8f859c60729");
|
|
private static readonly string Collection = "ExpensesModificationLog";
|
|
public ExpensesService(
|
|
IDbContextFactory<ApplicationDbContext> dbContextFactory,
|
|
ApplicationDbContext context,
|
|
IServiceScopeFactory serviceScopeFactory,
|
|
UtilityMongoDBHelper updateLogHelper,
|
|
CacheUpdateHelper cache,
|
|
ILoggingService logger,
|
|
S3UploadService s3Service,
|
|
IMapper mapper)
|
|
{
|
|
_dbContextFactory = dbContextFactory;
|
|
_context = context;
|
|
_logger = logger;
|
|
_cache = cache;
|
|
_serviceScopeFactory = serviceScopeFactory;
|
|
_updateLogHelper = updateLogHelper;
|
|
_s3Service = s3Service;
|
|
_mapper = mapper;
|
|
}
|
|
|
|
#region =================================================================== Get Functions ===================================================================
|
|
|
|
/// <summary>
|
|
/// Retrieves a paginated list of expenses based on user permissions and optional filters.
|
|
/// </summary>
|
|
/// <param name="filter">A URL-encoded JSON string containing filter criteria. See <see cref="ExpensesFilter"/>.</param>
|
|
/// <param name="pageSize">The number of records to return per page.</param>
|
|
/// <param name="pageNumber">The page number to retrieve.</param>
|
|
/// <returns>A paginated list of expenses.</returns>
|
|
public async Task<ApiResponse<object>> GetExpensesListAsync(Employee loggedInEmployee, Guid tenantId, string? filter, int pageSize, int pageNumber)
|
|
{
|
|
try
|
|
{
|
|
_logger.LogInfo(
|
|
"Attempting to fetch expenses list for PageNumber: {PageNumber}, PageSize: {PageSize} with Filter: {Filter}",
|
|
pageNumber, pageSize, filter ?? "");
|
|
|
|
// 1. --- Get User Permissions ---
|
|
if (loggedInEmployee == null)
|
|
{
|
|
// This is an authentication/authorization issue. The user should be logged in.
|
|
_logger.LogWarning("Could not find an employee for the current logged-in user.");
|
|
return ApiResponse<object>.ErrorResponse("User not found or not authenticated.", 403);
|
|
}
|
|
Guid loggedInEmployeeId = loggedInEmployee.Id;
|
|
List<ExpenseList> expenseVM = new List<ExpenseList>();
|
|
var totalEntites = 0;
|
|
var hasViewSelfPermissionTask = Task.Run(async () =>
|
|
{
|
|
using var scope = _serviceScopeFactory.CreateScope();
|
|
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
|
return await permissionService.HasPermission(PermissionsMaster.ExpenseViewSelf, loggedInEmployeeId);
|
|
});
|
|
|
|
var hasViewAllPermissionTask = Task.Run(async () =>
|
|
{
|
|
using var scope = _serviceScopeFactory.CreateScope();
|
|
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
|
return await permissionService.HasPermission(PermissionsMaster.ExpenseViewAll, loggedInEmployeeId);
|
|
});
|
|
|
|
await Task.WhenAll(hasViewSelfPermissionTask, hasViewAllPermissionTask);
|
|
|
|
if (!hasViewAllPermissionTask.Result && !hasViewSelfPermissionTask.Result)
|
|
{
|
|
// User has neither required permission. Deny access.
|
|
_logger.LogWarning("Access DENIED for employee {EmployeeId} attempting to get expenses list.", loggedInEmployeeId);
|
|
return ApiResponse<object>.SuccessResponse(new List<ExpenseList>(), "You do not have permission to view any expenses.", 200);
|
|
}
|
|
|
|
|
|
// 2. --- Deserialize Filter and Apply ---
|
|
ExpensesFilter? expenseFilter = TryDeserializeFilter(filter);
|
|
|
|
var (totalPages, totalCount, expenseList) = await _cache.GetExpenseListAsync(tenantId, loggedInEmployeeId, hasViewAllPermissionTask.Result, hasViewSelfPermissionTask.Result,
|
|
pageNumber, pageSize, expenseFilter);
|
|
|
|
if (expenseList == null)
|
|
{
|
|
|
|
// 3. --- Build Base Query and Apply Permissions ---
|
|
// Start with a base IQueryable. Filters will be chained onto this.
|
|
var expensesQuery = _context.Expenses
|
|
.Where(e => e.TenantId == tenantId); // Always filter by TenantId first.
|
|
|
|
await _cache.AddExpensesListToCache(expenses: await expensesQuery.ToListAsync());
|
|
|
|
// Apply permission-based filtering BEFORE any other filters or pagination.
|
|
|
|
if (!hasViewAllPermissionTask.Result && hasViewSelfPermissionTask.Result)
|
|
{
|
|
// User only has 'View Self' permission, so restrict the query to their own expenses.
|
|
_logger.LogInfo("User {EmployeeId} has 'View Self' permission. Restricting query to their expenses.", loggedInEmployeeId);
|
|
expensesQuery = expensesQuery.Where(e => e.CreatedById == loggedInEmployeeId);
|
|
}
|
|
|
|
if (expenseFilter != null)
|
|
{
|
|
// CRITICAL FIX: Apply filters cumulatively using multiple `if` statements, not `if-else if`.
|
|
if (expenseFilter.StartDate.HasValue && expenseFilter.EndDate.HasValue)
|
|
{
|
|
expensesQuery = expensesQuery.Where(e => e.CreatedAt.Date >= expenseFilter.StartDate.Value.Date && e.CreatedAt.Date <= expenseFilter.EndDate.Value.Date);
|
|
}
|
|
|
|
if (expenseFilter.ProjectIds?.Any() == true)
|
|
{
|
|
expensesQuery = expensesQuery.Where(e => expenseFilter.ProjectIds.Contains(e.ProjectId));
|
|
}
|
|
|
|
if (expenseFilter.StatusIds?.Any() == true)
|
|
{
|
|
expensesQuery = expensesQuery.Where(e => expenseFilter.StatusIds.Contains(e.StatusId));
|
|
}
|
|
|
|
if (expenseFilter.PaidById?.Any() == true)
|
|
{
|
|
expensesQuery = expensesQuery.Where(e => expenseFilter.PaidById.Contains(e.PaidById));
|
|
}
|
|
|
|
// Only allow filtering by 'CreatedBy' if the user has 'View All' permission.
|
|
if (expenseFilter.CreatedByIds?.Any() == true && hasViewAllPermissionTask.Result)
|
|
{
|
|
expensesQuery = expensesQuery.Where(e => expenseFilter.CreatedByIds.Contains(e.CreatedById));
|
|
}
|
|
}
|
|
|
|
// 4. --- Apply Ordering and Pagination ---
|
|
// This should be the last step before executing the query.
|
|
|
|
totalEntites = await expensesQuery.CountAsync();
|
|
|
|
var paginatedQuery = expensesQuery
|
|
.OrderByDescending(e => e.CreatedAt)
|
|
.Skip((pageNumber - 1) * pageSize)
|
|
.Take(pageSize);
|
|
|
|
// 5. --- Execute Query and Map Results ---
|
|
var expensesList = await paginatedQuery.ToListAsync();
|
|
|
|
if (!expensesList.Any())
|
|
{
|
|
_logger.LogInfo("No expenses found matching the criteria for employee {EmployeeId}.", loggedInEmployeeId);
|
|
return ApiResponse<object>.SuccessResponse(new List<ExpenseList>(), "No expenses found for the given criteria.", 200);
|
|
}
|
|
|
|
expenseVM = await GetAllExpnesRelatedTables(expensesList);
|
|
totalPages = (int)Math.Ceiling((double)totalEntites / pageSize);
|
|
|
|
}
|
|
else
|
|
{
|
|
expenseVM = await GetAllExpnesRelatedTables(_mapper.Map<List<Expenses>>(expenseList));
|
|
totalEntites = (int)totalCount;
|
|
}
|
|
// 7. --- Return Final Success Response ---
|
|
var message = $"{expenseVM.Count} expense records fetched successfully.";
|
|
_logger.LogInfo(message);
|
|
var response = new
|
|
{
|
|
CurrentPage = pageNumber,
|
|
TotalPages = totalPages,
|
|
TotalEntites = totalEntites,
|
|
Data = expenseVM,
|
|
};
|
|
return ApiResponse<object>.SuccessResponse(response, message, 200);
|
|
}
|
|
catch (DbUpdateException dbEx)
|
|
{
|
|
_logger.LogError(dbEx, "Databsae Exception occured while fetching list expenses");
|
|
return ApiResponse<object>.ErrorResponse("Databsae Exception", new
|
|
{
|
|
Message = dbEx.Message,
|
|
StackTrace = dbEx.StackTrace,
|
|
Source = dbEx.Source,
|
|
InnerException = new
|
|
{
|
|
Message = dbEx.InnerException?.Message,
|
|
StackTrace = dbEx.InnerException?.StackTrace,
|
|
Source = dbEx.InnerException?.Source,
|
|
}
|
|
}, 500);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error occured while fetching list expenses");
|
|
return ApiResponse<object>.ErrorResponse("Error Occured", new
|
|
{
|
|
Message = ex.Message,
|
|
StackTrace = ex.StackTrace,
|
|
Source = ex.Source,
|
|
InnerException = new
|
|
{
|
|
Message = ex.InnerException?.Message,
|
|
StackTrace = ex.InnerException?.StackTrace,
|
|
Source = ex.InnerException?.Source,
|
|
}
|
|
}, 500);
|
|
}
|
|
}
|
|
public async Task<ApiResponse<object>> GetExpenseDetailsAsync(Guid id, Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
try
|
|
{
|
|
var expenseDetails = await _cache.GetExpenseDetailsById(id, tenantId);
|
|
if (expenseDetails == null)
|
|
{
|
|
expenseDetails = await _cache.AddExpenseByIdAsync(id, tenantId);
|
|
if (expenseDetails == null)
|
|
{
|
|
_logger.LogWarning("User attempted to fetch expense details with ID {ExpenseId}, but not found in both database and cache", id);
|
|
return ApiResponse<object>.ErrorResponse("Expense Not Found", "Expense Not Found", 404);
|
|
}
|
|
}
|
|
var vm = await GetAllExpnesRelatedTablesFromMongoDB(expenseDetails);
|
|
|
|
_logger.LogInfo("Employee {EmployeeId} successfully fetched expense details with ID {ExpenseId}", loggedInEmployee.Id, vm.Id);
|
|
return ApiResponse<object>.SuccessResponse(vm, "Successfully fetched the details of expense", 200);
|
|
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "An unhandled exception occurred while fetching an expense details {ExpenseId}.", id);
|
|
return ApiResponse<object>.ErrorResponse("An internal server error occurred.", new
|
|
{
|
|
Message = ex.Message,
|
|
StackTrace = ex.StackTrace,
|
|
Source = ex.Source,
|
|
InnerException = new
|
|
{
|
|
Message = ex.InnerException?.Message,
|
|
StackTrace = ex.InnerException?.StackTrace,
|
|
Source = ex.InnerException?.Source,
|
|
}
|
|
}, 500);
|
|
}
|
|
}
|
|
public async Task<ApiResponse<object>> GetSupplerNameListAsync(Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
try
|
|
{
|
|
var supplerNameList = await _context.Expenses.Where(e => e.TenantId == tenantId).Select(e => e.SupplerName).Distinct().ToListAsync();
|
|
_logger.LogInfo("Employee {EmployeeId} fetched list of suppler names from expenses in a tenant {TenantId}", loggedInEmployee.Id, tenantId);
|
|
return ApiResponse<object>.SuccessResponse(supplerNameList, $"{supplerNameList.Count} records of suppler names fetched from expense", 200);
|
|
}
|
|
catch (DbUpdateException dbEx)
|
|
{
|
|
_logger.LogError(dbEx, "Databsae Exception occured while fetching suppler name list from expense");
|
|
return ApiResponse<object>.ErrorResponse("Databsae Exception", new
|
|
{
|
|
Message = dbEx.Message,
|
|
StackTrace = dbEx.StackTrace,
|
|
Source = dbEx.Source,
|
|
InnerException = new
|
|
{
|
|
Message = dbEx.InnerException?.Message,
|
|
StackTrace = dbEx.InnerException?.StackTrace,
|
|
Source = dbEx.InnerException?.Source,
|
|
}
|
|
}, 500);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region =================================================================== Post Functions ===================================================================
|
|
|
|
/// <summary>
|
|
/// Creates a new expense entry along with its bill attachments.
|
|
/// This operation is transactional and performs validations and file uploads concurrently for optimal performance
|
|
/// by leveraging async/await without unnecessary thread-pool switching via Task.Run.
|
|
/// </summary>
|
|
/// <param name="dto">The data transfer object containing expense details and attachments.</param>
|
|
/// <returns>An IActionResult indicating the result of the creation operation.</returns>
|
|
public async Task<ApiResponse<object>> CreateExpenseAsync(CreateExpensesDto dto, Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
_logger.LogDebug("Starting CreateExpense for Project {ProjectId}", dto.ProjectId);
|
|
// The entire operation is wrapped in a transaction to ensure data consistency.
|
|
await using var transaction = await _context.Database.BeginTransactionAsync();
|
|
|
|
try
|
|
{
|
|
// 1. Authorization & Validation: Run all I/O-bound checks concurrently using factories for safety.
|
|
|
|
// PERMISSION CHECKS: Use IServiceScopeFactory for thread-safe access to scoped services.
|
|
var hasUploadPermissionTask = Task.Run(async () => // Task.Run is acceptable here to create a new scope, but let's do it cleaner.
|
|
{
|
|
using var scope = _serviceScopeFactory.CreateScope();
|
|
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
|
return await permissionService.HasPermission(PermissionsMaster.ExpenseUpload, loggedInEmployee.Id);
|
|
});
|
|
|
|
var hasProjectPermissionTask = Task.Run(async () =>
|
|
{
|
|
using var scope = _serviceScopeFactory.CreateScope();
|
|
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
|
return await permissionService.HasProjectPermission(loggedInEmployee, dto.ProjectId);
|
|
});
|
|
|
|
// VALIDATION CHECKS: Use IDbContextFactory for thread-safe, parallel database queries.
|
|
// Each task gets its own DbContext instance.
|
|
var projectTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == dto.ProjectId);
|
|
});
|
|
var paidByTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.Employees.AsNoTracking().FirstOrDefaultAsync(e => e.Id == dto.PaidById);
|
|
});
|
|
var expenseTypeTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.ExpensesTypeMaster.AsNoTracking().FirstOrDefaultAsync(et => et.Id == dto.ExpensesTypeId);
|
|
});
|
|
var paymentModeTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.PaymentModeMatser.AsNoTracking().FirstOrDefaultAsync(pm => pm.Id == dto.PaymentModeId);
|
|
});
|
|
var statusMappingTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.ExpensesStatusMapping
|
|
.Include(s => s.Status)
|
|
.Include(s => s.NextStatus)
|
|
.AsNoTracking()
|
|
.Where(es => es.StatusId == Draft && es.Status != null)
|
|
.GroupBy(s => s.StatusId)
|
|
.Select(g => new
|
|
{
|
|
Status = g.Select(s => s.Status).FirstOrDefault(),
|
|
NextStatus = g.Select(s => s.NextStatus).ToList()
|
|
})
|
|
.FirstOrDefaultAsync();
|
|
});
|
|
|
|
|
|
// Await all prerequisite checks at once.
|
|
await Task.WhenAll(
|
|
hasUploadPermissionTask, hasProjectPermissionTask,
|
|
projectTask, expenseTypeTask, paymentModeTask, statusMappingTask, paidByTask
|
|
);
|
|
|
|
// 2. Aggregate and Check Results
|
|
if (!await hasUploadPermissionTask || !await hasProjectPermissionTask)
|
|
{
|
|
_logger.LogWarning("Access DENIED for employee {EmployeeId} on project {ProjectId}.", loggedInEmployee.Id, dto.ProjectId);
|
|
return ApiResponse<object>.ErrorResponse("Access Denied.", "You do not have permission to upload expenses for this project.", 403);
|
|
}
|
|
|
|
var validationErrors = new List<string>();
|
|
var project = await projectTask;
|
|
var expenseType = await expenseTypeTask;
|
|
var paymentMode = await paymentModeTask;
|
|
var statusMapping = await statusMappingTask;
|
|
var paidBy = await paidByTask;
|
|
|
|
if (project == null) validationErrors.Add("Project not found.");
|
|
if (paidBy == null) validationErrors.Add("Paid by employee not found");
|
|
if (expenseType == null) validationErrors.Add("Expense Type not found.");
|
|
if (paymentMode == null) validationErrors.Add("Payment Mode not found.");
|
|
if (statusMapping == null) validationErrors.Add("Default status 'Draft' not found.");
|
|
|
|
if (validationErrors.Any())
|
|
{
|
|
await transaction.RollbackAsync();
|
|
var errorMessage = string.Join(" ", validationErrors);
|
|
_logger.LogWarning("Expense creation failed due to validation errors: {ValidationErrors}", errorMessage);
|
|
return ApiResponse<object>.ErrorResponse("Invalid input data.", errorMessage, 400);
|
|
}
|
|
|
|
// 3. Entity Creation
|
|
var expense = _mapper.Map<Expenses>(dto);
|
|
expense.CreatedById = loggedInEmployee.Id;
|
|
expense.CreatedAt = DateTime.UtcNow;
|
|
expense.TenantId = tenantId;
|
|
expense.IsActive = true;
|
|
expense.StatusId = Draft;
|
|
|
|
_context.Expenses.Add(expense);
|
|
|
|
// 4. Process Attachments
|
|
if (dto.BillAttachments?.Any() ?? false)
|
|
{
|
|
await ProcessAndUploadAttachmentsAsync(dto.BillAttachments, expense, loggedInEmployee.Id, tenantId);
|
|
}
|
|
|
|
// 5. Database Commit
|
|
await _context.SaveChangesAsync();
|
|
|
|
// 6. Transaction Commit
|
|
await transaction.CommitAsync();
|
|
|
|
await _cache.AddExpenseByObjectAsync(expense);
|
|
|
|
var response = _mapper.Map<ExpenseList>(expense);
|
|
response.PaidBy = _mapper.Map<BasicEmployeeVM>(paidBy);
|
|
response.Project = _mapper.Map<ProjectInfoVM>(project);
|
|
response.Status = _mapper.Map<ExpensesStatusMasterVM>(statusMapping!.Status);
|
|
response.NextStatus = _mapper.Map<List<ExpensesStatusMasterVM>>(statusMapping.NextStatus);
|
|
response.PaymentMode = _mapper.Map<PaymentModeMatserVM>(paymentMode);
|
|
response.ExpensesType = _mapper.Map<ExpensesTypeMasterVM>(expenseType);
|
|
|
|
_logger.LogInfo("Successfully created Expense {ExpenseId} for Project {ProjectId}.", expense.Id, expense.ProjectId);
|
|
return ApiResponse<object>.SuccessResponse(response, "Expense created successfully.", 201);
|
|
}
|
|
catch (DbUpdateException dbEx)
|
|
{
|
|
await transaction.RollbackAsync();
|
|
_logger.LogError(dbEx, "Databsae Exception occured while adding expense");
|
|
return ApiResponse<object>.ErrorResponse("Databsae Exception", new
|
|
{
|
|
Message = dbEx.Message,
|
|
StackTrace = dbEx.StackTrace,
|
|
Source = dbEx.Source,
|
|
InnerException = new
|
|
{
|
|
Message = dbEx.InnerException?.Message,
|
|
StackTrace = dbEx.InnerException?.StackTrace,
|
|
Source = dbEx.InnerException?.Source,
|
|
}
|
|
}, 500);
|
|
}
|
|
catch (ArgumentException ex) // Catches bad Base64 from attachment pre-validation
|
|
{
|
|
_logger.LogError(ex, "Invalid argument during expense creation for project {ProjectId}.", dto.ProjectId);
|
|
return ApiResponse<object>.ErrorResponse("Invalid Request Data.", new
|
|
{
|
|
Message = ex.Message,
|
|
StackTrace = ex.StackTrace,
|
|
Source = ex.Source,
|
|
InnerException = new
|
|
{
|
|
Message = ex.InnerException?.Message,
|
|
StackTrace = ex.InnerException?.StackTrace,
|
|
Source = ex.InnerException?.Source,
|
|
}
|
|
}, 400);
|
|
}
|
|
catch (Exception ex) // General-purpose catch for unexpected errors (e.g., S3 or DB connection failure)
|
|
{
|
|
_logger.LogError(ex, "An unhandled exception occurred while creating an expense for project {ProjectId}.", dto.ProjectId);
|
|
return ApiResponse<object>.ErrorResponse("An internal server error occurred.", new
|
|
{
|
|
Message = ex.Message,
|
|
StackTrace = ex.StackTrace,
|
|
Source = ex.Source,
|
|
InnerException = new
|
|
{
|
|
Message = ex.InnerException?.Message,
|
|
StackTrace = ex.InnerException?.StackTrace,
|
|
Source = ex.InnerException?.Source,
|
|
}
|
|
}, 500);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Changes the status of an expense record, performing validation, permission checks, and logging.
|
|
/// </summary>
|
|
/// <param name="model">The DTO containing the expense ID and the target status ID.</param>
|
|
/// <param name="loggedInEmployee">The employee performing the action.</param>
|
|
/// <param name="tenantId">The ID of the tenant owning the expense.</param>
|
|
/// <returns>An ApiResponse containing the updated expense details or an error.</returns>
|
|
public async Task<ApiResponse<object>> ChangeStatusAsync(ExpenseRecordDto model, Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
// --- 1. Fetch Existing Expense ---
|
|
// We include all related entities needed for the final response mapping to avoid multiple database trips.
|
|
// The query also ensures we don't process a request if the status is already the one requested.
|
|
var existingExpense = await _context.Expenses
|
|
.Include(e => e.ExpensesType)
|
|
.Include(e => e.Project)
|
|
.Include(e => e.PaidBy)
|
|
.ThenInclude(e => e!.JobRole)
|
|
.Include(e => e.PaymentMode)
|
|
.Include(e => e.Status)
|
|
.Include(e => e.CreatedBy)
|
|
.FirstOrDefaultAsync(e => e.Id == model.ExpenseId && e.StatusId != model.StatusId && e.TenantId == tenantId);
|
|
|
|
if (existingExpense == null)
|
|
{
|
|
// Use structured logging for better searchability.
|
|
_logger.LogWarning("Attempted to change status for a non-existent or already-updated expense. ExpenseId: {ExpenseId}, TenantId: {TenantId}", model.ExpenseId, tenantId);
|
|
return ApiResponse<object>.ErrorResponse("Expense not found or status is already set.", "Expense not found", 404);
|
|
}
|
|
|
|
_logger.LogInfo("Initiating status change for ExpenseId: {ExpenseId} from StatusId: {OldStatusId} to {NewStatusId}",
|
|
existingExpense.Id, existingExpense.StatusId, model.StatusId);
|
|
|
|
// --- 2. Concurrently Check Prerequisites ---
|
|
// We run status validation and permission fetching in parallel for efficiency.
|
|
// Using Task.Run with an async lambda is the standard way to start a concurrent,
|
|
// CPU- or I/O-bound operation on a background thread.
|
|
|
|
// Task to validate if the requested status change is a valid transition.
|
|
var statusMappingTask = Task.Run(async () =>
|
|
{
|
|
// 'await using' ensures the DbContext created by the factory is properly disposed
|
|
// within the scope of this background task.
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.ExpensesStatusMapping
|
|
.Include(s => s.NextStatus)
|
|
.FirstOrDefaultAsync(s => s.StatusId == existingExpense.StatusId && s.NextStatusId == model.StatusId && s.TenantId == tenantId);
|
|
});
|
|
|
|
// Task to fetch all permissions required for the *target* status.
|
|
var statusPermissionMappingTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.StatusPermissionMapping
|
|
.Where(sp => sp.StatusId == model.StatusId && sp.TenantId == tenantId)
|
|
.ToListAsync();
|
|
});
|
|
|
|
// Await both tasks to complete concurrently.
|
|
await Task.WhenAll(statusMappingTask, statusPermissionMappingTask);
|
|
|
|
// Now you can safely get the results.
|
|
var statusMapping = await statusMappingTask;
|
|
var statusPermissions = await statusPermissionMappingTask;
|
|
|
|
// --- 3. Validate Status Transition and Permissions ---
|
|
if (statusMapping == null)
|
|
{
|
|
_logger.LogWarning("Invalid status transition attempted for ExpenseId: {ExpenseId}. From StatusId: {FromStatusId} to {ToStatusId}",
|
|
existingExpense.Id, existingExpense.StatusId, model.StatusId);
|
|
return ApiResponse<object>.ErrorResponse("This status change is not allowed.", "Invalid Transition", 400);
|
|
}
|
|
|
|
// Check permissions. The logic is:
|
|
// 1. If the target status has specific permissions defined, the user must have at least one of them.
|
|
// 2. If no permissions are defined for the target status, only the original creator of the expense can change it.
|
|
bool hasPermission = false;
|
|
if (statusPermissions.Any())
|
|
{
|
|
// Using a scope to resolve scoped services like PermissionServices.
|
|
using var scope = _serviceScopeFactory.CreateScope();
|
|
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
|
foreach (var sp in statusPermissions)
|
|
{
|
|
if (await permissionService.HasPermission(sp.PermissionId, loggedInEmployee.Id))
|
|
{
|
|
hasPermission = true;
|
|
break; // User has one of the required permissions, no need to check further.
|
|
}
|
|
}
|
|
}
|
|
else if (existingExpense.CreatedById == loggedInEmployee.Id)
|
|
{
|
|
// Fallback: If no permissions are required for the status, allow the creator to make the change.
|
|
hasPermission = true;
|
|
}
|
|
|
|
if (!hasPermission)
|
|
{
|
|
_logger.LogWarning("Access DENIED for EmployeeId: {EmployeeId} attempting to change status of ExpenseId: {ExpenseId} to StatusId: {NewStatusId}",
|
|
loggedInEmployee.Id, existingExpense.Id, model.StatusId);
|
|
return ApiResponse<object>.ErrorResponse("You do not have the required permissions to perform this action.", "Access Denied", 403);
|
|
}
|
|
|
|
// --- 4. Update Expense and Add Log (in a transaction) ---
|
|
var existingEntityBson = _updateLogHelper.EntityToBsonDocument(existingExpense); // Capture state for audit log BEFORE changes.
|
|
|
|
existingExpense.StatusId = statusMapping.NextStatusId;
|
|
existingExpense.Status = statusMapping.NextStatus; // Assigning the included entity for the response mapping.
|
|
|
|
_context.ExpenseLogs.Add(new ExpenseLog
|
|
{
|
|
ExpenseId = existingExpense.Id,
|
|
Action = $"Status changed to '{statusMapping.NextStatus!.Name}'",
|
|
UpdatedById = loggedInEmployee.Id,
|
|
Comment = model.Comment,
|
|
TenantId = tenantId
|
|
});
|
|
|
|
try
|
|
{
|
|
await _context.SaveChangesAsync();
|
|
_logger.LogInfo("Successfully updated status for ExpenseId: {ExpenseId} to StatusId: {NewStatusId}", existingExpense.Id, existingExpense.StatusId);
|
|
}
|
|
catch (DbUpdateConcurrencyException dbEx)
|
|
{
|
|
// This error occurs if the record was modified by someone else after we fetched it.
|
|
_logger.LogError(dbEx, "Concurrency conflict while updating status for ExpenseId: {ExpenseId}. The record may have been modified by another user.", existingExpense.Id);
|
|
return ApiResponse<object>.ErrorResponse("The expense was modified by another user. Please refresh and try again.", "Concurrency Error", 409); // 409 Conflict is appropriate
|
|
}
|
|
|
|
// --- 5. Perform Post-Save Actions (Audit Log and Fetching Next State for UI) ---
|
|
try
|
|
{
|
|
// Task to save the detailed audit log to a separate system (e.g., MongoDB).
|
|
var mongoDBTask = _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
|
|
{
|
|
EntityId = existingExpense.Id.ToString(),
|
|
UpdatedById = loggedInEmployee.Id.ToString(),
|
|
OldObject = existingEntityBson,
|
|
UpdatedAt = DateTime.UtcNow
|
|
}, Collection);
|
|
|
|
var cacheUpdateTask = _cache.ReplaceExpenseAsync(existingExpense);
|
|
|
|
// Task to get all possible next statuses from the *new* current state to help the UI.
|
|
// NOTE: This now fetches a list of all possible next states, which is more useful for a UI.
|
|
var getNextStatusesTask = _dbContextFactory.CreateDbContextAsync().ContinueWith(t =>
|
|
{
|
|
var dbContext = t.Result;
|
|
return dbContext.ExpensesStatusMapping
|
|
.Include(s => s.NextStatus)
|
|
.Where(s => s.StatusId == existingExpense.StatusId && s.NextStatus != null && s.TenantId == tenantId)
|
|
.Select(s => s.NextStatus) // Select only the status object
|
|
.ToListAsync()
|
|
.ContinueWith(res =>
|
|
{
|
|
dbContext.Dispose(); // Ensure the context is disposed
|
|
return res.Result;
|
|
});
|
|
}).Unwrap();
|
|
|
|
await Task.WhenAll(mongoDBTask, getNextStatusesTask, cacheUpdateTask);
|
|
|
|
var nextPossibleStatuses = await getNextStatusesTask;
|
|
|
|
var response = _mapper.Map<ExpenseList>(existingExpense);
|
|
if (nextPossibleStatuses != null)
|
|
{
|
|
// The response DTO should have a property like: public List<ExpensesStatusMasterVM> NextAvailableStatuses { get; set; }
|
|
response.NextStatus = _mapper.Map<List<ExpensesStatusMasterVM>>(nextPossibleStatuses);
|
|
}
|
|
|
|
return ApiResponse<object>.SuccessResponse(response);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// This catch block handles errors from post-save operations like MongoDB logging.
|
|
// The primary update was successful, but we must log this failure.
|
|
_logger.LogError(ex, "Error occurred during post-save operations for ExpenseId: {ExpenseId} (e.g., audit logging). The primary status change was successful.", existingExpense.Id);
|
|
|
|
// We can still return a success response because the main operation succeeded,
|
|
// but we should not block the user for a failed audit log.
|
|
// Alternatively, if audit logging is critical, you could return an error.
|
|
// Here, we choose to return success but log the ancillary failure.
|
|
var response = _mapper.Map<ExpenseList>(existingExpense);
|
|
return ApiResponse<object>.SuccessResponse(response, "Status updated, but a post-processing error occurred.");
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region =================================================================== Put Functions ===================================================================
|
|
|
|
public async Task<ApiResponse<object>> UpdateExpanseAsync(Guid id, UpdateExpensesDto model, Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
if (id != model.Id)
|
|
{
|
|
_logger.LogWarning("Id provided by path parameter and Id from body not matches for employee {EmployeeId}", loggedInEmployee.Id);
|
|
return ApiResponse<object>.ErrorResponse("Invalid Parameters", "Invalid Parameters", 400);
|
|
}
|
|
var existingExpense = await _context.Expenses
|
|
.Include(e => e.ExpensesType)
|
|
.Include(e => e.Project)
|
|
.Include(e => e.PaidBy)
|
|
.ThenInclude(e => e!.JobRole)
|
|
.Include(e => e.PaymentMode)
|
|
.Include(e => e.Status)
|
|
.Include(e => e.CreatedBy)
|
|
.FirstOrDefaultAsync(e =>
|
|
e.Id == model.Id &&
|
|
e.CreatedById == loggedInEmployee.Id &&
|
|
(e.StatusId == Draft || e.StatusId == Rejected) &&
|
|
e.TenantId == tenantId);
|
|
|
|
|
|
if (existingExpense == null)
|
|
{
|
|
_logger.LogWarning("User attempted to update expense with ID {ExpenseId}, but not found in database", id);
|
|
return ApiResponse<object>.ErrorResponse("Expense not found", "Expense not found", 404);
|
|
}
|
|
|
|
var existingEntityBson = _updateLogHelper.EntityToBsonDocument(existingExpense); // Capture state for audit log BEFORE changes
|
|
_mapper.Map(model, existingExpense);
|
|
_context.Entry(existingExpense).State = EntityState.Modified;
|
|
|
|
try
|
|
{
|
|
await _context.SaveChangesAsync();
|
|
_logger.LogInfo("Successfully updated project {ProjectId} by user {UserId}.", id, loggedInEmployee.Id);
|
|
}
|
|
catch (DbUpdateConcurrencyException ex)
|
|
{
|
|
// --- Step 3: Handle Concurrency Conflicts ---
|
|
// This happens if another user modified the project after we fetched it.
|
|
_logger.LogError(ex, "Concurrency conflict while updating project {ProjectId} ", id);
|
|
return ApiResponse<object>.ErrorResponse("Conflict occurred.", "This project has been modified by someone else. Please refresh and try again.", 409);
|
|
}
|
|
|
|
if (model.BillAttachments?.Any() ?? false)
|
|
{
|
|
var newBillAttachments = model.BillAttachments.Where(ba => ba.DocumentId == null && ba.IsActive).ToList();
|
|
if (newBillAttachments.Any())
|
|
{
|
|
await ProcessAndUploadAttachmentsAsync(newBillAttachments, existingExpense, loggedInEmployee.Id, tenantId);
|
|
try
|
|
{
|
|
await _context.SaveChangesAsync();
|
|
_logger.LogInfo("{Count} New attachments added while updating expense {ExpenseId} by employee {EmployeeId}",
|
|
newBillAttachments.Count, existingExpense.Id, loggedInEmployee.Id);
|
|
}
|
|
catch (DbUpdateException dbEx)
|
|
{
|
|
_logger.LogError(dbEx, "Databsae Exception occured while adding new attachments during updating expense");
|
|
return ApiResponse<object>.ErrorResponse("Databsae Exception", new
|
|
{
|
|
Message = dbEx.Message,
|
|
StackTrace = dbEx.StackTrace,
|
|
Source = dbEx.Source,
|
|
InnerException = new
|
|
{
|
|
Message = dbEx.InnerException?.Message,
|
|
StackTrace = dbEx.InnerException?.StackTrace,
|
|
Source = dbEx.InnerException?.Source,
|
|
}
|
|
}, 500);
|
|
|
|
}
|
|
}
|
|
|
|
var deleteBillAttachments = model.BillAttachments.Where(ba => ba.DocumentId != null && !ba.IsActive).ToList();
|
|
if (deleteBillAttachments.Any())
|
|
{
|
|
var documentIds = deleteBillAttachments.Select(d => d.DocumentId!.Value).ToList();
|
|
try
|
|
{
|
|
await DeleteAttachemnts(documentIds);
|
|
_logger.LogInfo("{Count} Attachments deleted while updating expense {ExpenseId} by employee {EmployeeId}",
|
|
deleteBillAttachments.Count, existingExpense.Id, loggedInEmployee.Id);
|
|
}
|
|
catch (DbUpdateException dbEx)
|
|
{
|
|
_logger.LogError(dbEx, "Databsae Exception occured while deleting attachments during updating expense");
|
|
return ApiResponse<object>.ErrorResponse("Databsae Exception", new
|
|
{
|
|
Message = dbEx.Message,
|
|
StackTrace = dbEx.StackTrace,
|
|
Source = dbEx.Source,
|
|
InnerException = new
|
|
{
|
|
Message = dbEx.InnerException?.Message,
|
|
StackTrace = dbEx.InnerException?.StackTrace,
|
|
Source = dbEx.InnerException?.Source,
|
|
}
|
|
}, 500);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Exception occured while deleting attachments during updating expense");
|
|
return ApiResponse<object>.ErrorResponse("Exception occured while deleting attachments during updating expense ", new
|
|
{
|
|
Message = ex.Message,
|
|
StackTrace = ex.StackTrace,
|
|
Source = ex.Source,
|
|
InnerException = new
|
|
{
|
|
Message = ex.InnerException?.Message,
|
|
StackTrace = ex.InnerException?.StackTrace,
|
|
Source = ex.InnerException?.Source,
|
|
}
|
|
}, 500);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
try
|
|
{
|
|
// Task to save the detailed audit log to a separate system (e.g., MongoDB).
|
|
var mongoDBTask = _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
|
|
{
|
|
EntityId = existingExpense.Id.ToString(),
|
|
UpdatedById = loggedInEmployee.Id.ToString(),
|
|
OldObject = existingEntityBson,
|
|
UpdatedAt = DateTime.UtcNow
|
|
}, Collection);
|
|
|
|
// Task to get all possible next statuses from the *new* current state to help the UI.
|
|
// NOTE: This now fetches a list of all possible next states, which is more useful for a UI.
|
|
var getNextStatusesTask = _dbContextFactory.CreateDbContextAsync().ContinueWith(t =>
|
|
{
|
|
var dbContext = t.Result;
|
|
return dbContext.ExpensesStatusMapping
|
|
.Include(s => s.NextStatus)
|
|
.Where(s => s.StatusId == existingExpense.StatusId && s.NextStatus != null && s.TenantId == tenantId)
|
|
.Select(s => s.NextStatus) // Select only the status object
|
|
.ToListAsync()
|
|
.ContinueWith(res =>
|
|
{
|
|
dbContext.Dispose(); // Ensure the context is disposed
|
|
return res.Result;
|
|
});
|
|
}).Unwrap();
|
|
|
|
var cacheUpdateTask = _cache.ReplaceExpenseAsync(existingExpense);
|
|
|
|
await Task.WhenAll(mongoDBTask, getNextStatusesTask, cacheUpdateTask);
|
|
|
|
var nextPossibleStatuses = getNextStatusesTask.Result;
|
|
|
|
var response = _mapper.Map<ExpenseList>(existingExpense);
|
|
if (nextPossibleStatuses != null)
|
|
{
|
|
// The response DTO should have a property like: public List<ExpensesStatusMasterVM> NextAvailableStatuses { get; set; }
|
|
response.NextStatus = _mapper.Map<List<ExpensesStatusMasterVM>>(nextPossibleStatuses);
|
|
}
|
|
|
|
return ApiResponse<object>.SuccessResponse(response, "Expense Updated Successfully", 200);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// This catch block handles errors from post-save operations like MongoDB logging.
|
|
// The primary update was successful, but we must log this failure.
|
|
_logger.LogError(ex, "Error occurred during post-save operations for ExpenseId: {ExpenseId} (e.g., audit logging). The primary status change was successful.", existingExpense.Id);
|
|
|
|
// We can still return a success response because the main operation succeeded,
|
|
// but we should not block the user for a failed audit log.
|
|
// Alternatively, if audit logging is critical, you could return an error.
|
|
// Here, we choose to return success but log the ancillary failure.
|
|
var response = _mapper.Map<ExpenseList>(existingExpense);
|
|
return ApiResponse<object>.SuccessResponse(response, "Status updated, but a post-processing error occurred.");
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region =================================================================== Delete Functions ===================================================================
|
|
|
|
public async Task<ApiResponse<object>> DeleteExpanseAsync(Guid id, Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
var expenseQuery = _context.Expenses.Where(e => e.Id == id && e.StatusId == Draft && e.CreatedById == loggedInEmployee.Id && e.TenantId == tenantId);
|
|
|
|
var hasAprrovePermissionTask = Task.Run(async () =>
|
|
{
|
|
using var scope = _serviceScopeFactory.CreateScope();
|
|
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
|
return await permissionService.HasPermission(PermissionsMaster.ExpenseApprove, loggedInEmployee.Id);
|
|
});
|
|
|
|
var hasAprrovePermission = await hasAprrovePermissionTask;
|
|
if (!hasAprrovePermission)
|
|
{
|
|
expenseQuery = expenseQuery.Where(e => e.CreatedById == loggedInEmployee.Id);
|
|
}
|
|
|
|
var existingExpense = await expenseQuery.FirstOrDefaultAsync();
|
|
if (existingExpense == null)
|
|
{
|
|
var message = hasAprrovePermission ? "Expenses not found" : "Expense cannot be deleted";
|
|
if (hasAprrovePermission)
|
|
{
|
|
_logger.LogWarning("Employee {EmployeeId} attempted to delete expense {ExpenseId}, but not found in database", loggedInEmployee.Id, id);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("Employee {EmployeeId} attempted to delete expense {ExpenseId}, Which is created by another employee", loggedInEmployee.Id, id);
|
|
}
|
|
return ApiResponse<object>.ErrorResponse(message, message, 400);
|
|
}
|
|
var documentIds = await _context.BillAttachments
|
|
.Where(ba => ba.ExpensesId == existingExpense.Id)
|
|
.Select(ba => ba.DocumentId)
|
|
.ToListAsync();
|
|
|
|
var existingEntityBson = _updateLogHelper.EntityToBsonDocument(existingExpense);
|
|
|
|
_context.Expenses.Remove(existingExpense);
|
|
try
|
|
{
|
|
await _context.SaveChangesAsync();
|
|
_logger.LogInfo("Employeee {EmployeeId} successfully deleted the expense {EmpenseId}", loggedInEmployee.Id, id);
|
|
}
|
|
catch (DbUpdateException dbEx)
|
|
{
|
|
_logger.LogError(dbEx, "Databsae Exception occured while deleting expense");
|
|
return ApiResponse<object>.ErrorResponse("Databsae Exception", new
|
|
{
|
|
Message = dbEx.Message,
|
|
StackTrace = dbEx.StackTrace,
|
|
Source = dbEx.Source,
|
|
InnerException = new
|
|
{
|
|
Message = dbEx.InnerException?.Message,
|
|
StackTrace = dbEx.InnerException?.StackTrace,
|
|
Source = dbEx.InnerException?.Source,
|
|
}
|
|
}, 500);
|
|
}
|
|
try
|
|
{
|
|
var attachmentDeletionTask = Task.Run(async () =>
|
|
{
|
|
await DeleteAttachemnts(documentIds);
|
|
});
|
|
|
|
var cacheTask = Task.Run(async () =>
|
|
{
|
|
await _cache.DeleteExpenseAsync(id, tenantId);
|
|
});
|
|
var mongoDBTask = _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
|
|
{
|
|
EntityId = existingExpense.Id.ToString(),
|
|
UpdatedById = loggedInEmployee.Id.ToString(),
|
|
OldObject = existingEntityBson,
|
|
UpdatedAt = DateTime.UtcNow
|
|
}, Collection);
|
|
|
|
await Task.WhenAll(attachmentDeletionTask, cacheTask, mongoDBTask);
|
|
}
|
|
catch (DbUpdateException dbEx)
|
|
{
|
|
_logger.LogError(dbEx, "Databsae Exception occured while deleting attachments during updating expense");
|
|
return ApiResponse<object>.ErrorResponse("Databsae Exception", new
|
|
{
|
|
Message = dbEx.Message,
|
|
StackTrace = dbEx.StackTrace,
|
|
Source = dbEx.Source,
|
|
InnerException = new
|
|
{
|
|
Message = dbEx.InnerException?.Message,
|
|
StackTrace = dbEx.InnerException?.StackTrace,
|
|
Source = dbEx.InnerException?.Source,
|
|
}
|
|
}, 500);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Exception occured while deleting attachments during updating expense");
|
|
return ApiResponse<object>.ErrorResponse("Exception occured while deleting attachments during updating expense ", new
|
|
{
|
|
Message = ex.Message,
|
|
StackTrace = ex.StackTrace,
|
|
Source = ex.Source,
|
|
InnerException = new
|
|
{
|
|
Message = ex.InnerException?.Message,
|
|
StackTrace = ex.InnerException?.StackTrace,
|
|
Source = ex.InnerException?.Source,
|
|
}
|
|
}, 500);
|
|
}
|
|
return ApiResponse<object>.SuccessResponse("Success", "Expense Deleted Successfully", 200);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region =================================================================== Helper Functions ===================================================================
|
|
|
|
private async Task<List<ExpenseList>> GetAllExpnesRelatedTables(List<Expenses> model)
|
|
{
|
|
List<ExpenseList> expenseList = new List<ExpenseList>();
|
|
var projectIds = model.Select(m => m.ProjectId).ToList();
|
|
var statusIds = model.Select(m => m.StatusId).ToList();
|
|
var expensesTypeIds = model.Select(m => m.ExpensesTypeId).ToList();
|
|
var paymentModeIds = model.Select(m => m.PaymentModeId).ToList();
|
|
var createdByIds = model.Select(m => m.CreatedById).ToList();
|
|
var paidByIds = model.Select(m => m.PaidById).ToList();
|
|
|
|
var projectTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.Projects.AsNoTracking().Where(p => projectIds.Contains(p.Id)).ToListAsync();
|
|
});
|
|
var paidByTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.Employees.AsNoTracking().Where(e => paidByIds.Contains(e.Id)).ToListAsync();
|
|
});
|
|
var createdByTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.Employees.AsNoTracking().Where(e => createdByIds.Contains(e.Id)).ToListAsync();
|
|
});
|
|
var expenseTypeTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.ExpensesTypeMaster.AsNoTracking().Where(et => expensesTypeIds.Contains(et.Id)).ToListAsync();
|
|
});
|
|
var paymentModeTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.PaymentModeMatser.AsNoTracking().Where(pm => paymentModeIds.Contains(pm.Id)).ToListAsync();
|
|
});
|
|
var statusMappingTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.ExpensesStatusMapping
|
|
.Include(s => s.Status)
|
|
.Include(s => s.NextStatus)
|
|
.AsNoTracking()
|
|
.Where(es => statusIds.Contains(es.StatusId) && es.Status != null)
|
|
.GroupBy(s => s.StatusId)
|
|
.Select(g => new
|
|
{
|
|
StatusId = g.Key,
|
|
Status = g.Select(s => s.Status).FirstOrDefault(),
|
|
NextStatus = g.Select(s => s.NextStatus).ToList()
|
|
}).ToListAsync();
|
|
});
|
|
var statusTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.ExpensesStatusMaster
|
|
.AsNoTracking()
|
|
.Where(es => statusIds.Contains(es.Id))
|
|
.ToListAsync();
|
|
});
|
|
|
|
// Await all prerequisite checks at once.
|
|
await Task.WhenAll(projectTask, expenseTypeTask, paymentModeTask, statusMappingTask, paidByTask, createdByTask, statusTask);
|
|
|
|
var projects = projectTask.Result;
|
|
var expenseTypes = expenseTypeTask.Result;
|
|
var paymentModes = paymentModeTask.Result;
|
|
var statusMappings = statusMappingTask.Result;
|
|
var paidBys = paidByTask.Result;
|
|
var createdBys = createdByTask.Result;
|
|
|
|
expenseList = model.Select(m =>
|
|
{
|
|
var response = _mapper.Map<ExpenseList>(m);
|
|
|
|
response.Project = projects.Where(p => p.Id == m.ProjectId).Select(p => _mapper.Map<ProjectInfoVM>(p)).FirstOrDefault();
|
|
response.PaidBy = paidBys.Where(p => p.Id == m.PaidById).Select(p => _mapper.Map<BasicEmployeeVM>(p)).FirstOrDefault();
|
|
response.CreatedBy = createdBys.Where(e => e.Id == m.CreatedById).Select(e => _mapper.Map<BasicEmployeeVM>(e)).FirstOrDefault();
|
|
response.Status = statusMappings.Where(s => s.StatusId == m.StatusId).Select(s => _mapper.Map<ExpensesStatusMasterVM>(s.Status)).FirstOrDefault();
|
|
if (response.Status == null)
|
|
{
|
|
var status = statusTask.Result;
|
|
response.Status = status.Where(s => s.Id == m.StatusId).Select(s => _mapper.Map<ExpensesStatusMasterVM>(s)).FirstOrDefault();
|
|
}
|
|
response.NextStatus = statusMappings.Where(s => s.StatusId == m.StatusId).Select(s => _mapper.Map<List<ExpensesStatusMasterVM>>(s.NextStatus)).FirstOrDefault();
|
|
response.PaymentMode = paymentModes.Where(pm => pm.Id == m.PaymentModeId).Select(pm => _mapper.Map<PaymentModeMatserVM>(pm)).FirstOrDefault();
|
|
response.ExpensesType = expenseTypes.Where(et => et.Id == m.ExpensesTypeId).Select(et => _mapper.Map<ExpensesTypeMasterVM>(et)).FirstOrDefault();
|
|
|
|
return response;
|
|
}).ToList();
|
|
|
|
return expenseList;
|
|
}
|
|
private async Task<ExpenseDetailsVM> GetAllExpnesRelatedTablesFromMongoDB(ExpenseDetailsMongoDB model)
|
|
{
|
|
var projectTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == Guid.Parse(model.ProjectId));
|
|
});
|
|
var paidByTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.Employees.AsNoTracking().FirstOrDefaultAsync(e => e.Id == Guid.Parse(model.PaidById));
|
|
});
|
|
var createdByTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.Employees.AsNoTracking().FirstOrDefaultAsync(e => e.Id == Guid.Parse(model.CreatedById));
|
|
});
|
|
var expenseTypeTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.ExpensesTypeMaster.AsNoTracking().FirstOrDefaultAsync(et => et.Id == Guid.Parse(model.ExpensesTypeId));
|
|
});
|
|
var paymentModeTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.PaymentModeMatser.AsNoTracking().FirstOrDefaultAsync(pm => pm.Id == Guid.Parse(model.PaymentModeId));
|
|
});
|
|
var statusMappingTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.ExpensesStatusMapping
|
|
.Include(s => s.Status)
|
|
.Include(s => s.NextStatus)
|
|
.AsNoTracking()
|
|
.Where(es => es.StatusId == Guid.Parse(model.StatusId) && es.Status != null)
|
|
.GroupBy(s => s.StatusId)
|
|
.Select(g => new
|
|
{
|
|
Status = g.Select(s => s.Status).FirstOrDefault(),
|
|
NextStatus = g.Select(s => s.NextStatus).ToList()
|
|
}).FirstOrDefaultAsync();
|
|
});
|
|
var statusTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.ExpensesStatusMaster
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(es => es.Id == Guid.Parse(model.StatusId));
|
|
});
|
|
// Await all prerequisite checks at once.
|
|
await Task.WhenAll(projectTask, expenseTypeTask, paymentModeTask, statusMappingTask, paidByTask, createdByTask, statusTask);
|
|
|
|
var project = projectTask.Result;
|
|
var expenseType = expenseTypeTask.Result;
|
|
var paymentMode = paymentModeTask.Result;
|
|
var statusMapping = statusMappingTask.Result;
|
|
var paidBy = paidByTask.Result;
|
|
var createdBy = createdByTask.Result;
|
|
|
|
var response = _mapper.Map<ExpenseDetailsVM>(model);
|
|
|
|
response.Project = _mapper.Map<ProjectInfoVM>(project);
|
|
response.PaidBy = _mapper.Map<BasicEmployeeVM>(paidBy);
|
|
response.CreatedBy = _mapper.Map<BasicEmployeeVM>(createdBy);
|
|
response.PaymentMode = _mapper.Map<PaymentModeMatserVM>(paymentMode);
|
|
response.ExpensesType = _mapper.Map<ExpensesTypeMasterVM>(expenseType);
|
|
if (statusMapping != null)
|
|
{
|
|
response.Status = _mapper.Map<ExpensesStatusMasterVM>(statusMapping.Status);
|
|
if (response.Status == null)
|
|
{
|
|
var status = statusTask.Result;
|
|
response.Status = _mapper.Map<ExpensesStatusMasterVM>(status);
|
|
}
|
|
response.NextStatus = _mapper.Map<List<ExpensesStatusMasterVM>>(statusMapping.NextStatus);
|
|
}
|
|
|
|
foreach (var document in model.Documents)
|
|
{
|
|
var vm = response.Documents.FirstOrDefault(d => d.DocumentId == Guid.Parse(document.DocumentId));
|
|
|
|
vm!.PreSignedUrl = _s3Service.GeneratePreSignedUrl(document.S3Key);
|
|
vm!.ThumbPreSignedUrl = _s3Service.GeneratePreSignedUrl(document.ThumbS3Key);
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Deserializes the filter string, handling multiple potential formats (e.g., direct JSON vs. escaped JSON string).
|
|
/// </summary>
|
|
/// <param name="filter">The JSON filter string from the request.</param>
|
|
/// <returns>An <see cref="ExpensesFilter"/> object or null if deserialization fails.</returns>
|
|
private ExpensesFilter? TryDeserializeFilter(string? filter)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(filter))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
|
ExpensesFilter? expenseFilter = null;
|
|
|
|
try
|
|
{
|
|
// First, try to deserialize directly. This is the expected case (e.g., from a web client).
|
|
expenseFilter = JsonSerializer.Deserialize<ExpensesFilter>(filter, options);
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
_logger.LogError(ex, "[{MethodName}] Failed to directly deserialize filter. Attempting to unescape and re-parse. Filter: {Filter}", nameof(TryDeserializeFilter), filter);
|
|
|
|
// If direct deserialization fails, it might be an escaped string (common with tools like Postman or some mobile clients).
|
|
try
|
|
{
|
|
// Unescape the string first, then deserialize the result.
|
|
string unescapedJsonString = JsonSerializer.Deserialize<string>(filter, options) ?? "";
|
|
if (!string.IsNullOrWhiteSpace(unescapedJsonString))
|
|
{
|
|
expenseFilter = JsonSerializer.Deserialize<ExpensesFilter>(unescapedJsonString, options);
|
|
}
|
|
}
|
|
catch (JsonException ex1)
|
|
{
|
|
// If both attempts fail, log the final error and return null.
|
|
_logger.LogError(ex1, "[{MethodName}] All attempts to deserialize the filter failed. Filter will be ignored. Filter: {Filter}", nameof(TryDeserializeFilter), filter);
|
|
return null;
|
|
}
|
|
}
|
|
return expenseFilter;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Processes and uploads attachments concurrently, then adds the resulting entities to the main DbContext.
|
|
/// </summary>
|
|
private async Task ProcessAndUploadAttachmentsAsync(IEnumerable<FileUploadModel> attachments, Expenses expense, Guid employeeId, Guid tenantId)
|
|
{
|
|
// Pre-validate all attachments to fail fast before any uploads.
|
|
foreach (var attachment in attachments)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(attachment.Base64Data) || !_s3Service.IsBase64String(attachment.Base64Data))
|
|
{
|
|
throw new ArgumentException($"Invalid or missing Base64 data for attachment: {attachment.FileName ?? "N/A"}");
|
|
}
|
|
}
|
|
|
|
var batchId = Guid.NewGuid();
|
|
|
|
// Create a list of tasks to be executed concurrently.
|
|
var processingTasks = attachments.Select(attachment =>
|
|
ProcessSingleAttachmentAsync(attachment, expense, employeeId, tenantId, batchId)
|
|
).ToList();
|
|
|
|
var results = await Task.WhenAll(processingTasks);
|
|
|
|
// This part is thread-safe as it runs after all concurrent tasks are complete.
|
|
foreach (var (document, billAttachment) in results)
|
|
{
|
|
_context.Documents.Add(document);
|
|
_context.BillAttachments.Add(billAttachment);
|
|
}
|
|
_logger.LogInfo("{AttachmentCount} attachments processed and staged for saving.", results.Length);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles the logic for a single attachment: upload to S3 and create corresponding entities.
|
|
/// </summary>
|
|
private async Task<(Document document, BillAttachments billAttachment)> ProcessSingleAttachmentAsync(
|
|
FileUploadModel attachment, Expenses expense, Guid employeeId, Guid tenantId, Guid batchId)
|
|
{
|
|
var base64Data = attachment.Base64Data!.Contains(',') ? attachment.Base64Data[(attachment.Base64Data.IndexOf(",") + 1)..] : attachment.Base64Data;
|
|
var fileType = _s3Service.GetContentTypeFromBase64(base64Data);
|
|
var fileName = _s3Service.GenerateFileName(fileType, expense.Id, "Expense");
|
|
var objectKey = $"tenant-{tenantId}/project-{expense.ProjectId}/Expenses/{fileName}";
|
|
|
|
// Await the I/O-bound upload operation directly.
|
|
await _s3Service.UploadFileAsync(base64Data, fileType, objectKey);
|
|
_logger.LogInfo("Uploaded file to S3 with key: {ObjectKey}", objectKey);
|
|
|
|
return CreateAttachmentEntities(batchId, expense.Id, employeeId, tenantId, objectKey, attachment);
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// A private static helper method to create Document and BillAttachment entities.
|
|
/// This remains unchanged as it's a pure data-shaping method.
|
|
/// </summary>
|
|
private static (Document document, BillAttachments billAttachment) CreateAttachmentEntities(
|
|
Guid batchId, Guid expenseId, Guid uploadedById, Guid tenantId, string s3Key, FileUploadModel attachmentDto)
|
|
{
|
|
var document = new Document
|
|
{
|
|
BatchId = batchId,
|
|
UploadedById = uploadedById,
|
|
FileName = attachmentDto.FileName ?? "",
|
|
ContentType = attachmentDto.ContentType ?? "",
|
|
S3Key = s3Key,
|
|
FileSize = attachmentDto.FileSize,
|
|
UploadedAt = DateTime.UtcNow,
|
|
TenantId = tenantId
|
|
};
|
|
var billAttachment = new BillAttachments { Document = document, ExpensesId = expenseId, TenantId = tenantId };
|
|
return (document, billAttachment);
|
|
}
|
|
|
|
private async Task DeleteAttachemnts(List<Guid> documentIds)
|
|
{
|
|
var attachmentTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
var attachments = await dbContext.BillAttachments.AsNoTracking().Where(ba => documentIds.Contains(ba.DocumentId)).ToListAsync();
|
|
|
|
dbContext.BillAttachments.RemoveRange(attachments);
|
|
await dbContext.SaveChangesAsync();
|
|
});
|
|
var documentsTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
var documents = await dbContext.Documents.AsNoTracking().Where(ba => documentIds.Contains(ba.Id)).ToListAsync();
|
|
|
|
if (documents.Any())
|
|
{
|
|
dbContext.Documents.RemoveRange(documents);
|
|
await dbContext.SaveChangesAsync();
|
|
|
|
List<S3DeletionObject> deletionObject = new List<S3DeletionObject>();
|
|
foreach (var document in documents)
|
|
{
|
|
deletionObject.Add(new S3DeletionObject
|
|
{
|
|
Key = document.S3Key
|
|
});
|
|
if (!string.IsNullOrWhiteSpace(document.ThumbS3Key) && document.ThumbS3Key != document.S3Key)
|
|
{
|
|
deletionObject.Add(new S3DeletionObject
|
|
{
|
|
Key = document.ThumbS3Key
|
|
});
|
|
}
|
|
}
|
|
await _updateLogHelper.PushToS3DeletionAsync(deletionObject);
|
|
}
|
|
});
|
|
|
|
await Task.WhenAll(attachmentTask, documentsTask);
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
}
|