mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
Merge pull request #57 from Sea-Haven-Industries/feat/sh-98-portal-lifecycle
feat(vendor-portal): complete service request lifecycle (SH-98)
This commit is contained in:
commit
f959cc45d0
8 changed files with 572 additions and 8 deletions
61
Api.SeaHavenIndustries.Tests/UpliftServiceRefusedTests.cs
Normal file
61
Api.SeaHavenIndustries.Tests/UpliftServiceRefusedTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
311
Api.SeaHavenIndustries.Tests/VendorPortalRefuseTests.cs
Normal file
311
Api.SeaHavenIndustries.Tests/VendorPortalRefuseTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue