marco.pms.api/Marco.Pms.Services/Service/PurchaseInvoiceService.cs

290 lines
15 KiB
C#

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<ApplicationDbContext> _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<ApplicationDbContext> dbContextFactory,
IServiceScopeFactory serviceScopeFactory,
ILoggingService logger,
S3UploadService s3Service,
IMapper mapper)
{
_dbContextFactory = dbContextFactory;
_serviceScopeFactory = serviceScopeFactory;
_logger = logger;
_s3Service = s3Service;
_mapper = mapper;
}
/// <summary>
/// Creates a new Purchase Invoice with validation, S3 file uploads, and transactional database storage.
/// </summary>
/// <param name="model">The invoice data transfer object.</param>
/// <param name="loggedInEmployee">The current user context.</param>
/// <param name="tenantId">The tenant identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The created invoice view model wrapped in an API response.</returns>
public async Task<ApiResponse<PurchaseInvoiceListVM>> 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<BasicProjectVM>(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<BasicProjectVM>(p))
.FirstOrDefaultAsync(ct);
if (serviceProject == null)
{
_logger.LogWarning("CreatePurchaseInvoice failed: Project {ProjectId} not found for Tenant {TenantId}", model.ProjectId, tenantId);
return ApiResponse<PurchaseInvoiceListVM>.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<PurchaseInvoiceListVM>.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<PurchaseInvoiceListVM>.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<PurchaseInvoiceListVM>.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<string>(); // Keep track for rollback
var preparedDocuments = new List<Document>();
var preparedAttachments = new List<PurchaseInvoiceAttachment>();
// 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<PurchaseInvoiceListVM>.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 ?? "<unnamed>");
return ApiResponse<PurchaseInvoiceListVM>.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<PurchaseInvoiceDetails>(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<PurchaseInvoiceListVM>(purchaseInvoice);
response.Status = status;
response.Project = projectVm;
response.Supplier = _mapper.Map<BasicOrganizationVm>(supplier);
return ApiResponse<PurchaseInvoiceListVM>.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<PurchaseInvoiceListVM>.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<PurchaseInvoiceListVM>.ErrorResponse("Creation Failed", "An unexpected error occurred while processing your request.", 500);
}
}
}
}