Added An API to add delivery challan to purchase invoice

This commit is contained in:
ashutosh.nehete 2025-11-27 12:28:21 +05:30
parent 34c5ac9c25
commit 41feb58d45
8 changed files with 251 additions and 2 deletions

View File

@ -0,0 +1,11 @@
namespace Marco.Pms.Model.Dtos.PurchaseInvoice
{
public class DeliveryChallanDto
{
public required string DeliveryChallanNumber { get; set; }
public required DateTime DeliveryChallanDate { get; set; }
public required string Description { get; set; }
public required Guid PurchaseInvoiceId { get; set; }
public required InvoiceAttachmentDto Attachment { get; set; }
}
}

View File

@ -0,0 +1,9 @@
namespace Marco.Pms.Model.ViewModels.PurchaseInvoice
{
public class BasicPurchaseInvoiceVM
{
public Guid Id { get; set; }
public string? Title { get; set; }
public string? PurchaseInvoiceUId { get; set; }
}
}

View File

@ -0,0 +1,16 @@
using Marco.Pms.Model.ViewModels.Activities;
namespace Marco.Pms.Model.ViewModels.PurchaseInvoice
{
public class DeliveryChallanVM
{
public Guid Id { get; set; }
public string? DeliveryChallanNumber { get; set; }
public DateTime DeliveryChallanDate { get; set; }
public string? Description { get; set; }
public BasicPurchaseInvoiceVM? PurchaseInvoice { get; set; }
public PurchaseInvoiceAttachmentVM? Attachment { get; set; }
public DateTime CreatedAt { get; set; }
public BasicEmployeeVM? CreatedBy { get; set; }
}
}

View File

@ -88,7 +88,7 @@ namespace MarcoBMS.Services.Controllers
return BadRequest(ApiResponse<object>.ErrorResponse("Employee ID is required and must not be Empty.", "Employee ID is required and must not be empty.", 400));
}
Employee? employee = await _context.Employees.Include(e => e.JobRole).FirstOrDefaultAsync(e => e.Id == employeeId && e.TenantId == tenantId);
Employee? employee = await _context.Employees.Include(e => e.JobRole).FirstOrDefaultAsync(e => e.Id == employeeId);
if (employee == null)
{
_logger.LogWarning("Employee {EmployeeId} not found", employeeId);

View File

@ -124,6 +124,38 @@ namespace Marco.Pms.Services.Controllers
#endregion
#region =================================================================== Delivery Challan Functions ===================================================================
/// <summary>
/// Adds a delivery challan.
/// </summary>
/// <param name="model">The delivery challan model.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The HTTP response for adding the delivery challan.</returns>
[HttpPost("delivery-challan/create")]
public async Task<IActionResult> AddDeliveryChallan([FromBody] DeliveryChallanDto model, CancellationToken ct)
{
// Get the currently logged-in employee
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
// Add the delivery challan using the purchase invoice service
var response = await _purchaseInvoiceService.AddDeliveryChallanAsync(model, loggedInEmployee, tenantId, ct);
// If the addition is successful, send a notification to the SignalR service
if (response.Success)
{
var notification = new
{
LoggedInUserId = loggedInEmployee.Id,
Keyword = "Delivery_Challan",
Response = response.Data
};
await _signalR.SendNotificationAsync(notification);
}
// Return the HTTP response
return StatusCode(response.StatusCode, response);
}
#endregion
#region =================================================================== Purchase Invoice History Functions ===================================================================

View File

@ -635,6 +635,11 @@ namespace Marco.Pms.Services.MappingProfiles
.ForMember(
dest => dest.PurchaseInvoiceUId,
opt => opt.MapFrom(src => $"{src.UIDPrefix}/{src.UIDPostfix:D5}"));
CreateMap<PurchaseInvoiceDetails, BasicPurchaseInvoiceVM>()
.ForMember(
dest => dest.PurchaseInvoiceUId,
opt => opt.MapFrom(src => $"{src.UIDPrefix}/{src.UIDPostfix:D5}"));
CreateMap<PurchaseInvoiceDetails, PurchaseInvoiceDetailsVM>()
.ForMember(
dest => dest.PurchaseInvoiceUId,
@ -651,6 +656,13 @@ namespace Marco.Pms.Services.MappingProfiles
dest => dest.ContentType,
opt => opt.MapFrom(src => src.Document != null ? src.Document.ContentType : null));
CreateMap<DeliveryChallanDto, DeliveryChallanDetails>()
.ForMember(
dest => dest.Attachment,
opt => opt.Ignore());
CreateMap<DeliveryChallanDetails, DeliveryChallanVM>();
#endregion
}
}

View File

@ -10,6 +10,7 @@ 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.Organization;
using Marco.Pms.Model.ViewModels.Projects;
using Marco.Pms.Model.ViewModels.PurchaseInvoice;
@ -31,6 +32,7 @@ namespace Marco.Pms.Services.Service
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,
@ -727,7 +729,7 @@ namespace Marco.Pms.Services.Service
if (status == null)
{
_logger.LogError(null, "UpdatePurchaseInvoiceAsync critical: Missing required purchase invoice status ID {StatusId}.", model.StatusId);
_logger.LogError(null, "UpdatePurchaseInvoiceAsync critical: Missing required purchase invoice status ID {StatusId}.", model.StatusId ?? Guid.Empty);
return ApiResponse<object>.ErrorResponse(
"System configuration error",
@ -884,6 +886,172 @@ namespace Marco.Pms.Services.Service
#endregion
#region =================================================================== Delivery Challan Functions ===================================================================
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));
// 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();
// 2. 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);
}
// 3. 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);
}
// 4. 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);
// 5. 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)
{
// 6. 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 ===================================================================

View File

@ -18,6 +18,7 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces
#endregion
#region =================================================================== Delivery Challan Functions ===================================================================
Task<ApiResponse<DeliveryChallanVM>> AddDeliveryChallanAsync(DeliveryChallanDto model, Employee loggedInEmployee, Guid tenantId, CancellationToken ct);
#endregion
#region =================================================================== Purchase Invoice History Functions ===================================================================