From 7dabd2515508c65b61ee0002b054e887b422217d Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Mon, 10 Aug 2026 13:00:32 -0300 Subject: [PATCH] feat(vendor-portal): add refusal lifecycle (SH-98) --- .../VendorPortalControllerTests.cs | 66 ++++ .../VendorPortalRefuseTests.cs | 311 ++++++++++++++++++ .../Controllers/VendorPortalController.cs | 26 ++ .../DTOs/VendorPortalServiceDTOs.cs | 18 + .../Implementation/VendorPortalService.cs | 95 +++++- .../Interfaces/IVendorPortalService.cs | 1 + 6 files changed, 510 insertions(+), 7 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/VendorPortalRefuseTests.cs diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs index 3060962..57d8332 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs @@ -157,6 +157,72 @@ public class VendorPortalControllerTests acceptData.Should().Be(acceptResult); } + [Fact] + public async Task Refuse_KeyNotFound_Returns404() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.RefuseDispatchAsync(Session, 99, It.IsAny(), It.IsAny())) + .ThrowsAsync(new KeyNotFoundException()); + var controller = NewController(service); + + var result = await controller.Refuse(99, new VendorPortalController.RefuseRequest { Reason = null }); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Refuse_InvalidOperation_Returns400WithoutInternalDetail() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.RefuseDispatchAsync(Session, 10, "no", It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Cannot refuse a dispatch with status 'Acknowledged'")); + var controller = NewController(service); + + var result = await controller.Refuse(10, new VendorPortalController.RefuseRequest { Reason = "no" }); + + var bad = result.Should().BeOfType().Subject; + var resp = bad.Value.Should().BeOfType().Subject; + resp.Message.Should().NotContain("Cannot refuse"); + resp.Message.Should().Contain("reference"); + } + + [Fact] + public async Task Refuse_Success_ReturnsRefusedResult() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + var refusedAt = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + var refuseResult = new RefuseDispatchResultDTO { Id = 10, Status = "Refused", RefusedAt = refusedAt }; + service.Setup(x => x.RefuseDispatchAsync(Session, 10, "Too far", It.IsAny())) + .ReturnsAsync(refuseResult); + var controller = NewController(service); + + var result = await controller.Refuse(10, new VendorPortalController.RefuseRequest { Reason = "Too far" }); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + var data = envelope.Data as RefuseDispatchResultDTO; + data.Should().Be(refuseResult); + service.Verify(x => x.RefuseDispatchAsync(Session, 10, "Too far", It.IsAny()), Times.Once); + } + + [Fact] + public void RefuseEndpoint_AdvertisesDualCompatibilityRoutes() + { + var routes = typeof(VendorPortalController) + .GetMethod(nameof(VendorPortalController.Refuse))! + .GetCustomAttributes(typeof(HttpPostAttribute), inherit: false) + .Cast() + .Select(attribute => attribute.Template); + + routes.Should().BeEquivalentTo("dispatches/{id:int}/refuse"); + } + [Fact] public async Task ChangeStatus_ForbiddenTransition_Returns400() { diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalRefuseTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalRefuseTests.cs new file mode 100644 index 0000000..fba8e58 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/VendorPortalRefuseTests.cs @@ -0,0 +1,311 @@ +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public sealed class VendorPortalRefuseTests +{ + private const string Token = "vendor-refuse-test-token"; + + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static async Task<(Vendor Vendor, WorkOrder WorkOrder, Dispatch Dispatch)> SeedSentDispatch( + ApplicationDbContext context, string status = "Sent") + { + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder + { + WorkerOrderTitle = "Repair", + SiteCode = "SITE-42", + Service = "Emergency plumbing" + }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = new Dispatch + { + VendorId = vendor.Id, + WorkOrderId = workOrder.Id, + Status = status, + DispatchNumber = "DSP-1" + }; + context.Dispatches.Add(dispatch); + context.VendorAccessTokens.Add(new VendorAccessToken + { + VendorId = vendor.Id, + Token = Token, + IssuedAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.AddDays(1) + }); + await context.SaveChangesAsync(); + return (vendor, workOrder, dispatch); + } + + private static VendorPortalService NewService( + ApplicationDbContext context, + Mock? userData = null) + { + userData ??= new Mock(); + var vendorData = new VendorDataService(context); + var tokenService = new VendorPortalTokenService( + vendorData, + Microsoft.Extensions.Options.Options.Create(new VendorPortalOptions())); + var upliftData = new Mock(); + upliftData.Setup(u => u.GetForVendorDispatchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + + return new VendorPortalService( + tokenService, + new DispatchDataService(context), + upliftData.Object, + new CommentDataService(context), + userData.Object, + Mock.Of(), + new VendorDocumentDataService(context), + Mock.Of(), + Microsoft.Extensions.Options.Options.Create(new FrontendOptions()), + Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()), + Microsoft.Extensions.Options.Options.Create(new VendorDocumentsOptions())); + } + + private static VendorPortalSession SessionFor(Vendor vendor) => new() + { + Id = vendor.Id, + CompanyName = vendor.CompanyName + }; + + [Fact] + public async Task RefuseDispatch_NoReason_RefusesStagesAuditAndOmitsComment() + { + using var context = NewContext(); + var (vendor, _, dispatch) = await SeedSentDispatch(context); + var service = NewService(context); + + var result = await service.RefuseDispatchAsync(SessionFor(vendor), dispatch.Id, null, CancellationToken.None); + + result.Status.Should().Be("Refused"); + result.Id.Should().Be(dispatch.Id); + result.RefusedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + + var reloaded = await context.Dispatches.FindAsync(dispatch.Id); + reloaded!.Status.Should().Be("Refused"); + reloaded.LastModificationTime.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + + var audit = await context.WorkOrderAuditLogs.SingleAsync(); + audit.Action.Should().Be("vendor_refuse"); + audit.OldValue.Should().Be("Sent"); + audit.NewValue.Should().Be("Refused"); + + context.Comments.Should().BeEmpty(); + } + + [Fact] + public async Task RefuseDispatch_NonblankReason_TrimsAndStagesRefusalComment() + { + using var context = NewContext(); + var (vendor, _, dispatch) = await SeedSentDispatch(context); + var service = NewService(context); + + var result = await service.RefuseDispatchAsync( + SessionFor(vendor), dispatch.Id, " Out of capacity ", CancellationToken.None); + + result.Status.Should().Be("Refused"); + + var comment = await context.Comments.SingleAsync(); + comment.RecordType.Should().Be("refusal"); + comment.CommentType.Should().Be("vendor"); + comment.Commenttext.Should().Be("Out of capacity"); + comment.Commenter.Should().Be(vendor.CompanyName); + comment.CreatedDate.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task RefuseDispatch_FiveHundredCharReason_Accepted() + { + using var context = NewContext(); + var (vendor, _, dispatch) = await SeedSentDispatch(context); + var service = NewService(context); + var reason = new string('x', 500); + + var result = await service.RefuseDispatchAsync(SessionFor(vendor), dispatch.Id, reason, CancellationToken.None); + + result.Status.Should().Be("Refused"); + var comment = await context.Comments.SingleAsync(); + comment.Commenttext.Should().HaveLength(500); + } + + [Fact] + public async Task RefuseDispatch_OverFiveHundredChars_Rejected() + { + using var context = NewContext(); + var (vendor, _, dispatch) = await SeedSentDispatch(context); + var service = NewService(context); + var reason = new string('x', 501); + + var act = () => service.RefuseDispatchAsync(SessionFor(vendor), dispatch.Id, reason, CancellationToken.None); + + await act.Should().ThrowAsync(); + var reloaded = await context.Dispatches.FindAsync(dispatch.Id); + reloaded!.Status.Should().Be("Sent"); + context.WorkOrderAuditLogs.Should().BeEmpty(); + context.Comments.Should().BeEmpty(); + } + + [Fact] + public async Task RefuseDispatch_NonSentStatus_Rejected() + { + using var context = NewContext(); + var (vendor, _, dispatch) = await SeedSentDispatch(context, status: "Acknowledged"); + var service = NewService(context); + + var act = () => service.RefuseDispatchAsync(SessionFor(vendor), dispatch.Id, null, CancellationToken.None); + + (await act.Should().ThrowAsync()) + .WithMessage("*Acknowledged*"); + context.WorkOrderAuditLogs.Should().BeEmpty(); + } + + [Fact] + public async Task RefuseDispatch_WrongVendor_ReturnsKeyNotFoundViaScopedLookup() + { + using var context = NewContext(); + var (vendor, _, dispatch) = await SeedSentDispatch(context); + var service = NewService(context); + var otherSession = new VendorPortalSession { Id = vendor.Id + 999, CompanyName = "Other" }; + + var act = () => service.RefuseDispatchAsync(otherSession, dispatch.Id, null, CancellationToken.None); + + await act.Should().ThrowAsync(); + var reloaded = await context.Dispatches.FindAsync(dispatch.Id); + reloaded!.Status.Should().Be("Sent"); + } + + [Fact] + public async Task RefuseDispatch_ForwardsCancellationToken() + { + using var context = NewContext(); + var (vendor, _, dispatch) = await SeedSentDispatch(context); + var service = NewService(context); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => service.RefuseDispatchAsync(SessionFor(vendor), dispatch.Id, null, cts.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetDispatchDetail_IncludesSiteCode_StatusUpdatedAt_AndAllowlistedDispatcherContact() + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedSentDispatch(context); + var statusUpdatedAt = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc); + dispatch.LastModificationTime = statusUpdatedAt; + await context.SaveChangesAsync(); + context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + { + WorkOrderId = workOrder.Id, + Action = "dispatch", + UserId = "dispatcher-1", + CreatedAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + var userData = new Mock(); + userData.Setup(u => u.GetProfileAsync("dispatcher-1", It.IsAny())) + .ReturnsAsync(new UserProfileData + { + Id = "dispatcher-1", + FirstName = "Dana", + Email = "dana@shoc.test", + Contact = "555-0199" + }); + var service = NewService(context, userData); + + var detail = await service.GetDispatchDetailAsync(SessionFor(vendor), dispatch.Id, CancellationToken.None); + + detail.Should().NotBeNull(); + detail!.WorkOrder!.SiteCode.Should().Be("SITE-42"); + detail.WorkOrder.Service.Should().Be("Emergency plumbing"); + detail.StatusUpdatedAt.Should().Be(statusUpdatedAt); + + detail.DispatcherContact.Should().NotBeNull(); + detail.DispatcherContact!.Name.Should().Be("Dana"); + detail.DispatcherContact.Email.Should().Be("dana@shoc.test"); + detail.DispatcherContact.Phone.Should().Be("555-0199"); + + var contactJson = System.Text.Json.JsonSerializer.Serialize(detail.DispatcherContact); + contactJson.Should().NotContain("dispatcher-1"); + contactJson.Should().NotContain("Id"); + } + + [Fact] + public async Task RefuseDispatch_ThenRefuseAgain_IsRejectedAsNonSent() + { + using var context = NewContext(); + var (vendor, _, dispatch) = await SeedSentDispatch(context); + var service = NewService(context); + + await service.RefuseDispatchAsync(SessionFor(vendor), dispatch.Id, null, CancellationToken.None); + + var act = () => service.RefuseDispatchAsync(SessionFor(vendor), dispatch.Id, null, CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task RefuseDispatch_MarksRefusedSoPortalLocksApply() + { + using var context = NewContext(); + var (vendor, _, dispatch) = await SeedSentDispatch(context); + var service = NewService(context); + + await service.RefuseDispatchAsync(SessionFor(vendor), dispatch.Id, null, CancellationToken.None); + + var act = () => service.RequestCancelAsync(SessionFor(vendor), dispatch.Id, null, CancellationToken.None); + + (await act.Should().ThrowAsync()) + .WithMessage("*Refused*"); + } + + [Fact] + public async Task UploadCompletionDocument_RefusedDispatch_IsLocked() + { + using var context = NewContext(); + var (vendor, _, dispatch) = await SeedSentDispatch(context); + var service = NewService(context); + await service.RefuseDispatchAsync(SessionFor(vendor), dispatch.Id, null, CancellationToken.None); + var bytes = "%PDF-1.4\ncompletion"u8.ToArray(); + var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "completion.pdf") + { + Headers = new HeaderDictionary(), + ContentType = "application/pdf" + }; + + var act = () => service.UploadCompletionDocumentAsync( + SessionFor(vendor), dispatch.Id, file, null, CancellationToken.None); + + await act.Should().ThrowAsync().WithMessage("*locked*"); + context.VendorCompletionDocuments.Should().BeEmpty(); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs index f4852ac..3642a48 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs @@ -80,6 +80,27 @@ namespace Api.SeaHavenIndustries.Controllers } } + [HttpPost("dispatches/{id:int}/refuse")] + public async Task Refuse(int id, [FromBody] RefuseRequest? body, CancellationToken cancellationToken = default) + { + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + + try + { + var result = await _portalService.RefuseDispatchAsync(session, id, body?.Reason, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); + } + catch (KeyNotFoundException) + { + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The requested action could not be completed for this dispatch") }); + } + } + [HttpPost("dispatches/{id:int}/status")] public async Task ChangeStatus(int id, [FromBody] ChangeStatusRequest body, CancellationToken cancellationToken = default) { @@ -292,6 +313,11 @@ namespace Api.SeaHavenIndustries.Controllers public string? Reason { get; set; } } + public class RefuseRequest + { + public string? Reason { get; set; } + } + public class ChecklistUpdateRequest { public bool IsCompleted { get; set; } diff --git a/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs index 3a4b5e7..cbc82b8 100644 --- a/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs @@ -79,7 +79,16 @@ namespace SeaHaven.Services.DTOs public DateTime? DueDate { get; set; } public string? Trade { get; set; } public string? SubTrade { get; set; } + public string? Service { get; set; } public string? Problem { get; set; } + public string? SiteCode { get; set; } + } + + public class DispatcherContactDTO + { + public string? Name { get; set; } + public string? Email { get; set; } + public string? Phone { get; set; } } public class PortalLocationDTO @@ -106,8 +115,10 @@ namespace SeaHaven.Services.DTOs public DateTime? CompletedDate { get; set; } public DateTime? DispatchedAt { get; set; } public DateTime? AcknowledgedAt { get; set; } + public DateTime? StatusUpdatedAt { get; set; } public PortalWorkOrderDTO? WorkOrder { get; set; } public PortalLocationDTO? Location { get; set; } + public DispatcherContactDTO? DispatcherContact { get; set; } public IEnumerable Checklist { get; set; } = Enumerable.Empty(); public IEnumerable Signoffs { get; set; } = Enumerable.Empty(); public IEnumerable Comments { get; set; } = Enumerable.Empty(); @@ -139,6 +150,13 @@ namespace SeaHaven.Services.DTOs public DateTime? AcknowledgedAt { get; set; } } + public class RefuseDispatchResultDTO + { + public int Id { get; set; } + public string? Status { get; set; } + public DateTime? RefusedAt { get; set; } + } + public class ChangeStatusResultDTO { public int Id { get; set; } diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index d0514c6..f2deb76 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -15,6 +15,8 @@ namespace SeaHaven.Services.Implementation "application/pdf", "image/jpeg", "image/jpg", "image/png" }; + private const int MaxRefusalReasonLength = 500; + private readonly IVendorPortalTokenService _tokens; private readonly IDispatchDataService _dispatchData; private readonly IUpliftDataService _upliftData; @@ -144,6 +146,26 @@ namespace SeaHaven.Services.Implementation }; }).ToList(); + DispatcherContactDTO? dispatcherContact = null; + if (dispatch.WorkOrderId.HasValue) + { + var dispatcherUserId = await _dispatchData.GetDispatchDispatcherUserIdAsync( + dispatch.WorkOrderId.Value, cancellationToken); + if (!string.IsNullOrWhiteSpace(dispatcherUserId)) + { + var profile = await _userData.GetProfileAsync(dispatcherUserId, cancellationToken); + if (profile != null) + { + dispatcherContact = new DispatcherContactDTO + { + Name = profile.FirstName, + Email = profile.Email, + Phone = profile.Contact + }; + } + } + } + return new VendorDispatchDetailDTO { Id = dispatch.Id, @@ -159,6 +181,7 @@ namespace SeaHaven.Services.Implementation CompletedDate = dispatch.CompletedDate, DispatchedAt = dispatch.DispatchedAt, AcknowledgedAt = dispatch.AcknowledgedAt, + StatusUpdatedAt = dispatch.LastModificationTime, WorkOrder = dispatch.WorkOrder == null ? null : new PortalWorkOrderDTO { Id = dispatch.WorkOrder.Id, @@ -169,7 +192,9 @@ namespace SeaHaven.Services.Implementation DueDate = dispatch.WorkOrder.DueDate, Trade = dispatch.WorkOrder.Trade, SubTrade = dispatch.WorkOrder.SubTrade, - Problem = dispatch.WorkOrder.Problem + Service = dispatch.WorkOrder.Service, + Problem = dispatch.WorkOrder.Problem, + SiteCode = dispatch.WorkOrder.SiteCode }, Location = dispatch.WorkOrder?.Locations == null ? null : new PortalLocationDTO { @@ -179,6 +204,7 @@ namespace SeaHaven.Services.Implementation State = dispatch.WorkOrder.Locations.State, Zip = dispatch.WorkOrder.Locations.Zip }, + DispatcherContact = dispatcherContact, Checklist = checklist.Select(c => new PortalChecklistItemDTO { Id = c.Id, @@ -212,8 +238,7 @@ namespace SeaHaven.Services.Implementation ScannedAt = document.ScannedAt, ReviewedAt = document.ReviewedAt, CanDownload = string.Equals(document.ScanStatus, "Passed", StringComparison.OrdinalIgnoreCase) - && dispatch.Status != "Verified" - && dispatch.Status != "Cancelled" + && !IsPortalLockedStatus(dispatch.Status) }) }; } @@ -247,6 +272,54 @@ namespace SeaHaven.Services.Implementation return new AcceptDispatchResultDTO { Id = dispatch.Id, Status = dispatch.Status, AcknowledgedAt = dispatch.AcknowledgedAt }; } + public async Task RefuseDispatchAsync(VendorPortalSession session, int id, string? reason, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + + if (dispatch.Status != "Sent") + { + throw new InvalidOperationException($"Cannot refuse a dispatch with status '{dispatch.Status}'"); + } + + var reasonText = reason?.Trim(); + if (reasonText != null && reasonText.Length > MaxRefusalReasonLength) + { + throw new InvalidOperationException($"Refusal reason must be {MaxRefusalReasonLength} characters or fewer"); + } + + var now = DateTime.UtcNow; + dispatch.Status = "Refused"; + dispatch.LastModificationTime = now; + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + FieldName = $"Dispatch {dispatch.DispatchNumber} Status", + OldValue = "Sent", + NewValue = "Refused", + Action = "vendor_refuse", + CreatedAt = now + }, cancellationToken); + + if (!string.IsNullOrWhiteSpace(reasonText)) + { + await _commentData.StageAsync(new Comments + { + DispatchId = id, + WorkerOrderId = dispatch.WorkOrderId, + Commenter = session.CompanyName, + CommentType = "vendor", + RecordType = "refusal", + Commenttext = reasonText, + CreatedDate = now + }, cancellationToken); + } + + await _dispatchData.SaveChangesAsync(cancellationToken); + return new RefuseDispatchResultDTO { Id = dispatch.Id, Status = dispatch.Status, RefusedAt = now }; + } + public async Task ChangeStatusAsync(VendorPortalSession session, int id, string? to, CancellationToken cancellationToken) { var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); @@ -282,7 +355,7 @@ namespace SeaHaven.Services.Implementation var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); - if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") + if (IsPortalLockedStatus(dispatch.Status)) { throw new InvalidOperationException($"Cannot request cancel on a '{dispatch.Status}' dispatch"); } @@ -319,7 +392,7 @@ namespace SeaHaven.Services.Implementation var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); - if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") + if (IsPortalLockedStatus(dispatch.Status)) { throw new InvalidOperationException("Dispatch is locked"); } @@ -435,7 +508,7 @@ namespace SeaHaven.Services.Implementation var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); - if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") + if (IsPortalLockedStatus(dispatch.Status)) { throw new InvalidOperationException($"Cannot request uplift on a '{dispatch.Status}' dispatch"); } @@ -569,6 +642,11 @@ namespace SeaHaven.Services.Implementation throw new KeyNotFoundException("Dispatch not found"); } + if (IsPortalLockedStatus(dispatch.Status)) + { + throw new InvalidOperationException("Dispatch is locked"); + } + var latest = await _documentData.GetLatestForDispatchAsync(dispatchId, cancellationToken); var replacedDocument = latest; if (replacesDocumentId.HasValue) @@ -645,7 +723,7 @@ namespace SeaHaven.Services.Implementation } var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(dispatchId, session.Id, cancellationToken); - if (dispatch != null && (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")) + if (dispatch != null && IsPortalLockedStatus(dispatch.Status)) { return DownloadCompletionDocumentResultDTO.Locked(); } @@ -739,5 +817,8 @@ namespace SeaHaven.Services.Implementation if (from == "In Progress" && to == "Completed") return true; return false; } + + private static bool IsPortalLockedStatus(string? status) + => status == "Verified" || status == "Cancelled" || status == "Refused"; } } diff --git a/SeaHaven.Services/Interfaces/IVendorPortalService.cs b/SeaHaven.Services/Interfaces/IVendorPortalService.cs index 6c53b0c..d379319 100644 --- a/SeaHaven.Services/Interfaces/IVendorPortalService.cs +++ b/SeaHaven.Services/Interfaces/IVendorPortalService.cs @@ -8,6 +8,7 @@ namespace SeaHaven.Services.Interfaces Task> ListDispatchesAsync(VendorPortalSession session, string? status, CancellationToken cancellationToken); Task GetDispatchDetailAsync(VendorPortalSession session, int id, CancellationToken cancellationToken); Task AcceptDispatchAsync(VendorPortalSession session, int id, CancellationToken cancellationToken); + Task RefuseDispatchAsync(VendorPortalSession session, int id, string? reason, CancellationToken cancellationToken); Task ChangeStatusAsync(VendorPortalSession session, int id, string? to, CancellationToken cancellationToken); Task RequestCancelAsync(VendorPortalSession session, int id, string? reason, CancellationToken cancellationToken); Task UpdateChecklistItemAsync(VendorPortalSession session, int id, int itemId, bool isCompleted, CancellationToken cancellationToken);