diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index 33ef432..9ca6a7e 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -35,6 +35,11 @@ public sealed class VendorPortalDocumentTests : IDisposable } private VendorPortalController NewController(ApplicationDbContext context) + { + return NewController(NewService(context)); + } + + private VendorPortalService NewService(ApplicationDbContext context) { var vendorData = new VendorDataService(context); var tokenService = new VendorPortalTokenService( @@ -45,11 +50,18 @@ public sealed class VendorPortalDocumentTests : IDisposable storageEnvironment.SetupGet(item => item.ContentRootPath).Returns(_contentRoot); var storage = new VendorDocumentStorageAdapter(storageEnvironment.Object); - var service = new VendorPortalService( + var upliftData = new Mock(); + upliftData.Setup(u => u.GetForVendorDispatchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + var commentData = new Mock(); + commentData.Setup(c => c.GetVendorViewableForDispatchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + + return new VendorPortalService( tokenService, new DispatchDataService(context), - Mock.Of(), - Mock.Of(), + upliftData.Object, + commentData.Object, Mock.Of(), Mock.Of(), documentData, @@ -57,7 +69,10 @@ public sealed class VendorPortalDocumentTests : IDisposable Microsoft.Extensions.Options.Options.Create(new FrontendOptions()), Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()), Microsoft.Extensions.Options.Options.Create(new VendorDocumentsOptions())); + } + private static VendorPortalController NewController(VendorPortalService service) + { var controller = new VendorPortalController(service, Mock.Of>()); controller.ControllerContext = new ControllerContext { @@ -292,6 +307,173 @@ public sealed class VendorPortalDocumentTests : IDisposable context.VendorCompletionDocuments.Should().HaveCount(1); } + [Fact] + public async Task GetDispatchDetail_ReturnsUploadedDocumentWithQuarantineAndReplacementMetadata() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + var service = NewService(context); + var pdf = "%PDF-1.4\nvendor completion"u8.ToArray(); + + await NewController(service).UploadCompletionDocument( + dispatch.Id, + FormFile(pdf, "completion-v1.pdf", "application/pdf")); + + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + var firstDetail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None); + + firstDetail.Should().NotBeNull(); + var firstDoc = firstDetail!.Documents.Should().ContainSingle().Subject; + firstDoc.OriginalFileName.Should().Be("completion-v1.pdf"); + firstDoc.ContentType.Should().Be("application/pdf"); + firstDoc.SizeBytes.Should().Be(pdf.Length); + firstDoc.ScanStatus.Should().Be("Pending"); + firstDoc.ReviewStatus.Should().Be("Processing"); + firstDoc.Version.Should().Be(1); + firstDoc.ReplacesDocumentId.Should().BeNull(); + firstDoc.CanDownload.Should().BeFalse(); + firstDoc.CreatedDate.Should().NotBeNull(); + + var first = await context.VendorCompletionDocuments.SingleAsync(); + await NewController(service).UploadCompletionDocument( + dispatch.Id, + FormFile(pdf, "completion-v2.pdf", "application/pdf"), + first.Id); + + var secondDetail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None); + var ordered = secondDetail!.Documents.OrderByDescending(d => d.Version).ToList(); + ordered.Should().HaveCount(2); + ordered[0].Version.Should().Be(2); + ordered[0].ReplacesDocumentId.Should().Be(first.Id); + ordered[0].OriginalFileName.Should().Be("completion-v2.pdf"); + ordered[1].Version.Should().Be(1); + ordered[1].ReplacesDocumentId.Should().BeNull(); + } + + [Fact] + public async Task GetDispatchDetail_EnablesDownloadOnlyWhenScanPasses() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + var service = NewService(context); + var pdf = "%PDF-1.4\nvendor completion"u8.ToArray(); + + await NewController(service).UploadCompletionDocument( + dispatch.Id, + FormFile(pdf, "completion.pdf", "application/pdf")); + + var document = await context.VendorCompletionDocuments.SingleAsync(); + document.ScanStatus = "Passed"; + document.ScannedAt = DateTime.UtcNow; + await context.SaveChangesAsync(); + + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None); + + var doc = detail!.Documents.Should().ContainSingle().Subject; + doc.ScanStatus.Should().Be("Passed"); + doc.CanDownload.Should().BeTrue(); + doc.ScannedAt.Should().NotBeNull(); + } + + [Theory] + [InlineData("Verified")] + [InlineData("Cancelled")] + public async Task GetDispatchDetail_DisablesDownloadForLockedDispatches(string status) + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + var service = NewService(context); + var pdf = "%PDF-1.4\nvendor completion"u8.ToArray(); + + await NewController(service).UploadCompletionDocument( + dispatch.Id, + FormFile(pdf, "completion.pdf", "application/pdf")); + + var document = await context.VendorCompletionDocuments.SingleAsync(); + document.ScanStatus = "Passed"; + dispatch.Status = status; + await context.SaveChangesAsync(); + + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None); + + detail!.Documents.Should().ContainSingle().Which.CanDownload.Should().BeFalse(); + } + + [Fact] + public async Task GetDispatchDetail_ExcludesSoftDeletedDocuments() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + var service = NewService(context); + var pdf = "%PDF-1.4\nvendor completion"u8.ToArray(); + + await NewController(service).UploadCompletionDocument( + dispatch.Id, + FormFile(pdf, "completion.pdf", "application/pdf")); + + var document = await context.VendorCompletionDocuments.SingleAsync(); + document.IsDeleted = true; + await context.SaveChangesAsync(); + + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None); + + detail!.Documents.Should().BeEmpty(); + } + + [Fact] + public async Task GetDispatchDetail_ReturnsInvoicePaymentFieldsOnlyForOwningVendor() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + dispatch.InvoiceNumber = "INV-2048"; + dispatch.InvoiceStatus = "Confirmed"; + dispatch.PaymentStatus = "Scheduled"; + await context.SaveChangesAsync(); + + var service = NewService(context); + var ownerSession = await service.ResolveSessionAsync(Token, CancellationToken.None); + var ownerDetail = await service.GetDispatchDetailAsync(ownerSession!, dispatch.Id, CancellationToken.None); + + ownerDetail.Should().NotBeNull(); + ownerDetail!.InvoiceNumber.Should().Be("INV-2048"); + ownerDetail.InvoiceStatus.Should().Be("Confirmed"); + ownerDetail.PaymentStatus.Should().Be("Scheduled"); + + var ownerResult = await NewController(service).GetDispatch(dispatch.Id); + var ownerOk = ownerResult.Should().BeOfType().Subject; + var ownerResponse = ownerOk.Value.Should().BeOfType().Subject; + var ownerData = ((object)ownerResponse.Data) + .Should().BeOfType().Subject; + ownerData.InvoiceNumber.Should().Be("INV-2048"); + ownerData.InvoiceStatus.Should().Be("Confirmed"); + ownerData.PaymentStatus.Should().Be("Scheduled"); + + var otherVendor = new Vendor { CompanyName = "Other Vendor", IsActive = true }; + context.Vendors.Add(otherVendor); + await context.SaveChangesAsync(); + const string otherToken = "other-vendor-token"; + context.VendorAccessTokens.Add(new VendorAccessToken + { + VendorId = otherVendor.Id, + Token = otherToken, + IssuedAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.AddDays(1) + }); + await context.SaveChangesAsync(); + + var otherSession = await service.ResolveSessionAsync(otherToken, CancellationToken.None); + var otherDetail = await service.GetDispatchDetailAsync(otherSession!, dispatch.Id, CancellationToken.None); + otherDetail.Should().BeNull(); + + var otherController = NewController(service); + otherController.Request.Headers["X-Vendor-Token"] = otherToken; + var otherResult = await otherController.GetDispatch(dispatch.Id); + otherResult.Should().BeOfType(); + } + public void Dispose() { if (Directory.Exists(_contentRoot)) diff --git a/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs b/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs index 2ee5192..05aa4e9 100644 --- a/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs @@ -30,6 +30,16 @@ namespace SeaHaven.DataServices.Implementation && (document.IsDeleted == null || document.IsDeleted == false), cancellationToken); } + public Task> ListForVendorDispatchAsync(int dispatchId, int vendorId, CancellationToken cancellationToken) + { + return _context.VendorCompletionDocuments + .Where(document => document.DispatchId == dispatchId + && document.VendorId == vendorId + && (document.IsDeleted == null || document.IsDeleted == false)) + .OrderByDescending(document => document.Version) + .ToListAsync(cancellationToken); + } + public async Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken) { await _context.VendorCompletionDocuments.AddAsync(document, cancellationToken); diff --git a/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs index 5849b51..d6c76a6 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs @@ -6,6 +6,7 @@ namespace SeaHaven.DataServices.Interfaces { Task GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken); Task GetForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken); + Task> ListForVendorDispatchAsync(int dispatchId, int vendorId, CancellationToken cancellationToken); Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken); Task SaveChangesAsync(CancellationToken cancellationToken); } diff --git a/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs index 27cc8e7..3a4b5e7 100644 --- a/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs @@ -97,6 +97,9 @@ namespace SeaHaven.Services.DTOs public string? DispatchNumber { get; set; } public string? PONumber { get; set; } public string? Status { get; set; } + public string? InvoiceNumber { get; set; } + public string? InvoiceStatus { get; set; } + public string? PaymentStatus { get; set; } public decimal? NTEAmount { get; set; } public string? Description { get; set; } public DateTime? ScheduledDate { get; set; } @@ -109,6 +112,24 @@ namespace SeaHaven.Services.DTOs public IEnumerable Signoffs { get; set; } = Enumerable.Empty(); public IEnumerable Comments { get; set; } = Enumerable.Empty(); public IEnumerable UpliftRequests { get; set; } = Enumerable.Empty(); + public IEnumerable Documents { get; set; } = Enumerable.Empty(); + } + + public class VendorPortalDocumentDTO + { + public int Id { get; set; } + public string? OriginalFileName { get; set; } + public string? ContentType { get; set; } + public long SizeBytes { get; set; } + public string? ScanStatus { get; set; } + public string? ReviewStatus { get; set; } + public string? RejectionReason { get; set; } + public int Version { get; set; } + public int? ReplacesDocumentId { get; set; } + public DateTime? CreatedDate { get; set; } + public DateTime? ScannedAt { get; set; } + public DateTime? ReviewedAt { get; set; } + public bool CanDownload { get; set; } } public class AcceptDispatchResultDTO diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index b3b2362..d0514c6 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -90,6 +90,7 @@ namespace SeaHaven.Services.Implementation var signoffs = await _dispatchData.GetPortalSignoffsAsync(id, cancellationToken); var rawUplifts = await _upliftData.GetForVendorDispatchAsync(id, cancellationToken); var rawComments = await _commentData.GetVendorViewableForDispatchAsync(id, cancellationToken); + var rawDocuments = await _documentData.ListForVendorDispatchAsync(id, session.Id, cancellationToken); var upliftRequests = rawUplifts.Select(u => new PortalUpliftDTO { @@ -149,6 +150,9 @@ namespace SeaHaven.Services.Implementation DispatchNumber = dispatch.DispatchNumber, PONumber = dispatch.PONumber, Status = dispatch.Status, + InvoiceNumber = dispatch.InvoiceNumber, + InvoiceStatus = dispatch.InvoiceStatus, + PaymentStatus = dispatch.PaymentStatus, NTEAmount = dispatch.NTEAmount, Description = dispatch.Description, ScheduledDate = dispatch.ScheduledDate, @@ -192,7 +196,25 @@ namespace SeaHaven.Services.Implementation SignedAt = s.SignedAt }), Comments = comments, - UpliftRequests = upliftRequests + UpliftRequests = upliftRequests, + Documents = rawDocuments.Select(document => new VendorPortalDocumentDTO + { + Id = document.Id, + OriginalFileName = document.OriginalFileName, + ContentType = document.ContentType, + SizeBytes = document.SizeBytes, + ScanStatus = document.ScanStatus, + ReviewStatus = document.ReviewStatus, + RejectionReason = document.RejectionReason, + Version = document.Version, + ReplacesDocumentId = document.ReplacesDocumentId, + CreatedDate = document.CreatedDate, + ScannedAt = document.ScannedAt, + ReviewedAt = document.ReviewedAt, + CanDownload = string.Equals(document.ScanStatus, "Passed", StringComparison.OrdinalIgnoreCase) + && dispatch.Status != "Verified" + && dispatch.Status != "Cancelled" + }) }; }