Merge pull request #57 from Sea-Haven-Industries/feat/sh-98-portal-lifecycle
Some checks are pending
Validate and deploy dev / Validate deployable source bundle (push) Waiting to run
Validate and deploy dev / Deploy shoc-backend to Elastic Beanstalk dev (push) Blocked by required conditions

feat(vendor-portal): complete service request lifecycle (SH-98)
This commit is contained in:
Alexandre Brandizzi 2026-08-11 09:01:59 -03:00 • committed by GitHub
commit f959cc45d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 572 additions and 8 deletions

View file

@ -0,0 +1,61 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.Extensions.Options;
using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public sealed class UpliftServiceRefusedTests
{
[Fact]
public async Task ApproveAsync_RefusedDispatch_RejectsWithoutChangingNteOrRequest()
{
var request = new DispatchUpliftRequest
{
Id = 7,
DispatchId = 42,
Status = "Pending",
RequiredTier = 1,
RequestedNTE = 900m
};
var dispatch = new Dispatch
{
Id = 42,
Status = "Refused",
NTEAmount = 500m,
DispatchNumber = "DSP-42"
};
var upliftData = new Mock<IUpliftDataService>();
upliftData.Setup(data => data.GetByIdAsync(7, It.IsAny<CancellationToken>()))
.ReturnsAsync(request);
var dispatchData = new Mock<IDispatchDataService>();
dispatchData.Setup(data => data.GetByIdAsync(42)).ReturnsAsync(dispatch);
var service = new UpliftService(
upliftData.Object,
dispatchData.Object,
Microsoft.Extensions.Options.Options.Create(
new ApprovalsOptions { Tier1Roles = new[] { "UpliftApprover" } }));
var user = new ClaimsPrincipal(new ClaimsIdentity(
new[]
{
new Claim(ClaimTypes.NameIdentifier, "dispatcher-1"),
new Claim(ClaimTypes.Role, "UpliftApprover")
},
"test"));
var act = () => service.ApproveAsync(user, 7, "approve", CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>().WithMessage("*Refused*");
dispatch.NTEAmount.Should().Be(500m);
request.Status.Should().Be("Pending");
dispatchData.Verify(
data => data.StageAuditLogAsync(It.IsAny<WorkOrderAuditLog>(), It.IsAny<CancellationToken>()),
Times.Never);
upliftData.Verify(data => data.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Never);
}
}

View file

@ -157,6 +157,72 @@ public class VendorPortalControllerTests
acceptData.Should().Be(acceptResult);
}
[Fact]
public async Task Refuse_KeyNotFound_Returns404()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
service.Setup(x => x.RefuseDispatchAsync(Session, 99, It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new KeyNotFoundException());
var controller = NewController(service);
var result = await controller.Refuse(99, new VendorPortalController.RefuseRequest { Reason = null });
result.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task Refuse_InvalidOperation_Returns400WithoutInternalDetail()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
service.Setup(x => x.RefuseDispatchAsync(Session, 10, "no", It.IsAny<CancellationToken>()))
.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<BadRequestObjectResult>().Subject;
var resp = bad.Value.Should().BeOfType<Response>().Subject;
resp.Message.Should().NotContain("Cannot refuse");
resp.Message.Should().Contain("reference");
}
[Fact]
public async Task Refuse_Success_ReturnsRefusedResult()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.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<CancellationToken>()))
.ReturnsAsync(refuseResult);
var controller = NewController(service);
var result = await controller.Refuse(10, new VendorPortalController.RefuseRequest { Reason = "Too far" });
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
var data = envelope.Data as RefuseDispatchResultDTO;
data.Should().Be(refuseResult);
service.Verify(x => x.RefuseDispatchAsync(Session, 10, "Too far", It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public void RefuseEndpoint_AdvertisesDualCompatibilityRoutes()
{
var routes = typeof(VendorPortalController)
.GetMethod(nameof(VendorPortalController.Refuse))!
.GetCustomAttributes(typeof(HttpPostAttribute), inherit: false)
.Cast<HttpPostAttribute>()
.Select(attribute => attribute.Template);
routes.Should().BeEquivalentTo("dispatches/{id:int}/refuse");
}
[Fact]
public async Task ChangeStatus_ForbiddenTransition_Returns400()
{

View file

@ -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<ApplicationDbContext>()
.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<IUserDataService>? userData = null)
{
userData ??= new Mock<IUserDataService>();
var vendorData = new VendorDataService(context);
var tokenService = new VendorPortalTokenService(
vendorData,
Microsoft.Extensions.Options.Options.Create(new VendorPortalOptions()));
var upliftData = new Mock<IUpliftDataService>();
upliftData.Setup(u => u.GetForVendorDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<PortalUpliftData>());
return new VendorPortalService(
tokenService,
new DispatchDataService(context),
upliftData.Object,
new CommentDataService(context),
userData.Object,
Mock.Of<IEmailSender>(),
new VendorDocumentDataService(context),
Mock.Of<IVendorDocumentStoragePort>(),
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<InvalidOperationException>();
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<InvalidOperationException>())
.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<KeyNotFoundException>();
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<OperationCanceledException>();
}
[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<IUserDataService>();
userData.Setup(u => u.GetProfileAsync("dispatcher-1", It.IsAny<CancellationToken>()))
.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<InvalidOperationException>();
}
[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<InvalidOperationException>())
.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<InvalidOperationException>().WithMessage("*locked*");
context.VendorCompletionDocuments.Should().BeEmpty();
}
}

View file

@ -80,6 +80,27 @@ namespace Api.SeaHavenIndustries.Controllers
}
}
[HttpPost("dispatches/{id:int}/refuse")]
public async Task<IActionResult> 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<IActionResult> 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; }

View file

@ -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<PortalChecklistItemDTO> Checklist { get; set; } = Enumerable.Empty<PortalChecklistItemDTO>();
public IEnumerable<PortalSignoffDTO> Signoffs { get; set; } = Enumerable.Empty<PortalSignoffDTO>();
public IEnumerable<PortalCommentDTO> Comments { get; set; } = Enumerable.Empty<PortalCommentDTO>();
@ -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; }

View file

@ -88,7 +88,7 @@ namespace SeaHaven.Services.Implementation
var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId);
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")
if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled" || dispatch.Status == "Refused")
throw new InvalidOperationException($"Cannot approve uplift on a '{dispatch.Status}' dispatch");
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;

View file

@ -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<RefuseDispatchResultDTO> 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<ChangeStatusResultDTO> 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";
}
}

View file

@ -8,6 +8,7 @@ namespace SeaHaven.Services.Interfaces
Task<IEnumerable<VendorDispatchSummaryDTO>> ListDispatchesAsync(VendorPortalSession session, string? status, CancellationToken cancellationToken);
Task<VendorDispatchDetailDTO?> GetDispatchDetailAsync(VendorPortalSession session, int id, CancellationToken cancellationToken);
Task<AcceptDispatchResultDTO> AcceptDispatchAsync(VendorPortalSession session, int id, CancellationToken cancellationToken);
Task<RefuseDispatchResultDTO> RefuseDispatchAsync(VendorPortalSession session, int id, string? reason, CancellationToken cancellationToken);
Task<ChangeStatusResultDTO> ChangeStatusAsync(VendorPortalSession session, int id, string? to, CancellationToken cancellationToken);
Task RequestCancelAsync(VendorPortalSession session, int id, string? reason, CancellationToken cancellationToken);
Task<ChecklistItemResultDTO> UpdateChecklistItemAsync(VendorPortalSession session, int id, int itemId, bool isCompleted, CancellationToken cancellationToken);