using AutoMapper; using Marco.Pms.DataAccess.Data; using Marco.Pms.Helpers.Utility; using Marco.Pms.Model.Dtos.Collection; using Marco.Pms.Model.Dtos.PurchaseInvoice; using Marco.Pms.Model.Employees; using Marco.Pms.Model.Entitlements; using Marco.Pms.Model.Filters; using Marco.Pms.Model.MongoDBModels.Utility; using Marco.Pms.Model.OrganizationModel; using Marco.Pms.Model.Projects; using Marco.Pms.Model.PurchaseInvoice; using Marco.Pms.Model.ServiceProject; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Activities; using Marco.Pms.Model.ViewModels.Collection; using Marco.Pms.Model.ViewModels.Organization; using Marco.Pms.Model.ViewModels.Projects; using Marco.Pms.Model.ViewModels.PurchaseInvoice; using Marco.Pms.Services.Extensions; using Marco.Pms.Services.Service.ServiceInterfaces; using MarcoBMS.Services.Service; using Microsoft.EntityFrameworkCore; using System.Text.Json; using Document = Marco.Pms.Model.DocumentManager.Document; namespace Marco.Pms.Services.Service { public class PurchaseInvoiceService : IPurchaseInvoiceService { private readonly IDbContextFactory _dbContextFactory; private readonly IServiceScopeFactory _serviceScopeFactory; private readonly ILoggingService _logger; private readonly S3UploadService _s3Service; private readonly IMapper _mapper; private readonly Guid DraftInvoiceStatusId = Guid.Parse("8a5ef25e-3c9e-45de-add9-6b1c1df54381"); private readonly Guid DeliveryChallanTypeId = Guid.Parse("ca294108-a586-4207-88c8-163b24305ddc"); public PurchaseInvoiceService(IDbContextFactory dbContextFactory, IServiceScopeFactory serviceScopeFactory, ILoggingService logger, S3UploadService s3Service, IMapper mapper) { _dbContextFactory = dbContextFactory; _serviceScopeFactory = serviceScopeFactory; _logger = logger; _s3Service = s3Service; _mapper = mapper; } #region =================================================================== Purchase Invoice Functions =================================================================== /// /// Retrieves a paged list of purchase invoices for a given tenant with support for /// advanced filters, project-based search across Infra and Service projects, and /// consistent structured logging and error handling. /// /// Optional basic search string (currently not used, kept for backward compatibility). /// JSON string containing advanced filter configuration (list/date/search/group filters). /// Flag to filter active/inactive invoices. /// Number of records per page. /// Current page number (1-based). /// Currently logged-in employee context. /// Tenant identifier for multi-tenant isolation. /// Cancellation token for cooperative cancellation. /// Standard ApiResponse containing a paged invoice list payload. public async Task> GetPurchaseInvoiceListAsync(string? searchString, string? filter, bool isActive, int pageSize, int pageNumber, Employee loggedInEmployee, Guid tenantId, CancellationToken ct) { // Check if the employee has the necessary permissions var viewAllPermissionTask = HasPermissionAsync(PermissionsMaster.ViewAllPurchaseInvoice, loggedInEmployee.Id); var viewSelfPermissionTask = HasPermissionAsync(PermissionsMaster.ViewSelfPurchaseInvoice, loggedInEmployee.Id); var viewAllPermission = await viewAllPermissionTask; var viewSelfPermission = await viewSelfPermissionTask; if (!viewAllPermission && !viewSelfPermission) { _logger.LogWarning("Access Denied: {EmployeeId} do not have permission to view purchase invoice list", loggedInEmployee.Id); return ApiResponse.ErrorResponse("You do not have permission to view purchase invoice list.", "You do not have permission to view purchase invoice list.", 403); } // Basic argument validation and guard clauses if (tenantId == Guid.Empty) { _logger.LogWarning("GetPurchaseInvoiceListAsync called with empty TenantId. EmployeeId: {EmployeeId}", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Tenant information is missing. Please retry with a valid tenant.", 400); } if (pageSize <= 0 || pageNumber <= 0) { _logger.LogWarning( "GetPurchaseInvoiceListAsync called with invalid paging parameters. TenantId: {TenantId}, EmployeeId: {EmployeeId}, PageSize: {PageSize}, PageNumber: {PageNumber}", tenantId, loggedInEmployee.Id, pageSize, pageNumber); return ApiResponse.ErrorResponse("Invalid paging parameters. Page size and page number must be greater than zero.", 400); } // A correlationId can be pushed earlier in middleware and enriched into all logs. // Here it is assumed to be available through some context (e.g. _requestContext). _logger.LogInfo( "GetPurchaseInvoiceListAsync started. TenantId: {TenantId}, EmployeeId: {EmployeeId}, IsActive: {IsActive}, PageSize: {PageSize}, PageNumber: {PageNumber}", tenantId, loggedInEmployee.Id, isActive, pageSize, pageNumber); try { await using var context = await _dbContextFactory.CreateDbContextAsync(ct); // Base query for purchase invoices scoped to tenant and active flag IQueryable query = context.PurchaseInvoiceDetails .Include(pid => pid.Organization) .Include(pid => pid.Supplier) .Include(pid => pid.Status) .Where(pid => pid.IsActive == isActive && pid.TenantId == tenantId); var advanceFilter = TryDeserializeFilter(filter); // Apply filters based on employee permissions if (!viewAllPermission && viewSelfPermission) { query = query.Where(pid => pid.CreatedById == loggedInEmployee.Id); } // Apply ordering, default sort, etc. through your custom extension query = query.ApplyCustomFilters(advanceFilter, "CreatedAt"); if (advanceFilter != null) { // Apply list / dropdown / enum filters if (advanceFilter.Filters != null) { query = query.ApplyListFilters(advanceFilter.Filters); } // Apply created/modified date range filters if (advanceFilter.DateFilter != null) { query = query.ApplyDateFilter(advanceFilter.DateFilter); } // Apply non-project search filters on invoice fields if (advanceFilter.SearchFilters != null) { // NOTE: fixed logic - use && so that Project/ProjectName are excluded var invoiceSearchFilter = advanceFilter.SearchFilters .Where(f => f.Column != "ProjectName" && f.Column != "Project") .ToList(); if (invoiceSearchFilter.Any()) { query = query.ApplySearchFilters(invoiceSearchFilter); } } // Apply grouping if configured if (!string.IsNullOrWhiteSpace(advanceFilter.GroupByColumn)) { query = query.ApplyGroupByFilters(advanceFilter.GroupByColumn); } } bool isProjectFilter = false; // Run project lookups in parallel to reduce latency for project search scenarios. // Each task gets its own DbContext instance from the factory. var infraProjectTask = Task.Run(async () => { await using var projContext = await _dbContextFactory.CreateDbContextAsync(ct); IQueryable infraProjectsQuery = projContext.Projects .Where(p => p.TenantId == tenantId); if (advanceFilter?.SearchFilters != null && advanceFilter.SearchFilters.Any()) { var projectSearchFilter = advanceFilter.SearchFilters .Where(f => f.Column == "ProjectName" || f.Column == "Project") .Select(f => new SearchItem { Column = "Name", Value = f.Value }) .ToList(); if (projectSearchFilter.Any()) { infraProjectsQuery = infraProjectsQuery.ApplySearchFilters(projectSearchFilter); isProjectFilter = true; // NOTE: shared flag, see comment below. } } return await infraProjectsQuery .Select(p => _mapper.Map(p)) .ToListAsync(ct); }, ct); var serviceProjectTask = Task.Run(async () => { await using var projContext = await _dbContextFactory.CreateDbContextAsync(ct); IQueryable serviceProjectsQuery = projContext.ServiceProjects .Where(sp => sp.TenantId == tenantId); if (advanceFilter?.SearchFilters != null && advanceFilter.SearchFilters.Any()) { var projectSearchFilter = advanceFilter.SearchFilters .Where(f => f.Column == "ProjectName" || f.Column == "Project") .Select(f => new SearchItem { Column = "Name", Value = f.Value }) .ToList(); if (projectSearchFilter.Any()) { serviceProjectsQuery = serviceProjectsQuery.ApplySearchFilters(projectSearchFilter); isProjectFilter = true; // This is safe for bool but can be refactored for purity. } } return await serviceProjectsQuery .Select(sp => _mapper.Map(sp)) .ToListAsync(ct); }, ct); await Task.WhenAll(infraProjectTask, serviceProjectTask); var projects = infraProjectTask.Result ?? new List(); if (serviceProjectTask.Result != null && serviceProjectTask.Result.Any()) { projects.AddRange(serviceProjectTask.Result); } // If project filters were involved, constrain invoices to those projects if (isProjectFilter && projects.Any()) { var projectIds = projects.Select(p => p.Id).ToList(); query = query.Where(pid => projectIds.Contains(pid.ProjectId)); } if (!string.IsNullOrWhiteSpace(searchString)) { query = query.Where(pid => pid.Title.Contains(searchString) || (pid.UIDPrefix + "/" + pid.UIDPostfix.ToString().PadLeft(5, '0')).Contains(searchString)); } // Compute total count before paging var totalCount = await query.CountAsync(ct); // Apply paging var purchaseInvoices = await query .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .ToListAsync(ct); var totalPages = totalCount == 0 ? 0 : (int)Math.Ceiling((double)totalCount / pageSize); // Map invoice entities to view model and attach project details var response = purchaseInvoices.Select(pi => { var vm = _mapper.Map(pi); vm.Project = projects.FirstOrDefault(p => p.Id == pi.ProjectId); return vm; }).ToList(); var pagedResult = new { CurrentPage = pageNumber, PageSize = pageSize, TotalPages = totalPages, TotalCount = totalCount, HasPrevious = pageNumber > 1, HasNext = pageNumber < totalPages, Data = response }; _logger.LogInfo( "GetPurchaseInvoiceListAsync completed successfully. TenantId: {TenantId}, EmployeeId: {EmployeeId}, TotalCount: {TotalCount}, ReturnedCount: {ReturnedCount}, PageNumber: {PageNumber}, PageSize: {PageSize}", tenantId, loggedInEmployee.Id, totalCount, response.Count, pageNumber, pageSize); var successMessage = totalCount == 0 ? "No purchase invoices found for the specified criteria." : "Purchase invoice list fetched successfully."; return ApiResponse.SuccessResponse(pagedResult, successMessage, 200); } catch (OperationCanceledException ocex) { // Respect cancellation and return a 499-style semantic code if your ApiResponse supports it _logger.LogError(ocex, "GetPurchaseInvoiceListAsync request was canceled. TenantId: {TenantId}, EmployeeId: {EmployeeId}", tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("The request was canceled by the client or the server. Please retry if this was unintentional.", 499); } catch (DbUpdateException dbEx) { _logger.LogError(dbEx, "Database error while fetching purchase invoices. TenantId: {TenantId}, EmployeeId: {EmployeeId}", tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("A database error occurred while fetching purchase invoices. Please try again later or contact support.", 500); } catch (Exception ex) { _logger.LogError(ex, "Unhandled exception in GetPurchaseInvoiceListAsync. CorrelationId: {CorrelationId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("An unexpected error occurred while fetching purchase invoices. Please try again later or contact support.", 500); } } /// /// Retrieves the details of a specific purchase invoice, including project details and S3 attachment links. /// /// The unique identifier of the Purchase Invoice. /// The employee requesting the data. /// The tenant identifier for data isolation. /// Cancellation token for async operations. /// A wrapped response containing the Purchase Invoice View Model. public async Task> GetPurchaseInvoiceDetailsAsync(Guid id, Employee loggedInEmployee, Guid tenantId, CancellationToken ct) { // 1. Structured Logging: Log entry with context _logger.LogInfo("Fetching Purchase Invoice details. InvoiceId: {InvoiceId}, TenantId: {TenantId}", id, tenantId); try { // Check if the employee has the necessary permissions var viewAllPermissionTask = HasPermissionAsync(PermissionsMaster.ViewAllPurchaseInvoice, loggedInEmployee.Id); var viewSelfPermissionTask = HasPermissionAsync(PermissionsMaster.ViewSelfPurchaseInvoice, loggedInEmployee.Id); var viewAllPermission = await viewAllPermissionTask; var viewSelfPermission = await viewSelfPermissionTask; if (!viewAllPermission && !viewSelfPermission) { _logger.LogWarning("Access Denied: {EmployeeId} do not have permission to view purchase invoice details", loggedInEmployee.Id); return ApiResponse.ErrorResponse("You do not have permission to view purchase invoice details.", "You do not have permission to view purchase invoice details.", 403); } await using var context = await _dbContextFactory.CreateDbContextAsync(ct); // 2. Performance: Use AsNoTracking for read-only queries. // Use AsSplitQuery to avoid Cartesian explosion on multiple Includes. var purchaseInvoice = await context.PurchaseInvoiceDetails .AsNoTracking() .AsSplitQuery() .Include(pid => pid.Organization) .Include(pid => pid.Supplier) .Include(pid => pid.Status) .Include(pid => pid.CreatedBy).ThenInclude(e => e!.JobRole) .Include(pid => pid.UpdatedBy).ThenInclude(e => e!.JobRole) .Where(pid => pid.Id == id && pid.TenantId == tenantId) .FirstOrDefaultAsync(ct); // 3. Validation: Handle Not Found immediately if (purchaseInvoice == null) { _logger.LogWarning("Purchase Invoice not found or inactive. InvoiceId: {InvoiceId}", id); return ApiResponse.ErrorResponse("Purchase invoice not found", "The specified purchase invoice does not exist or has been deleted.", 404); } // 4. Parallel Execution: Fetch Project details efficiently // Note: Assuming these methods return null if not found, rather than throwing. var infraProjectTask = LoadInfraProjectAsync(purchaseInvoice.ProjectId, tenantId); var serviceProjectTask = LoadServiceProjectAsync(purchaseInvoice.ProjectId, tenantId); await Task.WhenAll(infraProjectTask, serviceProjectTask); // Safely retrieve results without blocking .Result var project = await infraProjectTask ?? await serviceProjectTask; if (project == null) { _logger.LogWarning("Data Inconsistency: Project not found for InvoiceId: {InvoiceId}, ProjectId: {ProjectId}", id, purchaseInvoice.ProjectId); return ApiResponse.ErrorResponse("Project not found", "The project associated with this invoice could not be found.", 404); } // 5. Optimized Attachment Fetching var attachments = await context.PurchaseInvoiceAttachments .AsNoTracking() .Include(pia => pia.Document) .Include(pia => pia.InvoiceAttachmentType) .Where(pia => pia.PurchaseInvoiceId == id && pia.TenantId == tenantId && pia.Document != null && pia.InvoiceAttachmentType != null) .ToListAsync(ct); // 6. Mapping & Transformation var response = _mapper.Map(purchaseInvoice); response.Project = project; if (attachments.Count > 0) { response.Attachments = attachments.Select(a => { var result = _mapper.Map(a); // Ensure S3 Key exists before generating URL to prevent SDK errors if (a.Document != null) { result.PreSignedUrl = _s3Service.GeneratePreSignedUrl(a.Document.S3Key); // Fallback logic for thumbnail var thumbKey = !string.IsNullOrEmpty(a.Document.ThumbS3Key) ? a.Document.ThumbS3Key : a.Document.S3Key; result.ThumbPreSignedUrl = _s3Service.GeneratePreSignedUrl(thumbKey); } return result; }).ToList(); } else { response.Attachments = new List(); } _logger.LogInfo("Successfully fetched Purchase Invoice details. InvoiceId: {InvoiceId}", id); return ApiResponse.SuccessResponse(response, "Purchase invoice details fetched successfully.", 200); } catch (OperationCanceledException) { // Handle request cancellation (e.g., user navigates away) _logger.LogWarning("Request was cancelled by the user. InvoiceId: {InvoiceId}", id); return ApiResponse.ErrorResponse("Request Cancelled", "The operation was cancelled.", 499); } catch (Exception ex) { // 7. Global Error Handling _logger.LogError(ex, "An unhandled exception occurred while fetching Purchase Invoice. InvoiceId: {InvoiceId}", id); return ApiResponse.ErrorResponse("Internal Server Error", "An unexpected error occurred while processing your request. Please contact support.", 500); } } /// /// Creates a new Purchase Invoice with validation, S3 file uploads, and transactional database storage. /// /// The invoice data transfer object. /// The current user context. /// The tenant identifier. /// Cancellation token. /// The created invoice view model wrapped in an API response. public async Task> CreatePurchaseInvoiceAsync(PurchaseInvoiceDto model, Employee loggedInEmployee, Guid tenantId, CancellationToken ct = default) { // 1. INPUT VALIDATION if (model == null) throw new ArgumentNullException(nameof(model)); // Scoped variables to hold validation results to avoid fetching them again later BasicProjectVM? projectVm = null; Organization? organization = null; Organization? supplier = null; PurchaseInvoiceStatus? status = null; try { // Check permissions var manageInvoicesPermission = await HasPermissionAsync(PermissionsMaster.ManagePurchaseInvoice, loggedInEmployee.Id); if (!manageInvoicesPermission) { _logger.LogWarning("Access Denied: {EmployeeId} do not have permission to create a purchase invoice", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Access Denied", "You do not have permission to create a purchase invoice.", 403); } _logger.LogInfo("Initiating Purchase Invoice creation for ProjectId: {ProjectId}, TenantId: {TenantId}", model.ProjectId, tenantId); // 2. DATA VALIDATION (Fail-Fast Strategy) // We use a single Context instance here for read-only validation. // It is more efficient than opening 5 parallel connections. await using var readContext = await _dbContextFactory.CreateDbContextAsync(ct); // A. Validate Project (Check Infra, if null check Service) // Optimized: Only fetch what is needed using Select first var infraProject = await readContext.Projects .AsNoTracking() .Where(p => p.Id == model.ProjectId && p.TenantId == tenantId) .Select(p => _mapper.Map(p)) .FirstOrDefaultAsync(ct); if (infraProject == null) { var serviceProject = await readContext.ServiceProjects .AsNoTracking() .Where(sp => sp.Id == model.ProjectId && sp.IsActive && sp.TenantId == tenantId) .Select(p => _mapper.Map(p)) .FirstOrDefaultAsync(ct); if (serviceProject == null) { _logger.LogWarning("CreatePurchaseInvoice failed: Project {ProjectId} not found for Tenant {TenantId}", model.ProjectId, tenantId); return ApiResponse.ErrorResponse("Project not found", "The specified project does not exist.", 404); } projectVm = serviceProject; } else { projectVm = infraProject; } // B. Validate Organization organization = await readContext.Organizations .AsNoTracking() .FirstOrDefaultAsync(o => o.Id == model.OrganizationId && o.IsActive, ct); if (organization == null) { _logger.LogWarning("CreatePurchaseInvoice failed: Organization {OrganizationId} not found.", model.OrganizationId); return ApiResponse.ErrorResponse("Organization not found", "The selected organization is invalid.", 404); } // C. Validate Supplier supplier = await readContext.Organizations .AsNoTracking() .FirstOrDefaultAsync(o => o.Id == model.SupplierId && o.IsActive, ct); if (supplier == null) { _logger.LogWarning("CreatePurchaseInvoice failed: Supplier {SupplierId} not found.", model.SupplierId); return ApiResponse.ErrorResponse("Supplier not found", "The selected supplier is invalid.", 404); } // D. Validate Status status = await readContext.PurchaseInvoiceStatus .AsNoTracking() .FirstOrDefaultAsync(s => s.Id == DraftInvoiceStatusId, ct); if (status == null) { _logger.LogWarning("CreatePurchaseInvoice critical: Default 'Draft' status ID {StatusId} is missing from DB.", DraftInvoiceStatusId); return ApiResponse.ErrorResponse("System Error", "Default invoice status configuration is missing.", 500); } // 3. PREPARE S3 UPLOADS (Optimistic Upload Pattern) // We upload files BEFORE opening the DB transaction to prevent locking the DB during slow network I/O. // If DB save fails, we will trigger a cleanup in the catch block. var uploadedS3Keys = new List(); // Keep track for rollback var preparedDocuments = new List(); var preparedAttachments = new List(); // Generate Invoice ID early for S3 folder structure var newInvoiceId = Guid.NewGuid(); if (model.Attachments.Any()) { var batchId = Guid.NewGuid(); // Fetch Attachment Types var typeIds = model.Attachments.Select(a => a.InvoiceAttachmentTypeId).ToList(); var types = await readContext.InvoiceAttachmentTypes.Where(iat => typeIds.Contains(iat.Id)).ToListAsync(ct); foreach (var attachment in model.Attachments) { // Validate Type if (!types.Any(t => t.Id == attachment.InvoiceAttachmentTypeId)) { _logger.LogWarning("CreatePurchaseInvoice failed: Attachment type {InvoiceAttachmentTypeId} is invalid.", attachment.InvoiceAttachmentTypeId); return ApiResponse.ErrorResponse("Invalid Attachment", $"Attachment type {attachment.InvoiceAttachmentTypeId} is invalid.", 400); } // Validate Base64 var base64Data = attachment.Base64Data?.Split(',').LastOrDefault(); if (string.IsNullOrWhiteSpace(base64Data)) { _logger.LogWarning("CreatePurchaseInvoice failed: Attachment {FileName} contains no data.", attachment.FileName ?? ""); return ApiResponse.ErrorResponse("Invalid Attachment", $"Attachment {attachment.FileName} contains no data.", 400); } // Process Metadata var fileType = _s3Service.GetContentTypeFromBase64(base64Data); // Use default extension if fileType extraction fails, prevents crashing var safeFileType = string.IsNullOrEmpty(fileType) ? "application/octet-stream" : fileType; var fileName = attachment.FileName ?? _s3Service.GenerateFileName(safeFileType, tenantId, "invoice"); var objectKey = $"tenant-{tenantId}/PurchaseInvoice/{newInvoiceId}/{fileName}"; // Perform Upload await _s3Service.UploadFileAsync(base64Data, safeFileType, objectKey); uploadedS3Keys.Add(objectKey); // Track for rollback // Prepare Entities var documentId = Guid.NewGuid(); preparedDocuments.Add(new Document { Id = documentId, BatchId = batchId, UploadedById = loggedInEmployee.Id, FileName = fileName, ContentType = attachment.ContentType ?? safeFileType, S3Key = objectKey, FileSize = attachment.FileSize, UploadedAt = DateTime.UtcNow, TenantId = tenantId }); preparedAttachments.Add(new PurchaseInvoiceAttachment { Id = Guid.NewGuid(), InvoiceAttachmentTypeId = attachment.InvoiceAttachmentTypeId, PurchaseInvoiceId = newInvoiceId, DocumentId = documentId, UploadedAt = DateTime.UtcNow, UploadedById = loggedInEmployee.Id, TenantId = tenantId }); } } // 4. TRANSACTIONAL PERSISTENCE await using var writeContext = await _dbContextFactory.CreateDbContextAsync(ct); // Use ExecutionStrategy for transient failure resiliency (e.g. cloud DB hiccups) var strategy = writeContext.Database.CreateExecutionStrategy(); return await strategy.ExecuteAsync(async () => { await using var transaction = await writeContext.Database.BeginTransactionAsync(ct); try { // A. UID Generation // Note: In high concurrency, "Max + 1" can cause duplicates. // Ideally, lock the table or use a DB Sequence. string uIDPrefix = $"PUR/{DateTime.Now:MMyy}"; var lastInvoice = await writeContext.PurchaseInvoiceDetails .Where(e => e.UIDPrefix == uIDPrefix && e.TenantId == tenantId) // Ensure Tenant Check .OrderByDescending(e => e.UIDPostfix) .Select(x => x.UIDPostfix) // Select only what we need .FirstOrDefaultAsync(ct); int uIDPostfix = (lastInvoice == 0 ? 0 : lastInvoice) + 1; // B. Map & Add Invoice var purchaseInvoice = _mapper.Map(model); purchaseInvoice.Id = newInvoiceId; // Set the ID we generated earlier purchaseInvoice.UIDPrefix = uIDPrefix; purchaseInvoice.UIDPostfix = uIDPostfix; purchaseInvoice.StatusId = status.Id; purchaseInvoice.CreatedAt = DateTime.UtcNow; purchaseInvoice.CreatedById = loggedInEmployee.Id; purchaseInvoice.IsActive = true; purchaseInvoice.TenantId = tenantId; writeContext.PurchaseInvoiceDetails.Add(purchaseInvoice); // C. Add Documents if any if (preparedDocuments.Any()) { writeContext.Documents.AddRange(preparedDocuments); writeContext.PurchaseInvoiceAttachments.AddRange(preparedAttachments); } await writeContext.SaveChangesAsync(ct); await transaction.CommitAsync(ct); _logger.LogInfo("Purchase Invoice created successfully. ID: {InvoiceId}, UID: {UID}", purchaseInvoice.Id, $"{uIDPrefix}-{uIDPostfix}"); // D. Prepare Response var response = _mapper.Map(purchaseInvoice); response.Status = status; response.Project = projectVm; response.Supplier = _mapper.Map(supplier); return ApiResponse.SuccessResponse(response, "Purchase invoice created successfully", 201); } catch (DbUpdateException ex) { await transaction.RollbackAsync(ct); // 5. COMPENSATION (S3 Rollback) // If DB failed, we must delete the orphaned files from S3 to save cost and storage. if (uploadedS3Keys != null && uploadedS3Keys.Any()) { _logger.LogInfo("Rolling back S3 uploads for failed invoice creation."); // Fire and forget cleanup, or await if strict consistency is required foreach (var key in uploadedS3Keys) { try { await _s3Service.DeleteFileAsync(key); } catch (Exception s3Ex) { _logger.LogError(s3Ex, "Failed to cleanup S3 file {Key} during rollback", key); } } } _logger.LogError(ex, "Failed to create purchase invoice for Tenant {TenantId}. Error: {Message}", tenantId, ex.Message); return ApiResponse.ErrorResponse("Creation Failed", "An unexpected error occurred while processing your request.", 500); } }); } catch (Exception ex) { _logger.LogError(ex, "Failed to create purchase invoice for Tenant {TenantId}. Error: {Message}", tenantId, ex.Message); return ApiResponse.ErrorResponse("Creation Failed", "An unexpected error occurred while processing your request.", 500); } } public async Task> UpdatePurchaseInvoiceAsync(Guid id, PurchaseInvoiceDetails purchaseInvoice, PurchaseInvoiceDto model, Employee loggedInEmployee, Guid tenantId, CancellationToken ct) { // Validate input arguments and log warnings for invalid cases. if (id == Guid.Empty) { _logger.LogWarning("UpdatePurchaseInvoiceAsync called with empty invoice Id. TenantId: {TenantId}, EmployeeId: {EmployeeId}", tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse( "Invalid invoice identifier", "The purchase invoice identifier cannot be empty.", 400); } if (tenantId == Guid.Empty) { _logger.LogWarning("UpdatePurchaseInvoiceAsync called with empty tenant Id. InvoiceId: {InvoiceId}, EmployeeId: {EmployeeId}", id, loggedInEmployee.Id); return ApiResponse.ErrorResponse( "Invalid tenant identifier", "The tenant identifier cannot be empty.", 400); } // Check if user has permission var manageInvoicesPermission = await HasPermissionAsync(PermissionsMaster.ManagePurchaseInvoice, loggedInEmployee.Id); if (!manageInvoicesPermission) { _logger.LogWarning("Access Denied: {EmployeeId} do not have permission to update a purchase invoice", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Access Denied", "You do not have permission to update a purchase invoice.", 403); } _logger.LogInfo("Starting UpdatePurchaseInvoiceAsync. InvoiceId: {InvoiceId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", id, tenantId, loggedInEmployee.Id); await using var context = await _dbContextFactory.CreateDbContextAsync(ct); await using var transaction = await context.Database.BeginTransactionAsync(); try { // Scoped helper service for update logs. using var scope = _serviceScopeFactory.CreateScope(); var updateLogHelper = scope.ServiceProvider.GetRequiredService(); // 1. Validate existence of Project (Infra or Service). var infraProject = await context.Projects .AsNoTracking() .Where(p => p.Id == model.ProjectId && p.TenantId == tenantId) .Select(p => _mapper.Map(p)) .FirstOrDefaultAsync(ct); BasicProjectVM? projectVm; if (infraProject == null) { var serviceProject = await context.ServiceProjects .AsNoTracking() .Where(sp => sp.Id == model.ProjectId && sp.IsActive && sp.TenantId == tenantId) .Select(p => _mapper.Map(p)) .FirstOrDefaultAsync(ct); if (serviceProject == null) { _logger.LogWarning("UpdatePurchaseInvoiceAsync failed: Project {ProjectId} not found for Tenant {TenantId}", model.ProjectId, tenantId); return ApiResponse.ErrorResponse("Project not found", "The specified project does not exist.", 404); } projectVm = serviceProject; } else { projectVm = infraProject; } // 2. Validate Organization. var organization = await context.Organizations .AsNoTracking() .FirstOrDefaultAsync(o => o.Id == model.OrganizationId && o.IsActive, ct); if (organization == null) { _logger.LogWarning("UpdatePurchaseInvoiceAsync failed: Organization {OrganizationId} not found or inactive.", model.OrganizationId); return ApiResponse.ErrorResponse("Organization not found", "The selected organization is invalid or inactive.", 404); } // 3. Validate Supplier. var supplier = await context.Organizations .AsNoTracking() .FirstOrDefaultAsync(o => o.Id == model.SupplierId && o.IsActive, ct); if (supplier == null) { _logger.LogWarning("UpdatePurchaseInvoiceAsync failed: Supplier {SupplierId} not found or inactive.", model.SupplierId); return ApiResponse.ErrorResponse("Supplier not found", "The selected supplier is invalid or inactive.", 404); } // 4. Validate PurchaseInvoiceStatus. var status = await context.PurchaseInvoiceStatus .AsNoTracking() .FirstOrDefaultAsync(s => s.Id == model.StatusId, ct); if (status == null) { _logger.LogError(null, "UpdatePurchaseInvoiceAsync critical: Missing required purchase invoice status ID {StatusId}.", model.StatusId ?? Guid.Empty); return ApiResponse.ErrorResponse( "System configuration error", "Required purchase invoice status configuration is missing in the system.", 500); } // Save previous state for audit/logging. var existingEntityBson = updateLogHelper.EntityToBsonDocument(purchaseInvoice); // Map updated fields from DTO to entity. _mapper.Map(model, purchaseInvoice); purchaseInvoice.UpdatedAt = DateTime.UtcNow; purchaseInvoice.UpdatedById = loggedInEmployee.Id; context.PurchaseInvoiceDetails.Update(purchaseInvoice); await context.SaveChangesAsync(ct); // 5. Handle attachments update. var newAttachments = model.Attachments.Where(a => !a.DocumentId.HasValue && a.IsActive).ToList(); var deleteAttachmentIds = model.Attachments .Where(a => a.DocumentId.HasValue && !a.IsActive) .Select(a => a.DocumentId!.Value) .ToList(); if (newAttachments.Any()) { var batchId = Guid.NewGuid(); // Validate attachment types. var typeIds = newAttachments.Select(a => a.InvoiceAttachmentTypeId).Distinct().ToList(); var validTypes = await context.InvoiceAttachmentTypes .Where(iat => typeIds.Contains(iat.Id)) .ToListAsync(ct); var preparedDocuments = new List(); var preparedAttachments = new List(); // Process each new attachment. foreach (var attachment in newAttachments) { var attachmentType = validTypes.FirstOrDefault(t => t.Id == attachment.InvoiceAttachmentTypeId); if (attachmentType == null) { _logger.LogWarning("UpdatePurchaseInvoiceAsync failed: Invalid attachment type ID {AttachmentTypeId}.", attachment.InvoiceAttachmentTypeId); return ApiResponse.ErrorResponse("Invalid attachment types", $"One or more attachment types are invalid: {attachment.InvoiceAttachmentTypeId}", 400); } // Validate base64 data presence. var base64Data = attachment.Base64Data?.Split(',').LastOrDefault(); if (string.IsNullOrWhiteSpace(base64Data)) { _logger.LogWarning("UpdatePurchaseInvoiceAsync failed: Attachment '{FileName}' contains no data.", attachment.FileName ?? ""); return ApiResponse.ErrorResponse("Invalid attachment", $"Attachment '{attachment.FileName ?? ""}' contains no valid data.", 400); } // Determine content type with fallback. var fileType = _s3Service.GetContentTypeFromBase64(base64Data); var safeFileType = string.IsNullOrEmpty(fileType) ? "application/octet-stream" : fileType; var fileName = attachment.FileName ?? _s3Service.GenerateFileName(safeFileType, tenantId, "invoice"); var objectKey = $"tenant-{tenantId}/PurchaseInvoice/{id}/{fileName}"; // Upload file to S3 asynchronously. await _s3Service.UploadFileAsync(base64Data, safeFileType, objectKey); var documentId = Guid.NewGuid(); // Prepare Document entity. preparedDocuments.Add(new Document { Id = documentId, BatchId = batchId, UploadedById = loggedInEmployee.Id, FileName = fileName, ContentType = attachment.ContentType ?? safeFileType, S3Key = objectKey, FileSize = attachment.FileSize, UploadedAt = DateTime.UtcNow, TenantId = tenantId, }); // Prepare PurchaseInvoiceAttachment entity. preparedAttachments.Add(new PurchaseInvoiceAttachment { Id = Guid.NewGuid(), InvoiceAttachmentTypeId = attachmentType.Id, PurchaseInvoiceId = id, DocumentId = documentId, UploadedAt = DateTime.UtcNow, UploadedById = loggedInEmployee.Id, TenantId = tenantId, }); } // Add batched uploaded documents and attachments. context.Documents.AddRange(preparedDocuments); context.PurchaseInvoiceAttachments.AddRange(preparedAttachments); await context.SaveChangesAsync(ct); } // Delete attachments marked for removal. if (deleteAttachmentIds.Any()) { await DeleteAttachemnts(deleteAttachmentIds, ct); _logger.LogInfo("Deleted {Count} attachments from PurchaseInvoiceId {InvoiceId} for TenantId {TenantId}", deleteAttachmentIds.Count, id, tenantId); } await transaction.CommitAsync(); // Push audit log entry asynchronously for traceability. await updateLogHelper.PushToUpdateLogsAsync( new UpdateLogsObject { EntityId = id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), OldObject = existingEntityBson, UpdatedAt = DateTime.UtcNow }, "PurchaseInvoiceModificationLog"); _logger.LogInfo("Purchase invoice updated successfully. InvoiceId: {InvoiceId}, TenantId: {TenantId}, UpdatedById: {UserId}", id, tenantId, loggedInEmployee.Id); var response = _mapper.Map(purchaseInvoice); response.Project = projectVm; return ApiResponse.SuccessResponse(response, "Purchase invoice updated successfully.", 200); } catch (OperationCanceledException) { await transaction.RollbackAsync(); _logger.LogError(null, "UpdatePurchaseInvoiceAsync operation cancelled. InvoiceId: {InvoiceId}, TenantId: {TenantId}", id, tenantId); return ApiResponse.ErrorResponse("Request cancelled", "The update operation was cancelled by the client or the server.", 499); } catch (DbUpdateException ex) { await transaction.RollbackAsync(); _logger.LogError(ex, "Unexpected error during update of purchase invoice. InvoiceId: {InvoiceId}, TenantId: {TenantId}, UserId: {UserId}", id, tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("Update failed", "An unexpected error occurred while updating the purchase invoice. Please try again later.", 500); } catch (Exception ex) { _logger.LogError(ex, "Unexpected error during update of purchase invoice. InvoiceId: {InvoiceId}, TenantId: {TenantId}, UserId: {UserId}", id, tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("Update failed", "An unexpected error occurred while updating the purchase invoice. Please try again later.", 500); } } public async Task> DeletePurchaseInvoice(Guid id, bool isActive, Employee loggedInEmployee, Guid tenantId, CancellationToken ct) { // Check if the employee has the necessary permissions var deletePermission = await HasPermissionAsync(PermissionsMaster.DeletePurchaseInvoice, loggedInEmployee.Id); if (!deletePermission) { _logger.LogWarning("DeletePurchaseInvoiceAsync failed: EmployeeId {EmployeeId} does not have permission.", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Permission denied", "You do not have permission to delete this invoice.", 403); } await using var context = await _dbContextFactory.CreateDbContextAsync(ct); var purchaseInvoice = await context.PurchaseInvoiceDetails.FirstOrDefaultAsync(x => x.Id == id && x.TenantId == tenantId, ct); if (purchaseInvoice == null) { _logger.LogWarning("DeletePurchaseInvoiceAsync failed: InvoiceId {InvoiceId} not found.", id); return ApiResponse.ErrorResponse("Invoice not found", "The invoice with the specified ID was not found.", 404); } using var scope = _serviceScopeFactory.CreateScope(); var updateLogHelper = scope.ServiceProvider.GetRequiredService(); var existingEntityBson = updateLogHelper.EntityToBsonDocument(purchaseInvoice); purchaseInvoice.IsActive = isActive; await context.SaveChangesAsync(ct); await updateLogHelper.PushToUpdateLogsAsync( new UpdateLogsObject { EntityId = id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), OldObject = existingEntityBson, UpdatedAt = DateTime.UtcNow }, "PurchaseInvoiceModificationLog"); return ApiResponse.SuccessResponse(new { }, "Invoice deleted successfully.", 200); } /// /// Soft-deletes or restores a Purchase Invoice by toggling its active flag, /// with permission checks, audit logging, and structured logging suitable /// for enterprise-grade observability. /// /// The Purchase Invoice identifier. /// /// Indicates the new active state: /// false = mark as deleted/inactive (soft delete), /// true = restore/reactivate. /// /// The currently logged-in employee performing the operation. /// Tenant identifier to enforce multi-tenant isolation. /// Cancellation token for cooperative cancellation. /// /// Standardized with operation result or error details. /// public async Task> DeletePurchaseInvoiceAsync(Guid id, bool isActive, Employee loggedInEmployee, Guid tenantId, CancellationToken ct) { // Guard clause: validate invoice identifier. if (id == Guid.Empty) { _logger.LogWarning("DeletePurchaseInvoiceAsync called with empty InvoiceId. TenantId: {TenantId}, EmployeeId: {EmployeeId}", tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("Invoice reference is required.", "DeletePurchaseInvoiceAsync received an empty invoice Id.", 400); } try { // Step 1: Permission check for the current employee. var hasDeletePermission = await HasPermissionAsync(PermissionsMaster.DeletePurchaseInvoice, loggedInEmployee.Id); if (!hasDeletePermission) { _logger.LogWarning("DeletePurchaseInvoiceAsync permission denied. InvoiceId: {InvoiceId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", id, tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("You do not have permission to modify this invoice.", "DeletePurchaseInvoiceAsync failed due to missing DeletePurchaseInvoice permission.", 403); } // Step 2: Create a short-lived DbContext for this operation. await using var context = await _dbContextFactory.CreateDbContextAsync(ct); // Step 3: Retrieve the invoice scoped to the current tenant. var purchaseInvoice = await context.PurchaseInvoiceDetails .FirstOrDefaultAsync(x => x.Id == id && x.TenantId == tenantId, ct); if (purchaseInvoice == null) { _logger.LogWarning( "DeletePurchaseInvoiceAsync failed: Invoice not found. InvoiceId: {InvoiceId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", id, tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("Invoice not found.", $"Purchase invoice not found for Id: {id}, TenantId: {tenantId}.", 404); } // Step 4: Create a scoped helper for MongoDB update logs/audit trail. using var scope = _serviceScopeFactory.CreateScope(); var updateLogHelper = scope.ServiceProvider.GetRequiredService(); // Capture the existing state for audit logging before modification. var existingEntityBson = updateLogHelper.EntityToBsonDocument(purchaseInvoice); // Step 5: Apply the soft-delete or restore operation. purchaseInvoice.IsActive = isActive; // Persist changes with cancellation support. var rowsAffected = await context.SaveChangesAsync(ct); if (rowsAffected <= 0) { _logger.LogError(null, "DeletePurchaseInvoiceAsync failed to persist changes. InvoiceId: {InvoiceId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", id, tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("Failed to update the invoice status.", "DeletePurchaseInvoiceAsync SaveChangesAsync returned 0 rows affected.", 500); } // Step 6: Push audit log to MongoDB (non-critical but important for traceability). await updateLogHelper.PushToUpdateLogsAsync( new UpdateLogsObject { EntityId = id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), OldObject = existingEntityBson, UpdatedAt = DateTime.UtcNow }, "PurchaseInvoiceModificationLog"); _logger.LogInfo("DeletePurchaseInvoiceAsync completed successfully. InvoiceId: {InvoiceId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}, NewIsActive: {IsActive}", id, tenantId, loggedInEmployee.Id, isActive); var action = isActive ? "restored" : "deleted"; return ApiResponse.SuccessResponse(new { InvoiceId = id, IsActive = isActive }, $"Invoice has been {action} successfully.", 200); } catch (OperationCanceledException) { // Explicit cancellation handling to avoid misclassification as an error. _logger.LogError(null, "DeletePurchaseInvoiceAsync operation was canceled. InvoiceId: {InvoiceId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", id, tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("The operation was canceled.", "DeletePurchaseInvoiceAsync was canceled by the caller.", 499); } catch (DbUpdateException dbEx) { // Database-related error with structured logging. _logger.LogError(dbEx, "Database update error in DeletePurchaseInvoiceAsync. InvoiceId: {InvoiceId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", id, tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("An error occurred while updating the invoice.", "Database update exception occurred in DeletePurchaseInvoiceAsync.", 500); } catch (Exception ex) { // Catch-all for any unexpected failures. _logger.LogError(ex, "Unexpected error in DeletePurchaseInvoiceAsync. InvoiceId: {InvoiceId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", id, tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("An unexpected error occurred while updating the invoice status.", "Unhandled exception in DeletePurchaseInvoiceAsync.", 500); } } #endregion #region =================================================================== Delivery Challan Functions =================================================================== public async Task>> GetDeliveryChallansAsync(Guid purchaseInvoiceId, Employee loggedInEmployee, Guid tenantId, CancellationToken ct) { // 1. Setup Context // Using a factory ensures a clean context for this specific unit of work. await using var context = await _dbContextFactory.CreateDbContextAsync(ct); try { // Check if the employee has the necessary permissions var viewAllPermissionTask = HasPermissionAsync(PermissionsMaster.ViewAllPurchaseInvoice, loggedInEmployee.Id); var viewSelfPermissionTask = HasPermissionAsync(PermissionsMaster.ViewSelfPurchaseInvoice, loggedInEmployee.Id); var viewAllPermission = await viewAllPermissionTask; var viewSelfPermission = await viewSelfPermissionTask; if (!viewAllPermission && !viewSelfPermission) { _logger.LogWarning("Access Denied: {EmployeeId} do not have permission to view delivery challan list", loggedInEmployee.Id); return ApiResponse>.ErrorResponse("You do not have permission to view delivery challan list.", "You do not have permission to view delivery challan list.", 403); } _logger.LogInfo("GetDeliveryChallans: Fetching challans. InvoiceId: {InvoiceId}, Tenant: {TenantId}", purchaseInvoiceId, tenantId); // 2. Optimized Validation // Use AnyAsync() instead of FirstOrDefaultAsync(). // We only need to know if it *exists*, we don't need to load the data into memory. var isInvoiceValid = await context.PurchaseInvoiceDetails .AsNoTracking() .AnyAsync(pid => pid.Id == purchaseInvoiceId && pid.TenantId == tenantId, ct); if (!isInvoiceValid) { _logger.LogWarning("GetDeliveryChallans: Invoice not found. InvoiceId: {InvoiceId}", purchaseInvoiceId); return ApiResponse>.ErrorResponse("Invalid Purchase Invoice", "The specified purchase invoice does not exist.", 404); } // 3. Data Retrieval // Fetch only valid records with necessary related data. var deliveryChallanEntities = await context.DeliveryChallanDetails .AsNoTracking() .Include(dc => dc.PurchaseInvoice) .Include(dc => dc.Attachment).ThenInclude(pia => pia!.Document) .Where(dc => dc.PurchaseInvoiceId == purchaseInvoiceId && dc.TenantId == tenantId && dc.Attachment != null && dc.Attachment.Document != null) // Ensure strict data integrity .ToListAsync(ct); // 4. Early Exit for Empty Lists // Returns an empty list with 200 OK immediately, avoiding unnecessary mapping/looping. if (!deliveryChallanEntities.Any()) { _logger.LogInfo("GetDeliveryChallans: No challans found for InvoiceId: {InvoiceId}", purchaseInvoiceId); return ApiResponse>.SuccessResponse(new List(), "No delivery challans found.", 200); } // 5. Mapping and Transformation // We map the entities to View Models first, then apply business logic (S3 URLs). // Using Map> is generally more efficient than mapping inside a Select loop for complex objects. // Enhance VMs with Signed URLs // We iterate through the already-mapped list to populate non-database fields. // Zip or standard for-loop could be used, but since we mapped a list, we need to match them up. // Note: Automapper preserves order, so index matching works, but iterating the Source Entity to populate the Dest VM is safer. var responseList = deliveryChallanEntities.Select(dc => { var result = _mapper.Map(dc); if (dc.Attachment?.Document != null) { result.Attachment!.PreSignedUrl = _s3Service.GeneratePreSignedUrl(dc.Attachment.Document.S3Key); // Fallback logic for thumbnail var thumbKey = !string.IsNullOrEmpty(dc.Attachment.Document.ThumbS3Key) ? dc.Attachment.Document.ThumbS3Key : dc.Attachment.Document.S3Key; result.Attachment.ThumbPreSignedUrl = _s3Service.GeneratePreSignedUrl(thumbKey); } return result; }).ToList(); _logger.LogInfo("GetDeliveryChallans: Successfully returned {Count} items.", responseList.Count); return ApiResponse>.SuccessResponse(responseList, "List of delivery challans fetched successfully.", 200); } catch (Exception ex) { _logger.LogError(ex, "GetDeliveryChallans: An error occurred. InvoiceId: {InvoiceId}", purchaseInvoiceId); return ApiResponse>.ErrorResponse("Internal Server Error", "An unexpected error occurred while fetching delivery challans.", 500); } } public async Task> AddDeliveryChallanAsync(DeliveryChallanDto model, Employee loggedInEmployee, Guid tenantId, CancellationToken ct) { // 1. Input Validation - Fail Fast // Validate inputs before engaging expensive resources (DB/S3). if (model == null) throw new ArgumentNullException(nameof(model)); // 2. Security Check var addDeliveryChallanPermission = await HasPermissionAsync(PermissionsMaster.AddDeliveryChallan, loggedInEmployee.Id); if (!addDeliveryChallanPermission) { _logger.LogWarning("Access Denied: {EmployeeId} do not have permission to add delivery challan", loggedInEmployee.Id); return ApiResponse.ErrorResponse("You do not have permission to add delivery challan.", "You do not have permission to add delivery challan.", 403); } // Extract Base64 Data safely var base64Data = model.Attachment.Base64Data?.Split(',').LastOrDefault(); if (string.IsNullOrWhiteSpace(base64Data)) { _logger.LogWarning("AddDeliveryChallan: Validation Failed - Attachment is empty. Tenant: {TenantId}, Invoice: {InvoiceId}", tenantId, model.PurchaseInvoiceId); return ApiResponse.ErrorResponse("Invalid Attachment", "The uploaded attachment contains no data.", 400); } // Prepare S3 Metadata var fileType = _s3Service.GetContentTypeFromBase64(base64Data); var safeContentType = string.IsNullOrEmpty(fileType) ? "application/octet-stream" : fileType; // Use the sanitized file name or generate a new one to prevent path traversal or collision var fileName = !string.IsNullOrWhiteSpace(model.Attachment.FileName) ? model.Attachment.FileName : _s3Service.GenerateFileName(safeContentType, tenantId, "invoice"); var objectKey = $"tenant-{tenantId}/PurchaseInvoice/{model.PurchaseInvoiceId}/{fileName}"; // Generate new IDs upfront to maintain referential integrity in code var documentId = Guid.NewGuid(); var attachmentId = Guid.NewGuid(); var deliveryChallanId = Guid.NewGuid(); // 3. Database Read Operations (Scoped Context) // We use a factory to create a short-lived context. await using var context = await _dbContextFactory.CreateDbContextAsync(ct); // Fetch Purchase Invoice - Use AsNoTracking for performance since we aren't modifying it here. // Note: We project only what we need or map later to avoid EF translation issues with complex Mappers. var purchaseInvoiceEntity = await context.PurchaseInvoiceDetails .AsNoTracking() .FirstOrDefaultAsync(pid => pid.Id == model.PurchaseInvoiceId && pid.TenantId == tenantId, ct); if (purchaseInvoiceEntity == null) { _logger.LogWarning("AddDeliveryChallan: Purchase Invoice not found. Id: {InvoiceId}, Tenant: {TenantId}", model.PurchaseInvoiceId, tenantId); return ApiResponse.ErrorResponse("Not Found", "The specified Purchase Invoice does not exist.", 404); } // Validate Attachment Type var invoiceAttachmentType = await context.InvoiceAttachmentTypes .AsNoTracking() .FirstOrDefaultAsync(iat => iat.Id == DeliveryChallanTypeId, ct); if (invoiceAttachmentType == null) { _logger.LogError(null, "AddDeliveryChallan: Configuration Error - InvoiceAttachmentType {TypeId} missing.", DeliveryChallanTypeId); return ApiResponse.ErrorResponse("Configuration Error", "System configuration for Delivery Challan is missing.", 500); } // 4. External Service Call (S3 Upload) // We upload BEFORE the DB transaction. If this fails, we return error. // If DB fails later, we must compensate (delete this file). try { await _s3Service.UploadFileAsync(base64Data, safeContentType, objectKey); } catch (Exception ex) { _logger.LogError(ex, "AddDeliveryChallan: S3 Upload failed. Key: {ObjectKey}", objectKey); return ApiResponse.ErrorResponse("Upload Failed", "Failed to upload the attachment to storage.", 502); } // 5. Transactional Write Operations // Begin transaction for data consistency across multiple tables. await using var transaction = await context.Database.BeginTransactionAsync(ct); try { var now = DateTime.UtcNow; // Entity 1: Document (Metadata) var document = new Document { Id = documentId, BatchId = Guid.NewGuid(), // Assuming single batch for this operation UploadedById = loggedInEmployee.Id, FileName = fileName, ContentType = model.Attachment.ContentType ?? safeContentType, S3Key = objectKey, FileSize = model.Attachment.FileSize, // Ensure this is calculated correctly in DTO or here UploadedAt = now, TenantId = tenantId }; // Entity 2: PurchaseInvoiceAttachment (Link) var newAttachment = new PurchaseInvoiceAttachment { Id = attachmentId, InvoiceAttachmentTypeId = DeliveryChallanTypeId, PurchaseInvoiceId = model.PurchaseInvoiceId, DocumentId = documentId, UploadedAt = now, UploadedById = loggedInEmployee.Id, TenantId = tenantId }; // Entity 3: DeliveryChallanDetails (Domain Data) var deliveryChallan = _mapper.Map(model); deliveryChallan.Id = deliveryChallanId; deliveryChallan.AttachmentId = attachmentId; deliveryChallan.CreatedAt = now; deliveryChallan.CreatedById = loggedInEmployee.Id; deliveryChallan.TenantId = tenantId; // Batch Add context.Documents.Add(document); context.PurchaseInvoiceAttachments.Add(newAttachment); context.DeliveryChallanDetails.Add(deliveryChallan); // Execute DB changes - One round trip await context.SaveChangesAsync(ct); await transaction.CommitAsync(ct); _logger.LogInfo("AddDeliveryChallan: Success. ChallanId: {ChallanId}, Tenant: {TenantId}", deliveryChallanId, tenantId); // 6. Response Preparation // Map response objects. Ensure the VM matches the generic return type. var response = _mapper.Map(deliveryChallan); // Manual mapping for complex nested objects if Automapper config is not set for deep linking response.PurchaseInvoice = _mapper.Map(purchaseInvoiceEntity); response.CreatedBy = _mapper.Map(loggedInEmployee); response.Attachment = new PurchaseInvoiceAttachmentVM { DocumentId = document.Id, InvoiceAttachmentType = invoiceAttachmentType, FileName = document.FileName, ContentType = document.ContentType, // Generate URLs only when needed to keep response lightweight, or if they expire PreSignedUrl = _s3Service.GeneratePreSignedUrl(objectKey), ThumbPreSignedUrl = _s3Service.GeneratePreSignedUrl(objectKey) }; return ApiResponse.SuccessResponse(response, "Delivery Challan added successfully.", 201); // 201 Created } catch (Exception ex) { // 7. Rollback & Compensation await transaction.RollbackAsync(ct); _logger.LogError(ex, "AddDeliveryChallan: Database transaction failed. Rolling back. Tenant: {TenantId}", tenantId); // Compensating Action: Delete the file from S3 since DB insert failed. // We run this in a fire-and-forget or background manner, or await it carefully so it doesn't hide the original exception. try { _logger.LogInfo("AddDeliveryChallan: Attempting to delete orphaned S3 file: {ObjectKey}", objectKey); await _s3Service.DeleteFileAsync(objectKey); } catch (Exception s3Ex) { // Just log this, don't throw, so we still return the original DB error to the user _logger.LogError(s3Ex, "AddDeliveryChallan: Failed to clean up orphaned S3 file: {ObjectKey}", objectKey); } return ApiResponse.ErrorResponse("Processing Error", "An error occurred while saving the delivery challan.", 500); } } #endregion #region =================================================================== Purchase Invoice History Functions =================================================================== /// /// Retrieves the full payment history for a given Purchase Invoice, /// including related adjustment heads and creator information, /// with multi-tenant safety and structured logging. /// /// Identifier of the purchase invoice. /// The employee requesting the history. /// Current tenant identifier for multi-tenancy boundary. /// Cancellation token for cooperative cancellation. /// /// Standardized containing the list of payment history /// view models or a detailed error response. /// public async Task> GetPurchaseInvoiceHistoryListAsync(Guid purchaseInvoiceId, Employee loggedInEmployee, Guid tenantId, CancellationToken ct) { // Guard clauses to fail fast on obviously invalid parameters. if (purchaseInvoiceId == Guid.Empty) { _logger.LogWarning("GetPurchaseInvoiceHistoryListAsync called with empty PurchaseInvoiceId. TenantId: {TenantId}, EmployeeId: {EmployeeId}", tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("Purchase invoice reference is required.", "PurchaseInvoiceId is empty.", 400); } try { // Check if the employee has the necessary permissions var viewAllPermissionTask = HasPermissionAsync(PermissionsMaster.ViewAllPurchaseInvoice, loggedInEmployee.Id); var viewSelfPermissionTask = HasPermissionAsync(PermissionsMaster.ViewSelfPurchaseInvoice, loggedInEmployee.Id); var viewAllPermission = await viewAllPermissionTask; var viewSelfPermission = await viewSelfPermissionTask; if (!viewAllPermission && !viewSelfPermission) { _logger.LogWarning("Access Denied: {EmployeeId} do not have permission to view purchase invoice payment history list", loggedInEmployee.Id); return ApiResponse.ErrorResponse("You do not have permission to view purchase invoice payment history list.", "You do not have permission to view purchase invoice payment history list.", 403); } // Create a short-lived DbContext instance via factory for this operation. await using var context = await _dbContextFactory.CreateDbContextAsync(ct); // Step 1: Ensure the invoice exists for this tenant. var purchaseInvoice = await context.PurchaseInvoiceDetails .AsNoTracking() .FirstOrDefaultAsync( pi => pi.Id == purchaseInvoiceId && pi.TenantId == tenantId, ct); if (purchaseInvoice == null) { _logger.LogWarning("Purchase Invoice not found. InvoiceId: {InvoiceId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", purchaseInvoiceId, tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("Purchase invoice not found.", $"Purchase invoice not found for InvoiceId: {purchaseInvoiceId}, TenantId: {tenantId}.", 404); } // Step 2: Query payment history with necessary related data eagerly loaded. var paymentHistoryQuery = context.PurchaseInvoicePayments .Include(pip => pip.PaymentAdjustmentHead) .Include(pip => pip.CreatedBy) // Include creator .ThenInclude(e => e!.JobRole) // Include creator's job role .Where(pip => pip.InvoiceId == purchaseInvoiceId && pip.TenantId == tenantId); var paymentHistory = await paymentHistoryQuery.ToListAsync(ct); // Step 3: Map to view models for safe response shaping. var responseVm = _mapper.Map>(paymentHistory); _logger.LogInfo("Purchase Invoice payment history retrieved successfully. InvoiceId: {InvoiceId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}, PaymentsCount: {PaymentsCount}", purchaseInvoiceId, tenantId, loggedInEmployee.Id, responseVm.Count); // Even if there is no payment history, return 200 with empty collection. return ApiResponse.SuccessResponse(responseVm, "Purchase invoice payment history retrieved successfully.", 200); } catch (OperationCanceledException) { // Explicitly handle cancellation to avoid logging it as an error. _logger.LogWarning("GetPurchaseInvoiceHistoryListAsync operation was canceled. InvoiceId: {InvoiceId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", purchaseInvoiceId, tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("The operation was canceled.", "GetPurchaseInvoiceHistoryListAsync was canceled by the caller.", 499); } catch (Exception ex) { // Catch-all to ensure no unhandled exception reaches the client. _logger.LogError(ex, "Unexpected error while retrieving Purchase Invoice payment history. InvoiceId: {InvoiceId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", purchaseInvoiceId, tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("An unexpected error occurred while retrieving purchase invoice history.", "Unhandled exception in GetPurchaseInvoiceHistoryListAsync.", 500); } } /// /// Adds a payment entry against an existing Purchase Invoice with full validation /// and structured logging suitable for enterprise scenarios. /// /// Payment details to be recorded against the invoice. /// The currently logged-in employee performing this action. /// Current tenant identifier to enforce multi-tenancy boundaries. /// Cancellation token for cooperative cancellation. /// Standardized ApiResponse with the created payment view model or error details. public async Task> AddPurchaseInvoicePaymentAsync(ReceivedInvoicePaymentDto model, Employee loggedInEmployee, Guid tenantId, CancellationToken ct) { // Guard clauses to fail fast on invalid input and avoid null reference issues. if (model == null) { _logger.LogWarning("AddPurchaseInvoicePaymentAsync called with null model. TenantId: {TenantId}, EmployeeId: {EmployeeId}", tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("Invalid payment data.", "Received null payment model.", 400); } if (model.InvoiceId == Guid.Empty) { _logger.LogWarning("AddPurchaseInvoicePaymentAsync called with empty InvoiceId. TenantId: {TenantId}, EmployeeId: {EmployeeId}", tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("Invoice reference is required.", "InvoiceId is empty in payment model.", 200); } if (model.Amount <= 0) { _logger.LogWarning("AddPurchaseInvoicePaymentAsync called with non-positive Amount. TenantId: {TenantId}, EmployeeId: {EmployeeId}, Amount: {Amount}", tenantId, loggedInEmployee.Id, model.Amount); return ApiResponse.ErrorResponse("Payment amount must be greater than zero.", $"Invalid payment amount: {model.Amount}.", 400); } try { // Check permissions var manageInvoicesPermission = await HasPermissionAsync(PermissionsMaster.ManagePurchaseInvoice, loggedInEmployee.Id); if (!manageInvoicesPermission) { _logger.LogWarning("Access Denied: {EmployeeId} do not have permission to add payments to a purchase invoice", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Access Denied", "You do not have permission to add payments to a purchase invoice", 403); } // Create a short-lived DbContext instance using the factory to ensure proper scope per operation. await using var context = await _dbContextFactory.CreateDbContextAsync(ct); // Step 1: Validate that the invoice exists for the current tenant. var purchaseInvoice = await context.PurchaseInvoiceDetails .AsNoTracking() .FirstOrDefaultAsync( pi => pi.Id == model.InvoiceId && pi.TenantId == tenantId, ct); if (purchaseInvoice == null) { _logger.LogWarning("Purchase Invoice not found. InvoiceId: {InvoiceId}, TenantId: {TenantId}", model.InvoiceId, tenantId); return ApiResponse.ErrorResponse("Purchase invoice not found.", $"Purchase invoice not found for InvoiceId: {model.InvoiceId}, TenantId: {tenantId}.", 404); } // Step 2: Validate Payment Adjustment Head. var paymentAdjustmentHead = await context.PaymentAdjustmentHeads .FirstOrDefaultAsync(pah => pah.Id == model.PaymentAdjustmentHeadId && pah.TenantId == tenantId, ct); if (paymentAdjustmentHead == null) { _logger.LogWarning("Payment Adjustment Head not found. PaymentAdjustmentHeadId: {PaymentAdjustmentHeadId}, TenantId: {TenantId}", model.PaymentAdjustmentHeadId, tenantId); return ApiResponse.ErrorResponse("Payment adjustment head not found.", $"Payment adjustment head not found for Id: {model.PaymentAdjustmentHeadId}, TenantId: {tenantId}.", 404); } // Step 3: Get existing payments and ensure the new payment does not exceed the invoice total. var existingPayments = await context.PurchaseInvoicePayments .Where(pi => pi.InvoiceId == model.InvoiceId && pi.TenantId == tenantId) .ToListAsync(ct); var alreadyPaidAmount = existingPayments.Sum(pi => pi.Amount); var proposedTotalPaidAmount = alreadyPaidAmount + model.Amount; bool proformaCheck = purchaseInvoice.ProformaInvoiceAmount.HasValue ? proposedTotalPaidAmount > purchaseInvoice.ProformaInvoiceAmount : true; if (proposedTotalPaidAmount > purchaseInvoice.TotalAmount && proformaCheck) { _logger.LogWarning("Attempt to add payment exceeding invoice total. InvoiceId: {InvoiceId}, TenantId: {TenantId}, InvoiceTotal: {InvoiceTotal}, AlreadyPaid: {AlreadyPaid}, NewAmount: {NewAmount}, ProposedTotal: {ProposedTotal}", model.InvoiceId, tenantId, purchaseInvoice.TotalAmount, alreadyPaidAmount, model.Amount, proposedTotalPaidAmount); return ApiResponse.ErrorResponse("Total payment amount cannot exceed the invoice amount.", "Payment addition rejected due to exceeding invoice total amount.", 400); } // Step 4: Map DTO to entity and initialize metadata. var receivedInvoicePayment = _mapper.Map(model); receivedInvoicePayment.Id = Guid.NewGuid(); receivedInvoicePayment.CreatedAt = DateTime.UtcNow; receivedInvoicePayment.CreatedById = loggedInEmployee.Id; receivedInvoicePayment.TenantId = tenantId; // Step 5: Persist the new payment record. context.PurchaseInvoicePayments.Add(receivedInvoicePayment); // For enterprise robustness, pass the cancellation token to SaveChangesAsync. var saveResult = await context.SaveChangesAsync(ct); if (saveResult <= 0) { _logger.LogError(null, "SaveChangesAsync returned 0 while adding Purchase Invoice payment. InvoiceId: {InvoiceId}, TenantId: {TenantId}, PaymentId: {PaymentId}", model.InvoiceId, tenantId, receivedInvoicePayment.Id); return ApiResponse.ErrorResponse("Failed to add payment due to a persistence issue.", "Database SaveChangesAsync returned 0 rows affected while adding PurchaseInvoicePayment.", statusCode: StatusCodes.Status500InternalServerError); } // Step 6: Map entity back to a response view model. var responseVm = _mapper.Map(receivedInvoicePayment); responseVm.PaymentAdjustmentHead = _mapper.Map(paymentAdjustmentHead); _logger.LogInfo("Purchase Invoice payment added successfully. InvoiceId: {InvoiceId}, TenantId: {TenantId}, PaymentId: {PaymentId}, Amount: {Amount}, EmployeeId: {EmployeeId}", model.InvoiceId, tenantId, receivedInvoicePayment.Id, receivedInvoicePayment.Amount, loggedInEmployee.Id); return ApiResponse.SuccessResponse(responseVm, "Payment has been recorded successfully.", 201); // 201 Created is more appropriate for new resource. } catch (OperationCanceledException) { // Explicitly handle cancellation to avoid logging it as an error. _logger.LogError(null, "AddPurchaseInvoicePaymentAsync operation was canceled. InvoiceId: {InvoiceId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", model.InvoiceId, tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("The operation was canceled.", "AddPurchaseInvoicePaymentAsync was canceled by the caller.", 499); // 499 used by some systems for client cancellation. } catch (DbUpdateException dbEx) { // Database-related exceptions with structured logging for observability. _logger.LogError(dbEx, "Database update error while adding Purchase Invoice payment. InvoiceId: {InvoiceId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", model.InvoiceId, tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("An error occurred while saving the payment.", "Database update exception occurred during payment creation.", 500); } catch (Exception ex) { // Catch-all to avoid leaking unhandled exceptions to the client. _logger.LogError(ex, "Unexpected error while adding Purchase Invoice payment. InvoiceId: {InvoiceId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", model.InvoiceId, tenantId, loggedInEmployee.Id); return ApiResponse.ErrorResponse("An unexpected error occurred while processing the payment.", "Unhandled exception in AddPurchaseInvoicePaymentAsync.", 500); } } #endregion #region =================================================================== Helper Functions =================================================================== /// /// Async permission check helper with scoped DI lifetime /// private async Task HasPermissionAsync(Guid permission, Guid employeeId) { using var scope = _serviceScopeFactory.CreateScope(); var permissionService = scope.ServiceProvider.GetRequiredService(); return await permissionService.HasPermission(permission, employeeId); } public async Task GetPurchaseInvoiceByIdAsync(Guid id, Guid tenantId, CancellationToken ct = default) { await using var readContext = await _dbContextFactory.CreateDbContextAsync(ct); var purchaseInvoice = await readContext.PurchaseInvoiceDetails .Where(e => e.Id == id && e.TenantId == tenantId && e.IsActive) .FirstOrDefaultAsync(ct); if (purchaseInvoice == null) { _logger.LogWarning("Purchase Invoice not found. ID: {Id}, TenantID: {TenantId}", id, tenantId); } else { _logger.LogInfo("Purchase Invoice found. ID: {Id}, TenantID: {TenantId}", id, tenantId); } return purchaseInvoice; } private AdvanceFilter? TryDeserializeFilter(string? filter) { if (string.IsNullOrWhiteSpace(filter)) { return null; } var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; AdvanceFilter? advanceFilter = null; try { // First, try to deserialize directly. This is the expected case (e.g., from a web client). advanceFilter = JsonSerializer.Deserialize(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(filter, options) ?? ""; if (!string.IsNullOrWhiteSpace(unescapedJsonString)) { advanceFilter = JsonSerializer.Deserialize(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 advanceFilter; } /// /// Helper method to load infrastructure project by id. /// private async Task LoadInfraProjectAsync(Guid projectId, Guid tenantId) { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.Projects.Where(p => p.Id == projectId && p.TenantId == tenantId).Select(p => _mapper.Map(p)).FirstOrDefaultAsync(); } /// /// Helper method to load service project by id. /// private async Task LoadServiceProjectAsync(Guid projectId, Guid tenantId) { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.ServiceProjects.AsNoTracking().Where(sp => sp.Id == projectId && sp.TenantId == tenantId).Select(sp => _mapper.Map(sp)).FirstOrDefaultAsync(); } private async Task DeleteAttachemnts(List documentIds, CancellationToken ct) { var attachmentTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); var attachments = await context.PurchaseInvoiceAttachments.AsNoTracking().Where(ba => documentIds.Contains(ba.DocumentId)).ToListAsync(ct); context.PurchaseInvoiceAttachments.RemoveRange(attachments); await context.SaveChangesAsync(ct); }); var documentsTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var _updateLogHelper = scope.ServiceProvider.GetRequiredService(); await using var context = await _dbContextFactory.CreateDbContextAsync(); var documents = await context.Documents.AsNoTracking().Where(ba => documentIds.Contains(ba.Id)).ToListAsync(ct); if (documents.Any()) { context.Documents.RemoveRange(documents); await context.SaveChangesAsync(ct); List deletionObject = new List(); 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 } }