From 41feb58d455ddad676cf30d148af24dbf7c0c254 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Thu, 27 Nov 2025 12:28:21 +0530 Subject: [PATCH] Added An API to add delivery challan to purchase invoice --- .../PurchaseInvoice/DeliveryChallanDto.cs | 11 ++ .../PurchaseInvoice/BasicPurchaseInvoiceVM.cs | 9 + .../PurchaseInvoice/DeliveryChallanVM.cs | 16 ++ .../Controllers/AttendanceController.cs | 2 +- .../Controllers/PurchaseInvoiceController.cs | 32 ++++ .../MappingProfiles/MappingProfile.cs | 12 ++ .../Service/PurchaseInvoiceService.cs | 170 +++++++++++++++++- .../IPurchaseInvoiceService.cs | 1 + 8 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 Marco.Pms.Model/Dtos/PurchaseInvoice/DeliveryChallanDto.cs create mode 100644 Marco.Pms.Model/ViewModels/PurchaseInvoice/BasicPurchaseInvoiceVM.cs create mode 100644 Marco.Pms.Model/ViewModels/PurchaseInvoice/DeliveryChallanVM.cs diff --git a/Marco.Pms.Model/Dtos/PurchaseInvoice/DeliveryChallanDto.cs b/Marco.Pms.Model/Dtos/PurchaseInvoice/DeliveryChallanDto.cs new file mode 100644 index 0000000..4e71c71 --- /dev/null +++ b/Marco.Pms.Model/Dtos/PurchaseInvoice/DeliveryChallanDto.cs @@ -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; } + } +} diff --git a/Marco.Pms.Model/ViewModels/PurchaseInvoice/BasicPurchaseInvoiceVM.cs b/Marco.Pms.Model/ViewModels/PurchaseInvoice/BasicPurchaseInvoiceVM.cs new file mode 100644 index 0000000..db7a493 --- /dev/null +++ b/Marco.Pms.Model/ViewModels/PurchaseInvoice/BasicPurchaseInvoiceVM.cs @@ -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; } + } +} diff --git a/Marco.Pms.Model/ViewModels/PurchaseInvoice/DeliveryChallanVM.cs b/Marco.Pms.Model/ViewModels/PurchaseInvoice/DeliveryChallanVM.cs new file mode 100644 index 0000000..086e929 --- /dev/null +++ b/Marco.Pms.Model/ViewModels/PurchaseInvoice/DeliveryChallanVM.cs @@ -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; } + } +} diff --git a/Marco.Pms.Services/Controllers/AttendanceController.cs b/Marco.Pms.Services/Controllers/AttendanceController.cs index 2c90522..403dccf 100644 --- a/Marco.Pms.Services/Controllers/AttendanceController.cs +++ b/Marco.Pms.Services/Controllers/AttendanceController.cs @@ -88,7 +88,7 @@ namespace MarcoBMS.Services.Controllers return BadRequest(ApiResponse.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); diff --git a/Marco.Pms.Services/Controllers/PurchaseInvoiceController.cs b/Marco.Pms.Services/Controllers/PurchaseInvoiceController.cs index 0b80396..5d67aa1 100644 --- a/Marco.Pms.Services/Controllers/PurchaseInvoiceController.cs +++ b/Marco.Pms.Services/Controllers/PurchaseInvoiceController.cs @@ -124,6 +124,38 @@ namespace Marco.Pms.Services.Controllers #endregion #region =================================================================== Delivery Challan Functions =================================================================== + + /// + /// Adds a delivery challan. + /// + /// The delivery challan model. + /// The cancellation token. + /// The HTTP response for adding the delivery challan. + [HttpPost("delivery-challan/create")] + public async Task 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 =================================================================== diff --git a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs index 631e943..ea7b7b5 100644 --- a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs @@ -635,6 +635,11 @@ namespace Marco.Pms.Services.MappingProfiles .ForMember( dest => dest.PurchaseInvoiceUId, opt => opt.MapFrom(src => $"{src.UIDPrefix}/{src.UIDPostfix:D5}")); + + CreateMap() + .ForMember( + dest => dest.PurchaseInvoiceUId, + opt => opt.MapFrom(src => $"{src.UIDPrefix}/{src.UIDPostfix:D5}")); CreateMap() .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() + .ForMember( + dest => dest.Attachment, + opt => opt.Ignore()); + + CreateMap(); + #endregion } } diff --git a/Marco.Pms.Services/Service/PurchaseInvoiceService.cs b/Marco.Pms.Services/Service/PurchaseInvoiceService.cs index aabda5e..f3db011 100644 --- a/Marco.Pms.Services/Service/PurchaseInvoiceService.cs +++ b/Marco.Pms.Services/Service/PurchaseInvoiceService.cs @@ -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 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.ErrorResponse( "System configuration error", @@ -884,6 +886,172 @@ namespace Marco.Pms.Services.Service #endregion #region =================================================================== Delivery Challan Functions =================================================================== + public async Task> 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.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.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.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.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(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(deliveryChallan); + + // Manual mapping for complex nested objects if Automapper config is not set for deep linking + response.PurchaseInvoice = _mapper.Map(purchaseInvoiceEntity); + response.CreatedBy = _mapper.Map(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.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.ErrorResponse("Processing Error", "An error occurred while saving the delivery challan.", 500); + } + } #endregion #region =================================================================== Purchase Invoice History Functions =================================================================== diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IPurchaseInvoiceService.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IPurchaseInvoiceService.cs index 3f194f9..1225fee 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IPurchaseInvoiceService.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IPurchaseInvoiceService.cs @@ -18,6 +18,7 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces #endregion #region =================================================================== Delivery Challan Functions =================================================================== + Task> AddDeliveryChallanAsync(DeliveryChallanDto model, Employee loggedInEmployee, Guid tenantId, CancellationToken ct); #endregion #region =================================================================== Purchase Invoice History Functions ===================================================================