diff --git a/Marco.Pms.Model/Dtos/Expenses/CreateExpensesDto.cs b/Marco.Pms.Model/Dtos/Expenses/CreateExpensesDto.cs index d4e9b8d..53c8170 100644 --- a/Marco.Pms.Model/Dtos/Expenses/CreateExpensesDto.cs +++ b/Marco.Pms.Model/Dtos/Expenses/CreateExpensesDto.cs @@ -16,7 +16,6 @@ namespace Marco.Pms.Model.Dtos.Expenses public required string SupplerName { get; set; } public required double Amount { get; set; } public int? NoOfPersons { get; set; } = 0; - public required Guid StatusId { get; set; } public bool PreApproved { get; set; } = false; public required List BillAttachments { get; set; } } diff --git a/Marco.Pms.Model/Dtos/Expenses/ExpenseRecordDto.cs b/Marco.Pms.Model/Dtos/Expenses/ExpenseRecordDto.cs new file mode 100644 index 0000000..ef18799 --- /dev/null +++ b/Marco.Pms.Model/Dtos/Expenses/ExpenseRecordDto.cs @@ -0,0 +1,9 @@ +namespace Marco.Pms.Model.Dtos.Expenses +{ + public class ExpenseRecordDto + { + public Guid ExpenseId { get; set; } + public Guid StatusId { get; set; } + public string? Description { get; set; } + } +} diff --git a/Marco.Pms.Model/Dtos/Expenses/UpdateExpensesDto.cs b/Marco.Pms.Model/Dtos/Expenses/UpdateExpensesDto.cs new file mode 100644 index 0000000..28c4faf --- /dev/null +++ b/Marco.Pms.Model/Dtos/Expenses/UpdateExpensesDto.cs @@ -0,0 +1,23 @@ +using Marco.Pms.Model.Utilities; + +namespace Marco.Pms.Model.Dtos.Expenses +{ + public class UpdateExpensesDto + { + public required Guid Id { get; set; } + public required Guid ProjectId { get; set; } + public required Guid ExpensesTypeId { get; set; } + public required Guid PaymentModeId { get; set; } + public required Guid PaidById { get; set; } + public DateTime TransactionDate { get; set; } = DateTime.Now; + public string? TransactionId { get; set; } + public required string Description { get; set; } + public string? Location { get; set; } + public string? GSTNumber { get; set; } + public required string SupplerName { get; set; } + public required double Amount { get; set; } + public int? NoOfPersons { get; set; } = 0; + public bool PreApproved { get; set; } = false; + public List? BillAttachments { get; set; } + } +} diff --git a/Marco.Pms.Services/Controllers/ExpenseController.cs b/Marco.Pms.Services/Controllers/ExpenseController.cs index 23aae3f..029b65e 100644 --- a/Marco.Pms.Services/Controllers/ExpenseController.cs +++ b/Marco.Pms.Services/Controllers/ExpenseController.cs @@ -6,6 +6,7 @@ using Marco.Pms.Model.Expenses; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Expanses; using Marco.Pms.Model.ViewModels.Master; +using Marco.Pms.Model.ViewModels.Projects; using Marco.Pms.Services.Service; using MarcoBMS.Services.Helpers; using MarcoBMS.Services.Service; @@ -15,7 +16,6 @@ using Microsoft.EntityFrameworkCore; using System.Text.Json; using Document = Marco.Pms.Model.DocumentManager.Document; -// For more information on enabling Web API for empty projects, visit https://go.microsoft.com/fwlink/?LinkID=397860 namespace Marco.Pms.Services.Controllers { @@ -24,135 +24,37 @@ namespace Marco.Pms.Services.Controllers [Authorize] public class ExpenseController : ControllerBase { + private readonly IDbContextFactory _dbContextFactory; private readonly ApplicationDbContext _context; private readonly UserHelper _userHelper; private readonly PermissionServices _permission; private readonly ILoggingService _logger; private readonly S3UploadService _s3Service; + private readonly IServiceScopeFactory _serviceScopeFactory; private readonly IMapper _mapper; private readonly Guid tenantId; + private static readonly Guid Draft = Guid.Parse("297e0d8f-f668-41b5-bfea-e03b354251c8"); public ExpenseController( + IDbContextFactory dbContextFactory, ApplicationDbContext context, UserHelper userHelper, PermissionServices permission, + IServiceScopeFactory serviceScopeFactory, ILoggingService logger, S3UploadService s3Service, IMapper mapper) { + _dbContextFactory = dbContextFactory; _context = context; _userHelper = userHelper; _permission = permission; _logger = logger; + _serviceScopeFactory = serviceScopeFactory; _s3Service = s3Service; _mapper = mapper; tenantId = userHelper.GetTenantId(); } - [HttpGet("list")] - public async Task GetExpensesList1(string? filter, int pageSize = 20, int pageNumber = 1) - { - var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - var loggedInEmployeeId = loggedInEmployee.Id; - - List? expensesList = null; - var expensesListQuery = _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) - .Where(e => e.TenantId == tenantId) - .OrderByDescending(e => e.CreatedAt) - .Skip((pageNumber - 1) * pageSize) - .Take(pageSize); - - var HasViewAllPermission = await _permission.HasPermission(PermissionsMaster.ExpenseViewAll, loggedInEmployeeId); - var HasViewSelfPermission = await _permission.HasPermission(PermissionsMaster.ExpenseViewSelf, loggedInEmployeeId); - - if (HasViewSelfPermission) - { - expensesListQuery = expensesListQuery.Where(e => e.CreatedById == loggedInEmployeeId); - } - else if (!HasViewAllPermission) - { - _logger.LogWarning("Access DENIED for employee {EmployeeId} attempting to get expanses list.", loggedInEmployeeId); - return Ok(ApiResponse.SuccessResponse(new List(), "No Expense found for current user", 200)); - } - - ExpensesFilter? expenesFilter = null; - if (!string.IsNullOrWhiteSpace(filter)) - { - try - { - var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; - expenesFilter = JsonSerializer.Deserialize(filter, options); - } - catch (Exception ex) - { - _logger.LogError(ex, "[GetExpensesList] Failed to parse filter came from website or mobile"); - - try - { - var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; - string unescapedJsonString = JsonSerializer.Deserialize(filter, options) ?? ""; - expenesFilter = JsonSerializer.Deserialize(unescapedJsonString, options); - } - catch (Exception ex1) - { - _logger.LogError(ex1, "[GetExpensesList] Failed to parse filter came from postman"); - } - } - } - - var projectIds = expenesFilter?.ProjectIds; - var filterStatusIds = expenesFilter?.StatusIds; - var createdByIds = expenesFilter?.CreatedByIds; - var paidById = expenesFilter?.PaidById; - var startDate = expenesFilter?.StartDate; - var endDate = expenesFilter?.EndDate; - - if (startDate != null && endDate != null) - { - expensesListQuery = expensesListQuery.Where(e => e.CreatedAt.Date >= startDate && e.CreatedAt.Date <= endDate); - } - else if (projectIds != null && projectIds.Any()) - { - expensesListQuery = expensesListQuery.Where(e => projectIds.Contains(e.ProjectId)); - } - else if (filterStatusIds != null && filterStatusIds.Any()) - { - expensesListQuery = expensesListQuery.Where(e => filterStatusIds.Contains(e.StatusId)); - } - else if (createdByIds != null && createdByIds.Any() && HasViewAllPermission) - { - expensesListQuery = expensesListQuery.Where(e => createdByIds.Contains(e.CreatedById)); - } - else if (paidById != null && paidById.Any()) - { - expensesListQuery = expensesListQuery.Where(e => paidById.Contains(e.PaidById)); - } - - expensesList = await expensesListQuery.ToListAsync(); - - var response = _mapper.Map>(expensesList); - - var statusIds = expensesList.Select(e => e.StatusId).ToList(); - - var statusMappings = await _context.ExpensesStatusMapping - .Include(sm => sm.NextStatus) - .Where(sm => statusIds.Contains(sm.StatusId)) - .ToListAsync(); - - foreach (var expense in response) - { - var statusMapping = statusMappings.Where(sm => sm.StatusId == expense.Status?.Id).Select(sm => _mapper.Map(sm.NextStatus)).ToList(); - expense.NextStatus = statusMapping; - - } - return StatusCode(200, ApiResponse.SuccessResponse(response, $"{response.Count} records of expenses for you fetched successfully", 200)); - } /// /// Retrieves a paginated list of expenses based on user permissions and optional filters. @@ -161,7 +63,7 @@ namespace Marco.Pms.Services.Controllers /// The number of records to return per page. /// The page number to retrieve. /// A paginated list of expenses. - [HttpGet] // Assuming this is a GET endpoint + [HttpGet("list")] // Assuming this is a GET endpoint public async Task GetExpensesList(string? filter, int pageSize = 20, int pageNumber = 1) { try @@ -180,8 +82,21 @@ namespace Marco.Pms.Services.Controllers } Guid loggedInEmployeeId = loggedInEmployee.Id; - var hasViewAllPermission = await _permission.HasPermission(PermissionsMaster.ExpenseViewAll, loggedInEmployeeId); - var hasViewSelfPermission = await _permission.HasPermission(PermissionsMaster.ExpenseViewSelf, loggedInEmployeeId); + var hasViewSelfPermissionTask = Task.Run(async () => + { + using var scope = _serviceScopeFactory.CreateScope(); + var permissionService = scope.ServiceProvider.GetRequiredService(); + return await permissionService.HasPermission(PermissionsMaster.ExpenseViewSelf, loggedInEmployeeId); + }); + + var hasViewAllPermissionTask = Task.Run(async () => + { + using var scope = _serviceScopeFactory.CreateScope(); + var permissionService = scope.ServiceProvider.GetRequiredService(); + return await permissionService.HasPermission(PermissionsMaster.ExpenseViewAll, loggedInEmployeeId); + }); + + await Task.WhenAll(hasViewSelfPermissionTask, hasViewAllPermissionTask); // 2. --- Build Base Query and Apply Permissions --- // Start with a base IQueryable. Filters will be chained onto this. @@ -196,12 +111,12 @@ namespace Marco.Pms.Services.Controllers .Where(e => e.TenantId == tenantId); // Always filter by TenantId first. // Apply permission-based filtering BEFORE any other filters or pagination. - if (hasViewAllPermission) + if (hasViewAllPermissionTask.Result) { // User has 'View All' permission, no initial restriction on who created the expense. _logger.LogInfo("User {EmployeeId} has 'View All' permission.", loggedInEmployeeId); } - else if (hasViewSelfPermission) + else if (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); @@ -241,7 +156,7 @@ namespace Marco.Pms.Services.Controllers } // Only allow filtering by 'CreatedBy' if the user has 'View All' permission. - if (expenseFilter.CreatedByIds?.Any() == true && hasViewAllPermission) + if (expenseFilter.CreatedByIds?.Any() == true && hasViewAllPermissionTask.Result) { expensesQuery = expensesQuery.Where(e => expenseFilter.CreatedByIds.Contains(e.CreatedById)); } @@ -329,6 +244,433 @@ namespace Marco.Pms.Services.Controllers } } + [HttpGet("details/{id}")] + public string Get(int id) + { + return "value"; + } + + [HttpPost("create")] + public async Task CreateExpense1([FromBody] CreateExpensesDto dto) + { + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var hasUploadPermission = await _permission.HasPermission(PermissionsMaster.ExpenseUpload, loggedInEmployee.Id); + var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, dto.ProjectId); + if (!hasUploadPermission || !hasProjectPermission) + { + _logger.LogWarning("Access DENIED for employee {EmployeeId} for uploading expense on project {ProjectId}.", loggedInEmployee.Id, dto.ProjectId); + return StatusCode(403, ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to Upload expenses for this project", 403)); + } + var isExpensesTypeExist = await _context.ExpensesTypeMaster.AnyAsync(et => et.Id == dto.ExpensesTypeId); + if (!isExpensesTypeExist) + { + _logger.LogWarning("Expenses type not for ID: {ExpensesTypeId} when creating new expense", dto.ExpensesTypeId); + return NotFound(ApiResponse.ErrorResponse("Expanses Type not found", "Expanses Type not found", 404)); + } + var isPaymentModeExist = await _context.PaymentModeMatser.AnyAsync(et => et.Id == dto.PaymentModeId); + if (!isPaymentModeExist) + { + _logger.LogWarning("Payment Mode not for ID: {PaymentModeId} when creating new expense", dto.PaymentModeId); + return NotFound(ApiResponse.ErrorResponse("Payment Mode not found", "Payment Mode not found", 404)); + } + var isStatusExist = await _context.ExpensesStatusMaster.AnyAsync(et => et.Id == Draft); + if (!isStatusExist) + { + _logger.LogWarning("Status not for ID: {PaymentModeId} when creating new expense", dto.PaymentModeId); + return NotFound(ApiResponse.ErrorResponse("Status not found", "Status not found", 404)); + } + + var expense = _mapper.Map(dto); + + expense.CreatedById = loggedInEmployee.Id; + expense.CreatedAt = DateTime.UtcNow; + expense.TenantId = tenantId; + expense.IsActive = true; + expense.StatusId = Draft; + + _context.Expenses.Add(expense); + + Guid batchId = Guid.NewGuid(); + foreach (var attachment in dto.BillAttachments) + { + if (!_s3Service.IsBase64String(attachment.Base64Data)) + { + _logger.LogWarning("Image upload failed: Base64 data is missing While creating new expense entity for project {ProjectId} by employee {EmployeeId}", expense.ProjectId, expense.PaidById); + return BadRequest(ApiResponse.ErrorResponse("Base64 data is missing", "Base64 data is missing", 400)); + } + var base64 = attachment.Base64Data!.Contains(',') + ? attachment.Base64Data[(attachment.Base64Data.IndexOf(",") + 1)..] + : attachment.Base64Data; + + var fileType = _s3Service.GetContentTypeFromBase64(base64); + var fileName = _s3Service.GenerateFileName(fileType, expense.Id, "Expense"); + var objectKey = $"tenant-{tenantId}/project-{expense.ProjectId}/Expenses/{fileName}"; + try + { + await _s3Service.UploadFileAsync(base64, fileType, objectKey); + _logger.LogInfo("Image uploaded to S3 with key: {ObjectKey}", objectKey); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occured while saving image to S3"); + return BadRequest(ApiResponse.ErrorResponse("Cannot upload attachment to S3", new + { + message = ex.Message, + innerexcption = ex.InnerException?.Message, + stackTrace = ex.StackTrace, + source = ex.Source + }, 400)); + } + + var document = new Document + { + BatchId = batchId, + UploadedById = loggedInEmployee.Id, + FileName = attachment.FileName ?? "", + ContentType = attachment.ContentType ?? "", + S3Key = objectKey, + FileSize = attachment.FileSize, + UploadedAt = DateTime.UtcNow, + TenantId = tenantId + }; + _context.Documents.Add(document); + + var billAttachement = new BillAttachments + { + DocumentId = document.Id, + ExpensesId = expense.Id, + TenantId = tenantId + }; + _context.BillAttachments.Add(billAttachement); + } + try + { + await _context.SaveChangesAsync(); + } + catch (DbUpdateException dbEx) + { + _logger.LogError(dbEx, "Error occured while saving Expense, Document and bill attachment entity"); + return BadRequest(ApiResponse.ErrorResponse("Databsae Exception", new + { + Message = dbEx.Message, + StackTrace = dbEx.StackTrace, + Source = dbEx.Source, + innerexcption = new + { + Message = dbEx.InnerException?.Message, + StackTrace = dbEx.InnerException?.StackTrace, + Source = dbEx.InnerException?.Source, + } + }, 400)); + } + _logger.LogInfo("Documents and attachments saved for Expense: {ExpenseId}", expense.Id); + + return StatusCode(201, ApiResponse.SuccessResponse(expense, "Expense created Successfully", 201)); + } + + /// + /// Creates a new expense entry along with its bill attachments. + /// This operation is transactional and performs validations and file uploads in parallel for optimal performance. + /// Permission checks are also run in parallel using IServiceScopeFactory to ensure thread safety. + /// + /// The data transfer object containing expense details and attachments. + /// An IActionResult indicating the result of the creation operation. + [HttpPost] + public async Task CreateExpense([FromBody] CreateExpensesDto dto) + { + _logger.LogDebug("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 + { + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + + // 1. Authorization: Run permission checks in parallel using a service scope for each task. + // This is crucial for thread-safety as IPermissionService is a scoped service. + var hasUploadPermissionTask = Task.Run(async () => + { + using var scope = _serviceScopeFactory.CreateScope(); + var permissionService = scope.ServiceProvider.GetRequiredService(); + return await permissionService.HasPermission(PermissionsMaster.ExpenseUpload, loggedInEmployee.Id); + }); + + var hasProjectPermissionTask = Task.Run(async () => + { + using var scope = _serviceScopeFactory.CreateScope(); + var permissionService = scope.ServiceProvider.GetRequiredService(); + return await permissionService.HasProjectPermission(loggedInEmployee, dto.ProjectId); + }); + + await Task.WhenAll(hasUploadPermissionTask, hasProjectPermissionTask); + + if (!hasUploadPermissionTask.Result || !hasProjectPermissionTask.Result) + { + _logger.LogWarning("Access DENIED for employee {EmployeeId} on project {ProjectId}.", loggedInEmployee.Id, dto.ProjectId); + return StatusCode(403, ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to upload expenses for this project.", 403)); + } + + + // 2. Validation: Check if prerequisite entities exist. + // The method now returns a tuple indicating success or failure. + // Each task creates its own DbContext instance from the factory, making the parallel calls thread-safe. + var projectGetTask = Task.Run(async () => + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.Projects.FirstOrDefaultAsync(p => p.Id == dto.ProjectId); + }); + + var expenseTypeGetTask = Task.Run(async () => + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.ExpensesTypeMaster.FirstOrDefaultAsync(et => et.Id == dto.ExpensesTypeId); + }); + + var paymentModeGetTask = Task.Run(async () => + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.PaymentModeMatser.FirstOrDefaultAsync(pm => pm.Id == dto.PaymentModeId); + }); + + var statusGetTask = Task.Run(async () => + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.ExpensesStatusMaster.FirstOrDefaultAsync(es => es.Id == Draft); + }); + + await Task.WhenAll(projectGetTask, expenseTypeGetTask, paymentModeGetTask, statusGetTask); + + var project = await projectGetTask; + var expenseType = await expenseTypeGetTask; + var paymentMode = await paymentModeGetTask; + var status = await statusGetTask; + + if (project == null) + { + await transaction.RollbackAsync(); // Ensure transaction is terminated before returning. + _logger.LogWarning("Expense creation failed due to validation: Project with ID {ProjectId} not found.", dto.ProjectId); + return NotFound(ApiResponse.ErrorResponse("Project not found.", "Project not found.", 404)); + } + else if (expenseType == null) + { + await transaction.RollbackAsync(); // Ensure transaction is terminated before returning. + _logger.LogWarning("Expense creation failed due to validation: Expense Type with ID {ExpensesTypeId} not found.", dto.ExpensesTypeId); + return NotFound(ApiResponse.ErrorResponse("Expense Type not found.", "Expense Type not found.", 404)); + } + else if (paymentMode == null) + { + await transaction.RollbackAsync(); // Ensure transaction is terminated before returning. + _logger.LogWarning("Expense creation failed due to validation: Payment Mode with ID {PaymentModeId} not found.", dto.PaymentModeId); + return NotFound(ApiResponse.ErrorResponse("Payment Mode not found.", "Payment Mode not found.", 404)); + } + else if (status == null) + { + await transaction.RollbackAsync(); // Ensure transaction is terminated before returning. + _logger.LogWarning("Expense creation failed due to validation: Status with ID {StatusId} not found.", Draft); + return NotFound(ApiResponse.ErrorResponse("Status not found.", "Status not found.", 404)); + } + + // 3. Entity Creation + var expense = _mapper.Map(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(); + + var response = _mapper.Map(expense); + + response.Project = _mapper.Map(project); + response.Status = _mapper.Map(status); + response.PaymentMode = _mapper.Map(paymentMode); + response.ExpensesType = _mapper.Map(expenseType); + + _logger.LogInfo("Successfully created Expense {ExpenseId} for Project {ProjectId}.", expense.Id, expense.ProjectId); + return StatusCode(201, ApiResponse.SuccessResponse(response, "Expense created successfully.", 201)); + } + catch (ArgumentException ex) // For invalid Base64 or other bad arguments. + { + await transaction.RollbackAsync(); + _logger.LogError(ex, "Invalid argument provided during expense creation for project {ProjectId}.", dto.ProjectId); + return BadRequest(ApiResponse.ErrorResponse("Invalid Request Data", new + { + Message = ex.Message, + StackTrace = ex.StackTrace, + Source = ex.Source, + innerexcption = 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 failure). + { + await transaction.RollbackAsync(); + _logger.LogError(ex, "An unhandled exception occurred while creating an expense for project {ProjectId}.", dto.ProjectId); + return StatusCode(500, ApiResponse.ErrorResponse("An internal server error occurred.", new + { + Message = ex.Message, + StackTrace = ex.StackTrace, + Source = ex.Source, + innerexcption = new + { + Message = ex.InnerException?.Message, + StackTrace = ex.InnerException?.StackTrace, + Source = ex.InnerException?.Source, + } + }, 500)); + } + } + + /// + /// Processes and uploads attachments in parallel, then adds the resulting entities to the main DbContext. + /// + private async Task ProcessAndUploadAttachmentsAsync(IEnumerable attachments, Expenses expense, Guid employeeId, Guid tenantId) + { + var batchId = Guid.NewGuid(); + + var processingTasks = attachments.Select(attachment => Task.Run(async () => + { + if (string.IsNullOrWhiteSpace(attachment.Base64Data) || !_s3Service.IsBase64String(attachment.Base64Data)) + throw new ArgumentException("Invalid or missing Base64 data for an attachment."); + + 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}"; + + // Upload and create entities + 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); + })).ToList(); + + var results = await Task.WhenAll(processingTasks); + + // This part is thread-safe as it runs after all parallel 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); + } + + /// + /// A private static helper method to create Document and BillAttachment entities. + /// + 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); + } + + [HttpPost("action")] + public async Task ChangeStatus([FromBody] ExpenseRecordDto model) + { + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var exsitingExpenses = await _context.Expenses + .FirstOrDefaultAsync(e => e.Id == model.ExpenseId && e.TenantId == tenantId); + + if (exsitingExpenses == null) + { + return NotFound(ApiResponse.ErrorResponse("Expense not found", "Expense not found", 404)); + } + + exsitingExpenses.StatusId = model.StatusId; + + try + { + await _context.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException dbEx) + { + // --- Step 3: Handle Concurrency Conflicts --- + // This happens if another user modified the project after we fetched it. + _logger.LogError(dbEx, "Error occured while update status of expanse."); + return StatusCode(500, ApiResponse.ErrorResponse("Error occured while update status of expanse.", new + { + Message = dbEx.Message, + StackTrace = dbEx.StackTrace, + Source = dbEx.Source, + innerexcption = new + { + Message = dbEx.InnerException?.Message, + StackTrace = dbEx.InnerException?.StackTrace, + Source = dbEx.InnerException?.Source, + } + }, 500)); + } + var response = _mapper.Map(exsitingExpenses); + return Ok(ApiResponse.SuccessResponse(response)); + } + + [HttpPut("edit/{id}")] + public async Task UpdateExpanse(Guid id, [FromBody] UpdateExpensesDto model) + { + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var exsitingExpense = await _context.Expenses.FirstOrDefaultAsync(e => e.Id == model.Id && e.TenantId == tenantId); + + + if (exsitingExpense == null) + { + return NotFound(ApiResponse.ErrorResponse("Expense not found", "Expense not found", 404)); + } + _mapper.Map(model, exsitingExpense); + _context.Entry(exsitingExpense).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 StatusCode(409, ApiResponse.ErrorResponse("Conflict occurred.", "This project has been modified by someone else. Please refresh and try again.", 409)); + } + var response = _mapper.Map(exsitingExpense); + return Ok(ApiResponse.SuccessResponse(response)); + } + + [HttpDelete("delete/{id}")] + public void Delete(int id) + { + } + #region =================================================================== Helper Functions =================================================================== + /// /// Deserializes the filter string, handling multiple potential formats (e.g., direct JSON vs. escaped JSON string). /// @@ -373,153 +715,6 @@ namespace Marco.Pms.Services.Controllers return expenseFilter; } - [HttpGet("details/{id}")] - public string Get(int id) - { - return "value"; - } - - [HttpPost("create")] - public async Task Post([FromBody] CreateExpensesDto dto) - { - var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - var hasUploadPermission = await _permission.HasPermission(PermissionsMaster.ExpenseUpload, loggedInEmployee.Id); - var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, dto.ProjectId); - if (!hasUploadPermission || !hasProjectPermission) - { - _logger.LogWarning("Access DENIED for employee {EmployeeId} for uploading expense on project {ProjectId}.", loggedInEmployee.Id, dto.ProjectId); - return StatusCode(403, ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to Upload expenses for this project", 403)); - } - var isExpensesTypeExist = await _context.ExpensesTypeMaster.AnyAsync(et => et.Id == dto.ExpensesTypeId); - if (!isExpensesTypeExist) - { - _logger.LogWarning("Expenses type not for ID: {ExpensesTypeId} when creating new expense", dto.ExpensesTypeId); - return NotFound(ApiResponse.ErrorResponse("Expanses Type not found", "Expanses Type not found", 404)); - } - var isPaymentModeExist = await _context.PaymentModeMatser.AnyAsync(et => et.Id == dto.PaymentModeId); - if (!isPaymentModeExist) - { - _logger.LogWarning("Payment Mode not for ID: {PaymentModeId} when creating new expense", dto.PaymentModeId); - return NotFound(ApiResponse.ErrorResponse("Payment Mode not found", "Payment Mode not found", 404)); - } - var isStatusExist = await _context.ExpensesStatusMaster.AnyAsync(et => et.Id == dto.StatusId); - if (!isStatusExist) - { - _logger.LogWarning("Status not for ID: {PaymentModeId} when creating new expense", dto.PaymentModeId); - return NotFound(ApiResponse.ErrorResponse("Status not found", "Status not found", 404)); - } - var expense = new Expenses - { - ProjectId = dto.ProjectId, - ExpensesTypeId = dto.ExpensesTypeId, - PaymentModeId = dto.PaymentModeId, - PaidById = dto.PaidById, - CreatedById = loggedInEmployee.Id, - TransactionDate = dto.TransactionDate, - CreatedAt = DateTime.UtcNow, - TransactionId = dto.TransactionId, - Description = dto.Description, - Location = dto.Location, - GSTNumber = dto.GSTNumber, - SupplerName = dto.SupplerName, - Amount = dto.Amount, - NoOfPersons = dto.NoOfPersons, - StatusId = dto.StatusId, - PreApproved = dto.PreApproved, - IsActive = true, - TenantId = tenantId - }; - _context.Expenses.Add(expense); - - - Guid batchId = Guid.NewGuid(); - foreach (var attachment in dto.BillAttachments) - { - //if (!_s3Service.IsBase64String(attachment.Base64Data)) - //{ - // _logger.LogWarning("Image upload failed: Base64 data is missing While creating new expense entity for project {ProjectId} by employee {EmployeeId}", expense.ProjectId, expense.PaidById); - // return BadRequest(ApiResponse.ErrorResponse("Base64 data is missing", "Base64 data is missing", 400)); - //} - var base64 = attachment.Base64Data!.Contains(',') - ? attachment.Base64Data[(attachment.Base64Data.IndexOf(",") + 1)..] - : attachment.Base64Data; - - var fileType = _s3Service.GetContentTypeFromBase64(base64); - var fileName = _s3Service.GenerateFileName(fileType, expense.Id, "Expense"); - var objectKey = $"tenant-{tenantId}/project-{expense.ProjectId}/Expenses/{fileName}"; - try - { - await _s3Service.UploadFileAsync(base64, fileType, objectKey); - _logger.LogInfo("Image uploaded to S3 with key: {ObjectKey}", objectKey); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error occured while saving image to S3"); - return BadRequest(ApiResponse.ErrorResponse("Cannot upload attachment to S3", new - { - message = ex.Message, - innerexcption = ex.InnerException?.Message, - stackTrace = ex.StackTrace, - source = ex.Source - }, 400)); - } - - var document = new Document - { - BatchId = batchId, - UploadedById = loggedInEmployee.Id, - FileName = attachment.FileName ?? "", - ContentType = attachment.ContentType ?? "", - S3Key = objectKey, - //Base64Data = attachment.Base64Data, - FileSize = attachment.FileSize, - UploadedAt = DateTime.UtcNow, - TenantId = tenantId - }; - _context.Documents.Add(document); - - var billAttachement = new BillAttachments - { - DocumentId = document.Id, - ExpensesId = expense.Id, - TenantId = tenantId - }; - _context.BillAttachments.Add(billAttachement); - } - try - { - await _context.SaveChangesAsync(); - } - catch (DbUpdateException dbEx) - { - _logger.LogError(dbEx, "Error occured while saving Expense, Document and bill attachment entity"); - return BadRequest(ApiResponse.ErrorResponse("Databsae Exception", new - { - Message = dbEx.Message, - StackTrace = dbEx.StackTrace, - Source = dbEx.Source, - innerexcption = new - { - Message = dbEx.InnerException?.Message, - StackTrace = dbEx.InnerException?.StackTrace, - Source = dbEx.InnerException?.Source, - } - }, 400)); - } - _logger.LogInfo("Documents and attachments saved for Expense: {ExpenseId}", expense.Id); - - return StatusCode(201, ApiResponse.SuccessResponse(expense, "Expense created Successfully", 201)); - } - - - [HttpPut("edit/{id}")] - public void Put(int id, [FromBody] string value) - { - } - - [HttpDelete("delete/{id}")] - public void Delete(int id) - { - } + #endregion } } diff --git a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs index fad5b78..ea34613 100644 --- a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs @@ -1,4 +1,5 @@ using AutoMapper; +using Marco.Pms.Model.Dtos.Expenses; using Marco.Pms.Model.Dtos.Master; using Marco.Pms.Model.Dtos.Project; using Marco.Pms.Model.Employees; @@ -76,7 +77,8 @@ namespace Marco.Pms.Services.MappingProfiles #region ======================================================= Expenses ======================================================= CreateMap(); - + CreateMap(); + CreateMap(); #endregion diff --git a/Marco.Pms.Services/Service/S3UploadService.cs b/Marco.Pms.Services/Service/S3UploadService.cs index 1d98a33..b07093c 100644 --- a/Marco.Pms.Services/Service/S3UploadService.cs +++ b/Marco.Pms.Services/Service/S3UploadService.cs @@ -41,6 +41,7 @@ namespace Marco.Pms.Services.Service if (allowedFilesType == null || !allowedFilesType.Contains(fileType)) { + _logger.LogWarning("Unsupported file type. {FileType}", fileType); throw new InvalidOperationException("Unsupported file type."); }