1742 lines
95 KiB
C#
1742 lines
95 KiB
C#
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<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");
|
|
private readonly Guid DeliveryChallanTypeId = Guid.Parse("ca294108-a586-4207-88c8-163b24305ddc");
|
|
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)
|
|
{
|
|
// 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<object>.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<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 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<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));
|
|
}
|
|
|
|
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<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
|
|
{
|
|
// 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<PurchaseInvoiceDetailsVM>.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<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
|
|
{
|
|
// 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<PurchaseInvoiceListVM>.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<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);
|
|
}
|
|
// 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<object>.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<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 ?? Guid.Empty);
|
|
|
|
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.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<Document>();
|
|
var preparedAttachments = new List<PurchaseInvoiceAttachment>();
|
|
|
|
// 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<object>.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 ?? "<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 = 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<PurchaseInvoiceListVM>(purchaseInvoice);
|
|
response.Project = projectVm;
|
|
|
|
return ApiResponse<object>.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<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<object>> 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<object>.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<object>.ErrorResponse("Invoice not found", "The invoice with the specified ID was not found.", 404);
|
|
}
|
|
|
|
using var scope = _serviceScopeFactory.CreateScope();
|
|
var updateLogHelper = scope.ServiceProvider.GetRequiredService<UtilityMongoDBHelper>();
|
|
|
|
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<object>.SuccessResponse(new { }, "Invoice deleted successfully.", 200);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="id">The Purchase Invoice identifier.</param>
|
|
/// <param name="isActive">
|
|
/// Indicates the new active state:
|
|
/// false = mark as deleted/inactive (soft delete),
|
|
/// true = restore/reactivate.
|
|
/// </param>
|
|
/// <param name="loggedInEmployee">The currently logged-in employee performing the operation.</param>
|
|
/// <param name="tenantId">Tenant identifier to enforce multi-tenant isolation.</param>
|
|
/// <param name="ct">Cancellation token for cooperative cancellation.</param>
|
|
/// <returns>
|
|
/// Standardized <see cref="ApiResponse{T}"/> with operation result or error details.
|
|
/// </returns>
|
|
public async Task<ApiResponse<object>> 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<object>.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<object>.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<object>.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<UtilityMongoDBHelper>();
|
|
|
|
// 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<object>.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<object>.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<object>.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<object>.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<object>.ErrorResponse("An unexpected error occurred while updating the invoice status.", "Unhandled exception in DeletePurchaseInvoiceAsync.", 500);
|
|
}
|
|
}
|
|
|
|
|
|
#endregion
|
|
|
|
#region =================================================================== Delivery Challan Functions ===================================================================
|
|
|
|
public async Task<ApiResponse<List<DeliveryChallanVM>>> 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<List<DeliveryChallanVM>>.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<List<DeliveryChallanVM>>.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<List<DeliveryChallanVM>>.SuccessResponse(new List<DeliveryChallanVM>(), "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<List<T>> 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<DeliveryChallanVM>(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<List<DeliveryChallanVM>>.SuccessResponse(responseList, "List of delivery challans fetched successfully.", 200);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "GetDeliveryChallans: An error occurred. InvoiceId: {InvoiceId}", purchaseInvoiceId);
|
|
return ApiResponse<List<DeliveryChallanVM>>.ErrorResponse("Internal Server Error", "An unexpected error occurred while fetching delivery challans.", 500);
|
|
}
|
|
}
|
|
public async Task<ApiResponse<DeliveryChallanVM>> 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<DeliveryChallanVM>.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<DeliveryChallanVM>.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<DeliveryChallanVM>.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<DeliveryChallanVM>.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<DeliveryChallanVM>.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<DeliveryChallanDetails>(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<DeliveryChallanVM>(deliveryChallan);
|
|
|
|
// Manual mapping for complex nested objects if Automapper config is not set for deep linking
|
|
response.PurchaseInvoice = _mapper.Map<BasicPurchaseInvoiceVM>(purchaseInvoiceEntity);
|
|
response.CreatedBy = _mapper.Map<BasicEmployeeVM>(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<DeliveryChallanVM>.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<DeliveryChallanVM>.ErrorResponse("Processing Error", "An error occurred while saving the delivery challan.", 500);
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region =================================================================== Purchase Invoice History Functions ===================================================================
|
|
|
|
/// <summary>
|
|
/// Retrieves the full payment history for a given Purchase Invoice,
|
|
/// including related adjustment heads and creator information,
|
|
/// with multi-tenant safety and structured logging.
|
|
/// </summary>
|
|
/// <param name="purchaseInvoiceId">Identifier of the purchase invoice.</param>
|
|
/// <param name="loggedInEmployee">The employee requesting the history.</param>
|
|
/// <param name="tenantId">Current tenant identifier for multi-tenancy boundary.</param>
|
|
/// <param name="ct">Cancellation token for cooperative cancellation.</param>
|
|
/// <returns>
|
|
/// Standardized <see cref="ApiResponse{T}"/> containing the list of payment history
|
|
/// view models or a detailed error response.
|
|
/// </returns>
|
|
public async Task<ApiResponse<object>> 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<object>.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<object>.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<object>.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<List<ReceivedInvoicePaymentVM>>(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<object>.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<object>.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<object>.ErrorResponse("An unexpected error occurred while retrieving purchase invoice history.", "Unhandled exception in GetPurchaseInvoiceHistoryListAsync.", 500);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a payment entry against an existing Purchase Invoice with full validation
|
|
/// and structured logging suitable for enterprise scenarios.
|
|
/// </summary>
|
|
/// <param name="model">Payment details to be recorded against the invoice.</param>
|
|
/// <param name="loggedInEmployee">The currently logged-in employee performing this action.</param>
|
|
/// <param name="tenantId">Current tenant identifier to enforce multi-tenancy boundaries.</param>
|
|
/// <param name="ct">Cancellation token for cooperative cancellation.</param>
|
|
/// <returns>Standardized ApiResponse with the created payment view model or error details.</returns>
|
|
public async Task<ApiResponse<object>> 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<object>.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<object>.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<object>.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<object>.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<object>.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<object>.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<object>.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<PurchaseInvoicePayment>(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<object>.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<ReceivedInvoicePaymentVM>(receivedInvoicePayment);
|
|
responseVm.PaymentAdjustmentHead = _mapper.Map<PaymentAdjustmentHeadVM>(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<object>.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<object>.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<object>.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<object>.ErrorResponse("An unexpected error occurred while processing the payment.", "Unhandled exception in AddPurchaseInvoicePaymentAsync.", 500);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region =================================================================== Helper Functions ===================================================================
|
|
|
|
/// <summary>
|
|
/// Async permission check helper with scoped DI lifetime
|
|
/// </summary>
|
|
private async Task<bool> HasPermissionAsync(Guid permission, Guid employeeId)
|
|
{
|
|
using var scope = _serviceScopeFactory.CreateScope();
|
|
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
|
return await permissionService.HasPermission(permission, employeeId);
|
|
}
|
|
|
|
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
|
|
|
|
}
|
|
}
|