using AutoMapper; using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.DocumentManager; using Marco.Pms.Model.Dtos.PurchaseInvoice; using Marco.Pms.Model.Employees; using Marco.Pms.Model.OrganizationModel; using Marco.Pms.Model.PurchaseInvoice; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Organization; using Marco.Pms.Model.ViewModels.Projects; using Marco.Pms.Model.ViewModels.PurchaseInvoice; using Marco.Pms.Services.Service.ServiceInterfaces; using MarcoBMS.Services.Service; using Microsoft.EntityFrameworkCore; 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"); public PurchaseInvoiceService(IDbContextFactory dbContextFactory, IServiceScopeFactory serviceScopeFactory, ILoggingService logger, S3UploadService s3Service, IMapper mapper) { _dbContextFactory = dbContextFactory; _serviceScopeFactory = serviceScopeFactory; _logger = logger; _s3Service = s3Service; _mapper = mapper; } /// /// 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 { _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() == true) { 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", 200); } 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); } } } }