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

1017 lines
52 KiB
C#

using AutoMapper;
using Marco.Pms.DataAccess.Data;
using Marco.Pms.Helpers.Utility;
using Marco.Pms.Model.Dtos.PurchaseInvoice;
using Marco.Pms.Model.Employees;
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.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<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;
}
#region =================================================================== Purchase Invoice Functions ===================================================================
/// <summary>
/// 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.
/// </summary>
/// <param name="searchString">Optional basic search string (currently not used, kept for backward compatibility).</param>
/// <param name="filter">JSON string containing advanced filter configuration (list/date/search/group filters).</param>
/// <param name="isActive">Flag to filter active/inactive invoices.</param>
/// <param name="pageSize">Number of records per page.</param>
/// <param name="pageNumber">Current page number (1-based).</param>
/// <param name="loggedInEmployee">Currently logged-in employee context.</param>
/// <param name="tenantId">Tenant identifier for multi-tenant isolation.</param>
/// <param name="ct">Cancellation token for cooperative cancellation.</param>
/// <returns>Standard ApiResponse containing a paged invoice list payload.</returns>
public async Task<ApiResponse<object>> GetPurchaseInvoiceListAsync(string? searchString, string? filter, bool isActive, int pageSize, int pageNumber, Employee loggedInEmployee, Guid tenantId,
CancellationToken ct)
{
// Basic argument validation and guard clauses
if (tenantId == Guid.Empty)
{
_logger.LogWarning("GetPurchaseInvoiceListAsync called with empty TenantId. EmployeeId: {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.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<object>.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<PurchaseInvoiceDetails> 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 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<Project> 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<BasicProjectVM>(p))
.ToListAsync(ct);
}, ct);
var serviceProjectTask = Task.Run(async () =>
{
await using var projContext = await _dbContextFactory.CreateDbContextAsync(ct);
IQueryable<ServiceProject> 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<BasicProjectVM>(sp))
.ToListAsync(ct);
}, ct);
await Task.WhenAll(infraProjectTask, serviceProjectTask);
var projects = infraProjectTask.Result ?? new List<BasicProjectVM>();
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));
}
// 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<PurchaseInvoiceListVM>(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<object>.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<object>.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<object>.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<object>.ErrorResponse("An unexpected error occurred while fetching purchase invoices. Please try again later or contact support.", 500);
}
}
/// <summary>
/// Retrieves the details of a specific purchase invoice, including project details and S3 attachment links.
/// </summary>
/// <param name="id">The unique identifier of the Purchase Invoice.</param>
/// <param name="loggedInEmployee">The employee requesting the data.</param>
/// <param name="tenantId">The tenant identifier for data isolation.</param>
/// <param name="ct">Cancellation token for async operations.</param>
/// <returns>A wrapped response containing the Purchase Invoice View Model.</returns>
public async Task<ApiResponse<PurchaseInvoiceDetailsVM>> 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
{
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 || !purchaseInvoice.IsActive)
{
_logger.LogWarning("Purchase Invoice not found or inactive. InvoiceId: {InvoiceId}", id);
return ApiResponse<PurchaseInvoiceDetailsVM>.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<PurchaseInvoiceDetailsVM>.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<PurchaseInvoiceDetailsVM>(purchaseInvoice);
response.Project = project;
if (attachments.Count > 0)
{
response.Attachments = attachments.Select(a =>
{
var result = _mapper.Map<PurchaseInvoiceAttachmentVM>(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<PurchaseInvoiceAttachmentVM>();
}
_logger.LogInfo("Successfully fetched Purchase Invoice details. InvoiceId: {InvoiceId}", id);
return ApiResponse<PurchaseInvoiceDetailsVM>.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<PurchaseInvoiceDetailsVM>.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<PurchaseInvoiceDetailsVM>.ErrorResponse("Internal Server Error", "An unexpected error occurred while processing your request. Please contact support.",
500);
}
}
/// <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())
{
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", 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<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);
}
}
public async Task<ApiResponse<object>> 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<object>.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<object>.ErrorResponse(
"Invalid tenant identifier",
"The tenant identifier cannot be empty.",
400);
}
_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<UtilityMongoDBHelper>();
// 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<BasicProjectVM>(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<BasicProjectVM>(p))
.FirstOrDefaultAsync(ct);
if (serviceProject == null)
{
_logger.LogWarning("UpdatePurchaseInvoiceAsync failed: Project {ProjectId} not found for Tenant {TenantId}", model.ProjectId, tenantId);
return ApiResponse<object>.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<object>.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<object>.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);
return ApiResponse<object>.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.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 invalidTypeIds = typeIds.Except(validTypes.Select(t => t.Id)).ToList();
if (invalidTypeIds.Any())
{
foreach (var invalidId in invalidTypeIds)
{
_logger.LogWarning("UpdatePurchaseInvoiceAsync failed: Invalid attachment type ID {AttachmentTypeId}.", invalidId);
}
return ApiResponse<object>.ErrorResponse("Invalid attachment types", $"One or more attachment types are invalid: {string.Join(", ", invalidTypeIds)}", 400);
}
var preparedDocuments = new List<Document>();
var preparedAttachments = new List<PurchaseInvoiceAttachment>();
// Process each new attachment.
foreach (var attachment in newAttachments)
{
// 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 ?? "<unnamed>");
return ApiResponse<object>.ErrorResponse("Invalid attachment", $"Attachment '{attachment.FileName ?? "<unnamed>"}' 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 = attachment.InvoiceAttachmentTypeId,
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);
return ApiResponse<object>.SuccessResponse(model, "Purchase invoice updated successfully.", 200);
}
catch (OperationCanceledException)
{
await transaction.RollbackAsync();
_logger.LogError(null, "UpdatePurchaseInvoiceAsync operation cancelled. InvoiceId: {InvoiceId}, TenantId: {TenantId}", id, tenantId);
return ApiResponse<object>.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<object>.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<object>.ErrorResponse("Update failed", "An unexpected error occurred while updating the purchase invoice. Please try again later.", 500);
}
}
//public async Task<ApiResponse> DeletePurchaseInvoiceAsync(Guid id, Guid tenantId, CancellationToken ct = default)
#endregion
#region =================================================================== Delivery Challan Functions ===================================================================
#endregion
#region =================================================================== Purchase Invoice History Functions ===================================================================
#endregion
#region =================================================================== Helper Functions ===================================================================
public async Task<PurchaseInvoiceDetails?> 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<AdvanceFilter>(filter, options);
}
catch (JsonException ex)
{
_logger.LogError(ex, "[{MethodName}] Failed to directly deserialize filter. Attempting to unescape and re-parse. Filter: {Filter}", nameof(TryDeserializeFilter), filter);
// If direct deserialization fails, it might be an escaped string (common with tools like Postman or some mobile clients).
try
{
// Unescape the string first, then deserialize the result.
string unescapedJsonString = JsonSerializer.Deserialize<string>(filter, options) ?? "";
if (!string.IsNullOrWhiteSpace(unescapedJsonString))
{
advanceFilter = JsonSerializer.Deserialize<AdvanceFilter>(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;
}
/// <summary>
/// Helper method to load infrastructure project by id.
/// </summary>
private async Task<BasicProjectVM?> 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<BasicProjectVM>(p)).FirstOrDefaultAsync();
}
/// <summary>
/// Helper method to load service project by id.
/// </summary>
private async Task<BasicProjectVM?> 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<BasicProjectVM>(sp)).FirstOrDefaultAsync();
}
private async Task DeleteAttachemnts(List<Guid> 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<UtilityMongoDBHelper>();
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<S3DeletionObject> deletionObject = new List<S3DeletionObject>();
foreach (var document in documents)
{
deletionObject.Add(new S3DeletionObject
{
Key = document.S3Key
});
if (!string.IsNullOrWhiteSpace(document.ThumbS3Key) && document.ThumbS3Key != document.S3Key)
{
deletionObject.Add(new S3DeletionObject
{
Key = document.ThumbS3Key
});
}
}
await _updateLogHelper.PushToS3DeletionAsync(deletionObject);
}
});
await Task.WhenAll(attachmentTask, documentsTask);
}
#endregion
}
}