Added an API create the Purchase invoice
This commit is contained in:
parent
fa1c534ba8
commit
bbe36ed535
14
Marco.Pms.Model/Dtos/PurchaseInvoice/InvoiceAttachmentDto.cs
Normal file
14
Marco.Pms.Model/Dtos/PurchaseInvoice/InvoiceAttachmentDto.cs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
namespace Marco.Pms.Model.Dtos.PurchaseInvoice
|
||||||
|
{
|
||||||
|
public class InvoiceAttachmentDto
|
||||||
|
{
|
||||||
|
public Guid? DocumentId { get; set; }
|
||||||
|
public required Guid InvoiceAttachmentTypeId { get; set; }
|
||||||
|
public required string FileName { get; set; } // Name of the file (e.g., "image1.png")
|
||||||
|
public string? Base64Data { get; set; } // Base64-encoded string of the file
|
||||||
|
public string? ContentType { get; set; } // MIME type (e.g., "image/png", "application/pdf")
|
||||||
|
public long FileSize { get; set; } // File size in bytes
|
||||||
|
public string? Description { get; set; } // Optional: Description or purpose of the file
|
||||||
|
public required bool IsActive { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
31
Marco.Pms.Model/Dtos/PurchaseInvoice/PurchaseInvoiceDto.cs
Normal file
31
Marco.Pms.Model/Dtos/PurchaseInvoice/PurchaseInvoiceDto.cs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
namespace Marco.Pms.Model.Dtos.PurchaseInvoice
|
||||||
|
{
|
||||||
|
public class PurchaseInvoiceDto
|
||||||
|
{
|
||||||
|
public required string Title { get; set; }
|
||||||
|
public required string Description { get; set; }
|
||||||
|
public required Guid ProjectId { get; set; }
|
||||||
|
public required Guid OrganizationId { get; set; }
|
||||||
|
public required string BillingAddress { get; set; }
|
||||||
|
public required string ShippingAddress { get; set; }
|
||||||
|
public string? PurchaseOrderNumber { get; set; }
|
||||||
|
public DateTime? PurchaseOrderDate { get; set; }
|
||||||
|
public required Guid SupplierId { get; set; }
|
||||||
|
public string? ProformaInvoiceNumber { get; set; }
|
||||||
|
public DateTime? ProformaInvoiceDate { get; set; }
|
||||||
|
public double? ProformaInvoiceAmount { get; set; }
|
||||||
|
public string? InvoiceNumber { get; set; }
|
||||||
|
public DateTime? InvoiceDate { get; set; }
|
||||||
|
public string? EWayBillNumber { get; set; }
|
||||||
|
public DateTime? EWayBillDate { get; set; }
|
||||||
|
public string? InvoiceReferenceNumber { get; set; }
|
||||||
|
public string? AcknowledgmentNumber { get; set; }
|
||||||
|
public DateTime? AcknowledgmentDate { get; set; }
|
||||||
|
public required double BaseAmount { get; set; }
|
||||||
|
public required double TaxAmount { get; set; }
|
||||||
|
public double? TransportCharges { get; set; }
|
||||||
|
public required double TotalAmount { get; set; }
|
||||||
|
public DateTime? PaymentDueDate { get; set; } // Defaults to 40 days from the invoice date
|
||||||
|
public List<InvoiceAttachmentDto>? Attachments { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -28,6 +28,18 @@ namespace Marco.Pms.Model.PurchaseInvoice
|
|||||||
[ForeignKey("PurchaseInvoiceId")]
|
[ForeignKey("PurchaseInvoiceId")]
|
||||||
public PurchaseInvoiceDetails? PurchaseInvoice { get; set; }
|
public PurchaseInvoiceDetails? PurchaseInvoice { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the unique identifier for the type of the invoice attachment.
|
||||||
|
/// </summary>
|
||||||
|
public Guid InvoiceAttachmentTypeId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the type of the invoice attachment.
|
||||||
|
/// </summary>
|
||||||
|
[ValidateNever]
|
||||||
|
[ForeignKey("InvoiceAttachmentTypeId")]
|
||||||
|
public InvoiceAttachmentType? InvoiceAttachmentType { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the unique identifier for the document.
|
/// Gets or sets the unique identifier for the document.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -57,6 +57,18 @@ namespace Marco.Pms.Model.PurchaseInvoice
|
|||||||
[ForeignKey("OrganizationId")]
|
[ForeignKey("OrganizationId")]
|
||||||
public Organization? Organization { get; set; }
|
public Organization? Organization { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the status of the detail.
|
||||||
|
/// </summary>
|
||||||
|
public Guid StatusId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the status of the detail.
|
||||||
|
/// </summary>
|
||||||
|
[ValidateNever]
|
||||||
|
[ForeignKey("StatusId")]
|
||||||
|
public PurchaseInvoiceStatus? Status { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the billing address of the detail.
|
/// Gets or sets the billing address of the detail.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
using Marco.Pms.Model.PurchaseInvoice;
|
||||||
|
using Marco.Pms.Model.ViewModels.Organization;
|
||||||
|
using Marco.Pms.Model.ViewModels.Projects;
|
||||||
|
|
||||||
|
namespace Marco.Pms.Model.ViewModels.PurchaseInvoice
|
||||||
|
{
|
||||||
|
public class PurchaseInvoiceListVM
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string? Title { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? PurchaseInvoiceUId { get; set; }
|
||||||
|
public BasicProjectVM? Project { get; set; }
|
||||||
|
public BasicOrganizationVm? Supplier { get; set; }
|
||||||
|
public PurchaseInvoiceStatus? Status { get; set; }
|
||||||
|
public double TotalAmount { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
42
Marco.Pms.Services/Controllers/PurchaseInvoiceController.cs
Normal file
42
Marco.Pms.Services/Controllers/PurchaseInvoiceController.cs
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
using Marco.Pms.Model.Dtos.PurchaseInvoice;
|
||||||
|
using Marco.Pms.Services.Service.ServiceInterfaces;
|
||||||
|
using MarcoBMS.Services.Helpers;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Marco.Pms.Services.Controllers
|
||||||
|
{
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[ApiController]
|
||||||
|
public class PurchaseInvoiceController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly UserHelper _userHelper;
|
||||||
|
private readonly IPurchaseInvoiceService _purchaseInvoiceService;
|
||||||
|
private readonly Guid tenantId;
|
||||||
|
|
||||||
|
public PurchaseInvoiceController(UserHelper userHelper, IPurchaseInvoiceService purchaseInvoiceService)
|
||||||
|
{
|
||||||
|
_userHelper = userHelper;
|
||||||
|
_purchaseInvoiceService = purchaseInvoiceService;
|
||||||
|
tenantId = _userHelper.GetTenantId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a purchase invoice.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="model">The purchase invoice model.</param>
|
||||||
|
/// <param name="ct">The cancellation token.</param>
|
||||||
|
/// <returns>The HTTP response with the purchase invoice status and data.</returns>
|
||||||
|
[HttpPost("create")]
|
||||||
|
public async Task<IActionResult> CreatePurchaseInvoice([FromBody] PurchaseInvoiceDto model, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Get the currently logged-in employee
|
||||||
|
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
|
||||||
|
|
||||||
|
// Create a purchase invoice using the purchase invoice service
|
||||||
|
var response = await _purchaseInvoiceService.CreatePurchaseInvoiceAsync(model, loggedInEmployee, tenantId, ct);
|
||||||
|
|
||||||
|
// Return the HTTP response with the purchase invoice status and data
|
||||||
|
return StatusCode(response.StatusCode, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,6 +14,7 @@ using Marco.Pms.Model.Dtos.Expenses.Masters;
|
|||||||
using Marco.Pms.Model.Dtos.Master;
|
using Marco.Pms.Model.Dtos.Master;
|
||||||
using Marco.Pms.Model.Dtos.Organization;
|
using Marco.Pms.Model.Dtos.Organization;
|
||||||
using Marco.Pms.Model.Dtos.Project;
|
using Marco.Pms.Model.Dtos.Project;
|
||||||
|
using Marco.Pms.Model.Dtos.PurchaseInvoice;
|
||||||
using Marco.Pms.Model.Dtos.ServiceProject;
|
using Marco.Pms.Model.Dtos.ServiceProject;
|
||||||
using Marco.Pms.Model.Dtos.Tenant;
|
using Marco.Pms.Model.Dtos.Tenant;
|
||||||
using Marco.Pms.Model.Employees;
|
using Marco.Pms.Model.Employees;
|
||||||
@ -28,6 +29,7 @@ using Marco.Pms.Model.MongoDBModels.Masters;
|
|||||||
using Marco.Pms.Model.MongoDBModels.Project;
|
using Marco.Pms.Model.MongoDBModels.Project;
|
||||||
using Marco.Pms.Model.OrganizationModel;
|
using Marco.Pms.Model.OrganizationModel;
|
||||||
using Marco.Pms.Model.Projects;
|
using Marco.Pms.Model.Projects;
|
||||||
|
using Marco.Pms.Model.PurchaseInvoice;
|
||||||
using Marco.Pms.Model.ServiceProject;
|
using Marco.Pms.Model.ServiceProject;
|
||||||
using Marco.Pms.Model.TenantModels;
|
using Marco.Pms.Model.TenantModels;
|
||||||
using Marco.Pms.Model.TenantModels.MongoDBModel;
|
using Marco.Pms.Model.TenantModels.MongoDBModel;
|
||||||
@ -42,6 +44,7 @@ using Marco.Pms.Model.ViewModels.Expenses.Masters;
|
|||||||
using Marco.Pms.Model.ViewModels.Master;
|
using Marco.Pms.Model.ViewModels.Master;
|
||||||
using Marco.Pms.Model.ViewModels.Organization;
|
using Marco.Pms.Model.ViewModels.Organization;
|
||||||
using Marco.Pms.Model.ViewModels.Projects;
|
using Marco.Pms.Model.ViewModels.Projects;
|
||||||
|
using Marco.Pms.Model.ViewModels.PurchaseInvoice;
|
||||||
using Marco.Pms.Model.ViewModels.ServiceProject;
|
using Marco.Pms.Model.ViewModels.ServiceProject;
|
||||||
using Marco.Pms.Model.ViewModels.Tenant;
|
using Marco.Pms.Model.ViewModels.Tenant;
|
||||||
|
|
||||||
@ -618,6 +621,19 @@ namespace Marco.Pms.Services.MappingProfiles
|
|||||||
CreateMap<UpdateContactNoteDto, ContactNote>();
|
CreateMap<UpdateContactNoteDto, ContactNote>();
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region ======================================================= Purchase Invoice =======================================================
|
||||||
|
|
||||||
|
CreateMap<PurchaseInvoiceDto, PurchaseInvoiceDetails>()
|
||||||
|
.ForMember(
|
||||||
|
dest => dest.PaymentDueDate,
|
||||||
|
opt => opt.MapFrom(src => src.PaymentDueDate.HasValue ? src.PaymentDueDate : DateTime.UtcNow.AddDays(40)));
|
||||||
|
CreateMap<PurchaseInvoiceDetails, PurchaseInvoiceListVM>()
|
||||||
|
.ForMember(
|
||||||
|
dest => dest.PurchaseInvoiceUId,
|
||||||
|
opt => opt.MapFrom(src => $"{src.UIDPrefix}/{src.UIDPostfix:D5}"));
|
||||||
|
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -192,6 +192,7 @@ builder.Services.AddScoped<IAesEncryption, AesEncryption>();
|
|||||||
builder.Services.AddScoped<IOrganizationService, OrganizationService>();
|
builder.Services.AddScoped<IOrganizationService, OrganizationService>();
|
||||||
builder.Services.AddScoped<ITenantService, TenantService>();
|
builder.Services.AddScoped<ITenantService, TenantService>();
|
||||||
builder.Services.AddScoped<IServiceProject, ServiceProjectService>();
|
builder.Services.AddScoped<IServiceProject, ServiceProjectService>();
|
||||||
|
builder.Services.AddScoped<IPurchaseInvoiceService, PurchaseInvoiceService>();
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Helpers
|
#region Helpers
|
||||||
|
|||||||
289
Marco.Pms.Services/Service/PurchaseInvoiceService.cs
Normal file
289
Marco.Pms.Services/Service/PurchaseInvoiceService.cs
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using Marco.Pms.DataAccess.Data;
|
||||||
|
using Marco.Pms.Model.DocumentManager;
|
||||||
|
using Marco.Pms.Model.Dtos.PurchaseInvoice;
|
||||||
|
using Marco.Pms.Model.Employees;
|
||||||
|
using Marco.Pms.Model.OrganizationModel;
|
||||||
|
using Marco.Pms.Model.PurchaseInvoice;
|
||||||
|
using Marco.Pms.Model.Utilities;
|
||||||
|
using Marco.Pms.Model.ViewModels.Organization;
|
||||||
|
using Marco.Pms.Model.ViewModels.Projects;
|
||||||
|
using Marco.Pms.Model.ViewModels.PurchaseInvoice;
|
||||||
|
using Marco.Pms.Services.Service.ServiceInterfaces;
|
||||||
|
using MarcoBMS.Services.Service;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Marco.Pms.Services.Service
|
||||||
|
{
|
||||||
|
public class PurchaseInvoiceService : IPurchaseInvoiceService
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<ApplicationDbContext> _dbContextFactory;
|
||||||
|
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||||
|
private readonly ILoggingService _logger;
|
||||||
|
private readonly S3UploadService _s3Service;
|
||||||
|
private readonly IMapper _mapper;
|
||||||
|
|
||||||
|
private readonly Guid DraftInvoiceStatusId = Guid.Parse("8a5ef25e-3c9e-45de-add9-6b1c1df54381");
|
||||||
|
public PurchaseInvoiceService(IDbContextFactory<ApplicationDbContext> dbContextFactory,
|
||||||
|
IServiceScopeFactory serviceScopeFactory,
|
||||||
|
ILoggingService logger,
|
||||||
|
S3UploadService s3Service,
|
||||||
|
IMapper mapper)
|
||||||
|
{
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
_serviceScopeFactory = serviceScopeFactory;
|
||||||
|
_logger = logger;
|
||||||
|
_s3Service = s3Service;
|
||||||
|
_mapper = mapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new Purchase Invoice with validation, S3 file uploads, and transactional database storage.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="model">The invoice data transfer object.</param>
|
||||||
|
/// <param name="loggedInEmployee">The current user context.</param>
|
||||||
|
/// <param name="tenantId">The tenant identifier.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>The created invoice view model wrapped in an API response.</returns>
|
||||||
|
public async Task<ApiResponse<PurchaseInvoiceListVM>> CreatePurchaseInvoiceAsync(PurchaseInvoiceDto model, Employee loggedInEmployee, Guid tenantId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// 1. INPUT VALIDATION
|
||||||
|
if (model == null) throw new ArgumentNullException(nameof(model));
|
||||||
|
|
||||||
|
// Scoped variables to hold validation results to avoid fetching them again later
|
||||||
|
BasicProjectVM? projectVm = null;
|
||||||
|
Organization? organization = null;
|
||||||
|
Organization? supplier = null;
|
||||||
|
PurchaseInvoiceStatus? status = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogInfo("Initiating Purchase Invoice creation for ProjectId: {ProjectId}, TenantId: {TenantId}", model.ProjectId, tenantId);
|
||||||
|
|
||||||
|
// 2. DATA VALIDATION (Fail-Fast Strategy)
|
||||||
|
// We use a single Context instance here for read-only validation.
|
||||||
|
// It is more efficient than opening 5 parallel connections.
|
||||||
|
await using var readContext = await _dbContextFactory.CreateDbContextAsync(ct);
|
||||||
|
|
||||||
|
// A. Validate Project (Check Infra, if null check Service)
|
||||||
|
// Optimized: Only fetch what is needed using Select first
|
||||||
|
var infraProject = await readContext.Projects
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(p => p.Id == model.ProjectId && p.TenantId == tenantId)
|
||||||
|
.Select(p => _mapper.Map<BasicProjectVM>(p))
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
if (infraProject == null)
|
||||||
|
{
|
||||||
|
var serviceProject = await readContext.ServiceProjects
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(sp => sp.Id == model.ProjectId && sp.IsActive && sp.TenantId == tenantId)
|
||||||
|
.Select(p => _mapper.Map<BasicProjectVM>(p))
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
if (serviceProject == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("CreatePurchaseInvoice failed: Project {ProjectId} not found for Tenant {TenantId}", model.ProjectId, tenantId);
|
||||||
|
return ApiResponse<PurchaseInvoiceListVM>.ErrorResponse("Project not found", "The specified project does not exist.", 404);
|
||||||
|
}
|
||||||
|
projectVm = serviceProject;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
projectVm = infraProject;
|
||||||
|
}
|
||||||
|
|
||||||
|
// B. Validate Organization
|
||||||
|
organization = await readContext.Organizations
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(o => o.Id == model.OrganizationId && o.IsActive, ct);
|
||||||
|
|
||||||
|
if (organization == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("CreatePurchaseInvoice failed: Organization {OrganizationId} not found.", model.OrganizationId);
|
||||||
|
return ApiResponse<PurchaseInvoiceListVM>.ErrorResponse("Organization not found", "The selected organization is invalid.", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// C. Validate Supplier
|
||||||
|
supplier = await readContext.Organizations
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(o => o.Id == model.SupplierId && o.IsActive, ct);
|
||||||
|
|
||||||
|
if (supplier == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("CreatePurchaseInvoice failed: Supplier {SupplierId} not found.", model.SupplierId);
|
||||||
|
return ApiResponse<PurchaseInvoiceListVM>.ErrorResponse("Supplier not found", "The selected supplier is invalid.", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// D. Validate Status
|
||||||
|
status = await readContext.PurchaseInvoiceStatus
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(s => s.Id == DraftInvoiceStatusId, ct);
|
||||||
|
|
||||||
|
if (status == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("CreatePurchaseInvoice critical: Default 'Draft' status ID {StatusId} is missing from DB.", DraftInvoiceStatusId);
|
||||||
|
return ApiResponse<PurchaseInvoiceListVM>.ErrorResponse("System Error", "Default invoice status configuration is missing.", 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. PREPARE S3 UPLOADS (Optimistic Upload Pattern)
|
||||||
|
// We upload files BEFORE opening the DB transaction to prevent locking the DB during slow network I/O.
|
||||||
|
// If DB save fails, we will trigger a cleanup in the catch block.
|
||||||
|
var uploadedS3Keys = new List<string>(); // Keep track for rollback
|
||||||
|
var preparedDocuments = new List<Document>();
|
||||||
|
var preparedAttachments = new List<PurchaseInvoiceAttachment>();
|
||||||
|
|
||||||
|
// Generate Invoice ID early for S3 folder structure
|
||||||
|
var newInvoiceId = Guid.NewGuid();
|
||||||
|
|
||||||
|
if (model.Attachments?.Any() == true)
|
||||||
|
{
|
||||||
|
var batchId = Guid.NewGuid();
|
||||||
|
|
||||||
|
// Fetch Attachment Types
|
||||||
|
var typeIds = model.Attachments.Select(a => a.InvoiceAttachmentTypeId).ToList();
|
||||||
|
var types = await readContext.InvoiceAttachmentTypes.Where(iat => typeIds.Contains(iat.Id)).ToListAsync(ct);
|
||||||
|
|
||||||
|
foreach (var attachment in model.Attachments)
|
||||||
|
{
|
||||||
|
// Validate Type
|
||||||
|
if (!types.Any(t => t.Id == attachment.InvoiceAttachmentTypeId))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("CreatePurchaseInvoice failed: Attachment type {InvoiceAttachmentTypeId} is invalid.", attachment.InvoiceAttachmentTypeId);
|
||||||
|
return ApiResponse<PurchaseInvoiceListVM>.ErrorResponse("Invalid Attachment", $"Attachment type {attachment.InvoiceAttachmentTypeId} is invalid.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate Base64
|
||||||
|
var base64Data = attachment.Base64Data?.Split(',').LastOrDefault();
|
||||||
|
if (string.IsNullOrWhiteSpace(base64Data))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("CreatePurchaseInvoice failed: Attachment {FileName} contains no data.", attachment.FileName ?? "<unnamed>");
|
||||||
|
return ApiResponse<PurchaseInvoiceListVM>.ErrorResponse("Invalid Attachment", $"Attachment {attachment.FileName} contains no data.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process Metadata
|
||||||
|
var fileType = _s3Service.GetContentTypeFromBase64(base64Data);
|
||||||
|
// Use default extension if fileType extraction fails, prevents crashing
|
||||||
|
var safeFileType = string.IsNullOrEmpty(fileType) ? "application/octet-stream" : fileType;
|
||||||
|
var fileName = attachment.FileName ?? _s3Service.GenerateFileName(safeFileType, tenantId, "invoice");
|
||||||
|
var objectKey = $"tenant-{tenantId}/PurchaseInvoice/{newInvoiceId}/{fileName}";
|
||||||
|
|
||||||
|
// Perform Upload
|
||||||
|
await _s3Service.UploadFileAsync(base64Data, safeFileType, objectKey);
|
||||||
|
uploadedS3Keys.Add(objectKey); // Track for rollback
|
||||||
|
|
||||||
|
// Prepare Entities
|
||||||
|
var documentId = Guid.NewGuid();
|
||||||
|
preparedDocuments.Add(new Document
|
||||||
|
{
|
||||||
|
Id = documentId,
|
||||||
|
BatchId = batchId,
|
||||||
|
UploadedById = loggedInEmployee.Id,
|
||||||
|
FileName = fileName,
|
||||||
|
ContentType = attachment.ContentType ?? safeFileType,
|
||||||
|
S3Key = objectKey,
|
||||||
|
FileSize = attachment.FileSize,
|
||||||
|
UploadedAt = DateTime.UtcNow,
|
||||||
|
TenantId = tenantId
|
||||||
|
});
|
||||||
|
|
||||||
|
preparedAttachments.Add(new PurchaseInvoiceAttachment
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
InvoiceAttachmentTypeId = attachment.InvoiceAttachmentTypeId,
|
||||||
|
PurchaseInvoiceId = newInvoiceId,
|
||||||
|
DocumentId = documentId,
|
||||||
|
UploadedAt = DateTime.UtcNow,
|
||||||
|
UploadedById = loggedInEmployee.Id,
|
||||||
|
TenantId = tenantId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. TRANSACTIONAL PERSISTENCE
|
||||||
|
await using var writeContext = await _dbContextFactory.CreateDbContextAsync(ct);
|
||||||
|
|
||||||
|
// Use ExecutionStrategy for transient failure resiliency (e.g. cloud DB hiccups)
|
||||||
|
var strategy = writeContext.Database.CreateExecutionStrategy();
|
||||||
|
|
||||||
|
return await strategy.ExecuteAsync(async () =>
|
||||||
|
{
|
||||||
|
await using var transaction = await writeContext.Database.BeginTransactionAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// A. UID Generation
|
||||||
|
// Note: In high concurrency, "Max + 1" can cause duplicates.
|
||||||
|
// Ideally, lock the table or use a DB Sequence.
|
||||||
|
string uIDPrefix = $"PUR/{DateTime.Now:MMyy}";
|
||||||
|
|
||||||
|
var lastInvoice = await writeContext.PurchaseInvoiceDetails
|
||||||
|
.Where(e => e.UIDPrefix == uIDPrefix && e.TenantId == tenantId) // Ensure Tenant Check
|
||||||
|
.OrderByDescending(e => e.UIDPostfix)
|
||||||
|
.Select(x => x.UIDPostfix) // Select only what we need
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
int uIDPostfix = (lastInvoice == 0 ? 0 : lastInvoice) + 1;
|
||||||
|
|
||||||
|
// B. Map & Add Invoice
|
||||||
|
var purchaseInvoice = _mapper.Map<PurchaseInvoiceDetails>(model);
|
||||||
|
purchaseInvoice.Id = newInvoiceId; // Set the ID we generated earlier
|
||||||
|
purchaseInvoice.UIDPrefix = uIDPrefix;
|
||||||
|
purchaseInvoice.UIDPostfix = uIDPostfix;
|
||||||
|
purchaseInvoice.StatusId = status.Id;
|
||||||
|
purchaseInvoice.CreatedAt = DateTime.UtcNow;
|
||||||
|
purchaseInvoice.CreatedById = loggedInEmployee.Id;
|
||||||
|
purchaseInvoice.IsActive = true;
|
||||||
|
purchaseInvoice.TenantId = tenantId;
|
||||||
|
|
||||||
|
writeContext.PurchaseInvoiceDetails.Add(purchaseInvoice);
|
||||||
|
|
||||||
|
// C. Add Documents if any
|
||||||
|
if (preparedDocuments.Any())
|
||||||
|
{
|
||||||
|
writeContext.Documents.AddRange(preparedDocuments);
|
||||||
|
writeContext.PurchaseInvoiceAttachments.AddRange(preparedAttachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeContext.SaveChangesAsync(ct);
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
|
_logger.LogInfo("Purchase Invoice created successfully. ID: {InvoiceId}, UID: {UID}", purchaseInvoice.Id, $"{uIDPrefix}-{uIDPostfix}");
|
||||||
|
|
||||||
|
// D. Prepare Response
|
||||||
|
var response = _mapper.Map<PurchaseInvoiceListVM>(purchaseInvoice);
|
||||||
|
response.Status = status;
|
||||||
|
response.Project = projectVm;
|
||||||
|
response.Supplier = _mapper.Map<BasicOrganizationVm>(supplier);
|
||||||
|
|
||||||
|
return ApiResponse<PurchaseInvoiceListVM>.SuccessResponse(response, "Purchase invoice created successfully", 200);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException ex)
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(ct);
|
||||||
|
|
||||||
|
// 5. COMPENSATION (S3 Rollback)
|
||||||
|
// If DB failed, we must delete the orphaned files from S3 to save cost and storage.
|
||||||
|
if (uploadedS3Keys != null && uploadedS3Keys.Any())
|
||||||
|
{
|
||||||
|
_logger.LogInfo("Rolling back S3 uploads for failed invoice creation.");
|
||||||
|
// Fire and forget cleanup, or await if strict consistency is required
|
||||||
|
foreach (var key in uploadedS3Keys)
|
||||||
|
{
|
||||||
|
try { await _s3Service.DeleteFileAsync(key); }
|
||||||
|
catch (Exception s3Ex) { _logger.LogError(s3Ex, "Failed to cleanup S3 file {Key} during rollback", key); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_logger.LogError(ex, "Failed to create purchase invoice for Tenant {TenantId}. Error: {Message}", tenantId, ex.Message);
|
||||||
|
|
||||||
|
return ApiResponse<PurchaseInvoiceListVM>.ErrorResponse("Creation Failed", "An unexpected error occurred while processing your request.", 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to create purchase invoice for Tenant {TenantId}. Error: {Message}", tenantId, ex.Message);
|
||||||
|
return ApiResponse<PurchaseInvoiceListVM>.ErrorResponse("Creation Failed", "An unexpected error occurred while processing your request.", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
using Marco.Pms.Model.Dtos.PurchaseInvoice;
|
||||||
|
using Marco.Pms.Model.Employees;
|
||||||
|
using Marco.Pms.Model.Utilities;
|
||||||
|
using Marco.Pms.Model.ViewModels.PurchaseInvoice;
|
||||||
|
|
||||||
|
namespace Marco.Pms.Services.Service.ServiceInterfaces
|
||||||
|
{
|
||||||
|
public interface IPurchaseInvoiceService
|
||||||
|
{
|
||||||
|
Task<ApiResponse<PurchaseInvoiceListVM>> CreatePurchaseInvoiceAsync(PurchaseInvoiceDto model, Employee loggedInEmployee, Guid tenantId, CancellationToken ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user