mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
feat(uplifts): complete SH-101 approval lifecycle
This commit is contained in:
parent
32387bc913
commit
24283b320a
33 changed files with 6518 additions and 95 deletions
|
|
@ -210,4 +210,79 @@ public class UpliftControllerTests
|
|||
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
|
||||
envelope.Status.Should().Be("Success");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadEvidence_NotFound_Returns404()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 99, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(UpliftEvidenceDownloadResultDTO.NotFound());
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.DownloadEvidence(99);
|
||||
|
||||
result.Should().BeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadEvidence_Locked_Returns423()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 5, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(UpliftEvidenceDownloadResultDTO.Locked());
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.DownloadEvidence(5);
|
||||
|
||||
var status = result.Should().BeOfType<ObjectResult>().Subject;
|
||||
status.StatusCode.Should().Be(StatusCodes.Status423Locked);
|
||||
var response = status.Value.Should().BeOfType<Response>().Subject;
|
||||
response.Message.Should().NotContain("scan", "the locked message must not disclose internal state");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadEvidence_Available_ReturnsFile()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 5, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(UpliftEvidenceDownloadResultDTO.Ok(new MemoryStream(new byte[] { 1, 2, 3 }), "application/pdf", "invoice.pdf"));
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.DownloadEvidence(5);
|
||||
|
||||
result.Should().BeOfType<FileStreamResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadEvidence_Forbidden_ReturnsSanitized403()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
service.Setup(x => x.GetEvidenceForDownloadAsync(It.IsAny<ClaimsPrincipal>(), 5, It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new UpliftForbiddenException("SECRET-role-policy"));
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.DownloadEvidence(5);
|
||||
|
||||
var forbidden = result.Should().BeOfType<ObjectResult>().Subject;
|
||||
forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
|
||||
var response = forbidden.Value.Should().BeOfType<Response>().Subject;
|
||||
response.Message.Should().NotContain("SECRET");
|
||||
response.Message.Should().Contain("reference");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestChanges_InvalidOperation_ReturnsSanitized400()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
service.Setup(x => x.RequestChangesAsync(It.IsAny<ClaimsPrincipal>(), 5, "note", It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("SECRET-internal-state"));
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.RequestChanges(5, new UpliftController.DecisionRequest { Note = "note" });
|
||||
|
||||
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||
var resp = bad.Value.Should().BeOfType<Response>().Subject;
|
||||
resp.Message.Should().NotContain("SECRET");
|
||||
resp.Message.Should().Contain("reference");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using Moq;
|
|||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
|
@ -38,6 +39,8 @@ public sealed class UpliftServiceRefusedTests
|
|||
var service = new UpliftService(
|
||||
upliftData.Object,
|
||||
dispatchData.Object,
|
||||
Mock.Of<IVendorDocumentStoragePort>(),
|
||||
TimeProvider.System,
|
||||
Microsoft.Extensions.Options.Options.Create(
|
||||
new ApprovalsOptions { Tier1Roles = new[] { "UpliftApprover" } }));
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
|
|
|
|||
887
Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs
Normal file
887
Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs
Normal file
|
|
@ -0,0 +1,887 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
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 System.Security.Claims;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
// Repository-owned behavior tests for the SH-101 uplift approval workflow. These exercise
|
||||
// the real service + data-service layers against an in-memory DbContext so that state
|
||||
// transitions, audit correctness, idempotency, scoping, and the internal evidence
|
||||
// download are validated without recreating infrastructure or depending on SQL Server.
|
||||
public sealed class UpliftWorkflowTests
|
||||
{
|
||||
private const string Token = "uplift-workflow-token";
|
||||
|
||||
private static ApplicationDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static ApprovalsOptions NewOptions() => new()
|
||||
{
|
||||
UpliftTier1MaxUsd = 2500m,
|
||||
Tier1Roles = new[] { "Approver" },
|
||||
Tier2Roles = new[] { "Manager" },
|
||||
Tier1NotificationRecipients = new[] { "tier1@example.com" },
|
||||
Tier2NotificationRecipients = new[] { "tier2@example.com" },
|
||||
EscalationRecipients = new[] { "escalate@example.com" }
|
||||
};
|
||||
|
||||
private static ClaimsPrincipal UserWithRoles(params string[] roles)
|
||||
{
|
||||
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, "user-42") };
|
||||
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "Test"));
|
||||
}
|
||||
|
||||
private sealed class FakeDocumentStorage : IVendorDocumentStoragePort
|
||||
{
|
||||
private readonly Dictionary<string, byte[]> _files = new(StringComparer.Ordinal);
|
||||
|
||||
public Task SaveAsync(int vendorId, int dispatchId, string storedFileName, Stream content, CancellationToken cancellationToken)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
content.CopyTo(ms);
|
||||
_files[Key(vendorId, dispatchId, storedFileName)] = ms.ToArray();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Stream OpenRead(int vendorId, int dispatchId, string storedFileName)
|
||||
{
|
||||
var bytes = _files[Key(vendorId, dispatchId, storedFileName)];
|
||||
return new MemoryStream(bytes, writable: false);
|
||||
}
|
||||
|
||||
public void Delete(int vendorId, int dispatchId, string storedFileName)
|
||||
=> _files.Remove(Key(vendorId, dispatchId, storedFileName));
|
||||
|
||||
private static string Key(int vendorId, int dispatchId, string storedFileName)
|
||||
=> $"{vendorId}/{dispatchId}/{storedFileName}";
|
||||
}
|
||||
|
||||
private sealed class FakeEmailSender : IEmailSender
|
||||
{
|
||||
private readonly bool _deliver;
|
||||
public int Calls;
|
||||
public FakeEmailSender(bool deliver) => _deliver = deliver;
|
||||
public Task<bool> SendEmailAsync(string emailTo, string subject, string body)
|
||||
{
|
||||
Calls++;
|
||||
return Task.FromResult(_deliver);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<(Vendor Vendor, WorkOrder WorkOrder, Dispatch Dispatch)> SeedAsync(
|
||||
ApplicationDbContext context, string status = "Completed", decimal? nte = 1000m)
|
||||
{
|
||||
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
||||
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
|
||||
context.AddRange(vendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
Status = status,
|
||||
NTEAmount = nte,
|
||||
DispatchNumber = "DIS-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 VendorCompletionDocument EvidenceDocument(
|
||||
int vendorId, int dispatchId, int workOrderId, string scanStatus = "Passed", string purpose = "UpliftEvidence") => new()
|
||||
{
|
||||
VendorId = vendorId,
|
||||
DispatchId = dispatchId,
|
||||
WorkOrderId = workOrderId,
|
||||
OriginalFileName = "invoice.pdf",
|
||||
StoredFileName = "evidence.bin",
|
||||
ContentType = "application/pdf",
|
||||
SizeBytes = 4,
|
||||
ScanStatus = scanStatus,
|
||||
ReviewStatus = scanStatus == "Passed" ? "Approved" : "Processing",
|
||||
Purpose = purpose,
|
||||
Version = 1
|
||||
};
|
||||
|
||||
private static UpliftService NewUpliftService(
|
||||
ApplicationDbContext context, IVendorDocumentStoragePort storage) =>
|
||||
new(new UpliftDataService(context),
|
||||
new DispatchDataService(context),
|
||||
storage,
|
||||
TimeProvider.System,
|
||||
Microsoft.Extensions.Options.Options.Create(NewOptions()));
|
||||
|
||||
private static VendorPortalService NewPortalService(
|
||||
ApplicationDbContext context,
|
||||
IEmailSender emailSender,
|
||||
IVendorDocumentStoragePort? storage = null)
|
||||
{
|
||||
var vendorData = new VendorDataService(context);
|
||||
var tokenService = new VendorPortalTokenService(vendorData, Microsoft.Extensions.Options.Options.Create(new VendorPortalOptions()));
|
||||
storage ??= new FakeDocumentStorage();
|
||||
var commentData = new Mock<ICommentDataService>();
|
||||
commentData.Setup(c => c.GetVendorViewableForDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<PortalCommentData>());
|
||||
return new VendorPortalService(
|
||||
tokenService,
|
||||
new DispatchDataService(context),
|
||||
new UpliftDataService(context),
|
||||
commentData.Object,
|
||||
Mock.Of<IUserDataService>(),
|
||||
emailSender,
|
||||
new VendorDocumentDataService(context),
|
||||
storage,
|
||||
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
|
||||
Microsoft.Extensions.Options.Options.Create(NewOptions()),
|
||||
Microsoft.Extensions.Options.Options.Create(new VendorDocumentsOptions()),
|
||||
TimeProvider.System);
|
||||
}
|
||||
|
||||
// --- NoApprovalRequired: no mutation, no request row, audit-only ---
|
||||
|
||||
[Fact]
|
||||
public async Task RequestUplift_NoApprovalRequired_WhenRequestedNteAtOrBelowCurrent_DoesNotMutate()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
|
||||
|
||||
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
|
||||
var result = await service.RequestUpliftAsync(session!, dispatch.Id, 1000m, "ignored", null, null, CancellationToken.None);
|
||||
|
||||
result.NoApprovalRequired.Should().BeTrue();
|
||||
result.Id.Should().Be(0);
|
||||
result.Status.Should().Be(UpliftStatus.NoApprovalRequired);
|
||||
context.DispatchUpliftRequests.Should().BeEmpty();
|
||||
context.Dispatches.Single().NTEAmount.Should().Be(1000m);
|
||||
context.WorkOrderAuditLogs.Should().ContainSingle(a => a.Action == "uplift_no_approval_required");
|
||||
}
|
||||
|
||||
// --- Tier edge ---
|
||||
|
||||
[Fact]
|
||||
public async Task RequestUplift_Tier1_WhenDeltaEqualsTier1Max()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
|
||||
|
||||
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
|
||||
var result = await service.RequestUpliftAsync(session!, dispatch.Id, 3500m, "reason", null, 1, CancellationToken.None);
|
||||
|
||||
result.RequiredTier.Should().Be(1, "delta 2500 == tier1 max is still tier 1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestUplift_Tier2_WhenDeltaExceedsTier1Max()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
|
||||
|
||||
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
|
||||
var result = await service.RequestUpliftAsync(session!, dispatch.Id, 3500.01m, "reason", null, 1, CancellationToken.None);
|
||||
|
||||
result.RequiredTier.Should().Be(2);
|
||||
}
|
||||
|
||||
// --- Evidence scan + cross-vendor/dispatch scoping ---
|
||||
|
||||
[Fact]
|
||||
public async Task RequestUplift_EvidenceScanNotPassed_Throws()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id, scanStatus: "Pending"));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
|
||||
|
||||
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
|
||||
var act = () => service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "reason", null, 1, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
context.DispatchUpliftRequests.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestUplift_EvidenceFromAnotherVendor_Throws()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
var otherVendor = new Vendor { CompanyName = "Other", IsActive = true };
|
||||
var otherWorkOrder = new WorkOrder { WorkerOrderTitle = "Other" };
|
||||
context.AddRange(otherVendor, otherWorkOrder);
|
||||
await context.SaveChangesAsync();
|
||||
var otherDispatch = new Dispatch { VendorId = otherVendor.Id, WorkOrderId = otherWorkOrder.Id, Status = "Completed" };
|
||||
context.Dispatches.Add(otherDispatch);
|
||||
context.VendorCompletionDocuments.Add(EvidenceDocument(otherVendor.Id, otherDispatch.Id, otherWorkOrder.Id));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
|
||||
|
||||
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
|
||||
var act = () => service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "reason", null, 1, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
}
|
||||
|
||||
// --- RequestKey identical replay / mismatch (incl. EvidenceDocumentId) ---
|
||||
|
||||
[Fact]
|
||||
public async Task RequestUplift_RequestKey_IdenticalReplay_ReturnsSameRequest()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
|
||||
|
||||
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
|
||||
var first = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "need parts", "key-1", 1, CancellationToken.None);
|
||||
var replay = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "need parts", "key-1", 1, CancellationToken.None);
|
||||
|
||||
replay.Id.Should().Be(first.Id);
|
||||
replay.IdempotentReplay.Should().BeTrue();
|
||||
context.DispatchUpliftRequests.Should().ContainSingle();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestUplift_RequestKey_MismatchedEvidence_Throws()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
context.VendorCompletionDocuments.AddRange(
|
||||
EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id),
|
||||
new VendorCompletionDocument
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
DispatchId = dispatch.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
OriginalFileName = "second.pdf",
|
||||
StoredFileName = "second.bin",
|
||||
ContentType = "application/pdf",
|
||||
SizeBytes = 4,
|
||||
ScanStatus = "Passed",
|
||||
ReviewStatus = "Approved",
|
||||
Purpose = "UpliftEvidence",
|
||||
Version = 2
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var firstEvidenceId = 1;
|
||||
var secondEvidenceId = 2;
|
||||
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
|
||||
|
||||
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
|
||||
await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "need parts", "key-1", firstEvidenceId, CancellationToken.None);
|
||||
var act = () => service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "need parts", "key-1", secondEvidenceId, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestUplift_RequestKey_MismatchedAmount_Throws()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
|
||||
|
||||
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
|
||||
await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "need parts", "key-1", 1, CancellationToken.None);
|
||||
var act = () => service.RequestUpliftAsync(session!, dispatch.Id, 1600m, "need parts", "key-1", 1, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
}
|
||||
|
||||
// --- Notification error is separate from workflow state ---
|
||||
|
||||
[Fact]
|
||||
public async Task RequestUplift_NotificationDeliveryFailure_LeavesRequestPendingButRecordsError()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewPortalService(context, new FakeEmailSender(deliver: false));
|
||||
|
||||
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
|
||||
var result = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "need parts", null, 1, CancellationToken.None);
|
||||
|
||||
var req = context.DispatchUpliftRequests.Single();
|
||||
req.Status.Should().Be(UpliftStatus.Pending);
|
||||
req.NotificationStatus.Should().Be(UpliftNotificationStatus.Error);
|
||||
result.Status.Should().Be(UpliftStatus.Pending);
|
||||
result.Id.Should().NotBe(0);
|
||||
}
|
||||
|
||||
// --- SH-98 terminal dispatch guard (Refused) ---
|
||||
|
||||
[Fact]
|
||||
public async Task RequestUplift_RefusedDispatch_Throws()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, status: "Refused", nte: 1000m);
|
||||
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
|
||||
|
||||
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
|
||||
var act = () => service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "reason", null, null, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
}
|
||||
|
||||
// --- revise / withdraw / request-changes state transitions ---
|
||||
|
||||
[Fact]
|
||||
public async Task Revise_OnChangesRequested_ReturnsToPending_AndResetsDecision()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
|
||||
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
|
||||
var created = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "reason", null, 1, CancellationToken.None);
|
||||
|
||||
var req = context.DispatchUpliftRequests.Single();
|
||||
req.Status = UpliftStatus.ChangesRequested;
|
||||
req.DecisionNote = "lower please";
|
||||
req.DecidedAt = DateTime.UtcNow;
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var revised = await service.ReviseUpliftAsync(session!, dispatch.Id, created.Id, 1700m, "revised reason", 1, CancellationToken.None);
|
||||
|
||||
revised.Status.Should().Be(UpliftStatus.Pending);
|
||||
var after = context.DispatchUpliftRequests.Single();
|
||||
after.Status.Should().Be(UpliftStatus.Pending);
|
||||
after.DecisionNote.Should().BeNull();
|
||||
after.DecidedAt.Should().BeNull();
|
||||
after.RequestedNTE.Should().Be(1700m);
|
||||
context.WorkOrderAuditLogs.Should().Contain(a => a.Action == "uplift_revised");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Withdraw_Pending_TransitionsToWithdrawn_SetsDecidedAt()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
|
||||
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
|
||||
var created = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "reason", null, 1, CancellationToken.None);
|
||||
var beforeDecidedAt = context.DispatchUpliftRequests.Single().DecidedAt;
|
||||
|
||||
var result = await service.WithdrawUpliftAsync(session!, dispatch.Id, created.Id, CancellationToken.None);
|
||||
|
||||
result.Status.Should().Be(UpliftStatus.Withdrawn);
|
||||
var after = context.DispatchUpliftRequests.Single();
|
||||
after.Status.Should().Be(UpliftStatus.Withdrawn);
|
||||
after.DecidedAt.Should().NotBeNull();
|
||||
context.WorkOrderAuditLogs.Should().Contain(a => a.Action == "uplift_withdraw" && a.OldValue == UpliftStatus.Pending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Withdraw_AlreadyApproved_Throws()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
|
||||
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
|
||||
var created = await service.RequestUpliftAsync(session!, dispatch.Id, 1500m, "reason", null, 1, CancellationToken.None);
|
||||
context.DispatchUpliftRequests.Single().Status = UpliftStatus.Approved;
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var act = () => service.WithdrawUpliftAsync(session!, dispatch.Id, created.Id, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestChanges_Pending_TransitionsToChangesRequested_AuditsOldValue()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
CurrentNTE = 1000m,
|
||||
RequestedNTE = 1500m,
|
||||
VendorReason = "reason",
|
||||
Status = UpliftStatus.Pending,
|
||||
RequiredTier = 1,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, new FakeDocumentStorage());
|
||||
|
||||
var result = await service.RequestChangesAsync(UserWithRoles("Approver"), 1, "provide quote", CancellationToken.None);
|
||||
|
||||
result.Status.Should().Be(UpliftStatus.ChangesRequested);
|
||||
context.WorkOrderAuditLogs.Should().Contain(a =>
|
||||
a.Action == "uplift_changes_requested" && a.OldValue == UpliftStatus.Pending && a.NewValue == UpliftStatus.ChangesRequested);
|
||||
}
|
||||
|
||||
// --- Fix 1: reject/deny audit OldValue captures the pre-transition canonical status ---
|
||||
|
||||
[Fact]
|
||||
public async Task Reject_Pending_AuditOldValueIsPending_NotRejected()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
CurrentNTE = 1000m,
|
||||
RequestedNTE = 1500m,
|
||||
VendorReason = "reason",
|
||||
Status = UpliftStatus.Pending,
|
||||
RequiredTier = 1,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, new FakeDocumentStorage());
|
||||
|
||||
await service.RejectAsync(UserWithRoles("Approver"), 1, "too high", CancellationToken.None);
|
||||
|
||||
context.WorkOrderAuditLogs.Should().Contain(a =>
|
||||
a.Action == "uplift_reject"
|
||||
&& a.OldValue == UpliftStatus.Pending
|
||||
&& a.NewValue == UpliftStatus.Rejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deny_Pending_AuditOldValueIsPending_NotRejected()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
CurrentNTE = 1000m,
|
||||
RequestedNTE = 1500m,
|
||||
VendorReason = "reason",
|
||||
Status = UpliftStatus.Pending,
|
||||
RequiredTier = 1,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, new FakeDocumentStorage());
|
||||
|
||||
await service.DenyAsync(UserWithRoles("Approver"), 1, "too high", CancellationToken.None);
|
||||
|
||||
context.WorkOrderAuditLogs.Should().Contain(a =>
|
||||
a.Action == "uplift_deny"
|
||||
&& a.OldValue == UpliftStatus.Pending
|
||||
&& a.NewValue == UpliftStatus.Rejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Approve_Pending_UpdatesNte_AndTransitionsToApproved()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (_, _, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
CurrentNTE = 1000m,
|
||||
RequestedNTE = 1800m,
|
||||
VendorReason = "reason",
|
||||
Status = UpliftStatus.Pending,
|
||||
RequiredTier = 1,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, new FakeDocumentStorage());
|
||||
|
||||
var result = await service.ApproveAsync(UserWithRoles("Approver"), 1, "ok", CancellationToken.None);
|
||||
|
||||
result.Status.Should().Be(UpliftStatus.Approved);
|
||||
context.Dispatches.Single().NTEAmount.Should().Be(1800m);
|
||||
context.DispatchUpliftRequests.Single().DecidedAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Approve_RefusedDispatch_Throws()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (_, _, dispatch) = await SeedAsync(context, status: "Refused", nte: 1000m);
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
CurrentNTE = 1000m,
|
||||
RequestedNTE = 1800m,
|
||||
VendorReason = "reason",
|
||||
Status = UpliftStatus.Pending,
|
||||
RequiredTier = 1,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, new FakeDocumentStorage());
|
||||
|
||||
var act = () => service.ApproveAsync(UserWithRoles("Approver"), 1, "ok", CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
context.Dispatches.Single().NTEAmount.Should().Be(1000m);
|
||||
}
|
||||
|
||||
// --- Fix 6: expiry sets DecidedAt and preserves auditable transition ---
|
||||
|
||||
[Fact]
|
||||
public async Task ExpireDue_SetsStatusExpired_AndDecidedAt_AndAuditsTransition()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (_, _, dispatch) = await SeedAsync(context, status: "In Progress", nte: 1000m);
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
CurrentNTE = 1000m,
|
||||
RequestedNTE = 1500m,
|
||||
VendorReason = "reason",
|
||||
Status = UpliftStatus.Pending,
|
||||
RequiredTier = 1,
|
||||
ExpiresAt = DateTime.UtcNow.AddHours(-1),
|
||||
CreatedDate = DateTime.UtcNow.AddHours(-2)
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var lifecycle = new UpliftLifecycleService(
|
||||
new UpliftDataService(context),
|
||||
new DispatchDataService(context),
|
||||
Mock.Of<IUserDataService>(),
|
||||
new FakeEmailSender(deliver: true),
|
||||
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
|
||||
Microsoft.Extensions.Options.Options.Create(NewOptions()),
|
||||
TimeProvider.System,
|
||||
Mock.Of<ILogger<UpliftLifecycleService>>());
|
||||
|
||||
var expired = await lifecycle.ExpireDueAsync(CancellationToken.None);
|
||||
|
||||
expired.Should().Be(1);
|
||||
var req = context.DispatchUpliftRequests.Single();
|
||||
req.Status.Should().Be(UpliftStatus.Expired);
|
||||
req.DecidedAt.Should().NotBeNull();
|
||||
context.WorkOrderAuditLogs.Should().Contain(a =>
|
||||
a.Action == "uplift_expired" && a.OldValue == UpliftStatus.Pending && a.NewValue == UpliftStatus.Expired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExpireDue_DoesNotTransitionTerminalRequests()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (_, _, dispatch) = await SeedAsync(context, status: "In Progress", nte: 1000m);
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
CurrentNTE = 1000m,
|
||||
RequestedNTE = 1500m,
|
||||
VendorReason = "reason",
|
||||
Status = UpliftStatus.Approved,
|
||||
RequiredTier = 1,
|
||||
ExpiresAt = DateTime.UtcNow.AddHours(-1),
|
||||
CreatedDate = DateTime.UtcNow.AddHours(-2)
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var lifecycle = new UpliftLifecycleService(
|
||||
new UpliftDataService(context),
|
||||
new DispatchDataService(context),
|
||||
Mock.Of<IUserDataService>(),
|
||||
new FakeEmailSender(deliver: true),
|
||||
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
|
||||
Microsoft.Extensions.Options.Options.Create(NewOptions()),
|
||||
TimeProvider.System,
|
||||
Mock.Of<ILogger<UpliftLifecycleService>>());
|
||||
|
||||
var expired = await lifecycle.ExpireDueAsync(CancellationToken.None);
|
||||
|
||||
expired.Should().Be(0);
|
||||
context.DispatchUpliftRequests.Single().Status.Should().Be(UpliftStatus.Approved);
|
||||
}
|
||||
|
||||
// --- SH-101: orphan dispatch (deleted/unresolvable) must be skipped, not mutated/audited/saved ---
|
||||
|
||||
[Fact]
|
||||
public async Task ExpireDue_OrphanDispatchIsSkipped_ValidRequestStillExpiresAndAuditsOnce()
|
||||
{
|
||||
var orphanReq = new DispatchUpliftRequest
|
||||
{
|
||||
Id = 101,
|
||||
DispatchId = 404,
|
||||
Status = UpliftStatus.Pending,
|
||||
RequiredTier = 1
|
||||
};
|
||||
var validReq = new DispatchUpliftRequest
|
||||
{
|
||||
Id = 202,
|
||||
DispatchId = 7,
|
||||
Status = UpliftStatus.Pending,
|
||||
RequiredTier = 1
|
||||
};
|
||||
|
||||
var upliftData = new Mock<IUpliftDataService>();
|
||||
upliftData.Setup(u => u.GetDueForExpiryAsync(It.IsAny<DateTime>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<DispatchUpliftRequest> { orphanReq, validReq });
|
||||
upliftData.Setup(u => u.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var dispatch = new Dispatch { Id = 7, DispatchNumber = "DIS-7", WorkOrderId = 99 };
|
||||
var dispatchData = new Mock<IDispatchDataService>();
|
||||
dispatchData.Setup(d => d.GetByIdAsync(404)).ReturnsAsync((Dispatch?)null);
|
||||
dispatchData.Setup(d => d.GetByIdAsync(7)).ReturnsAsync(dispatch);
|
||||
dispatchData.Setup(d => d.StageAuditLogAsync(It.IsAny<WorkOrderAuditLog>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var lifecycle = new UpliftLifecycleService(
|
||||
upliftData.Object,
|
||||
dispatchData.Object,
|
||||
Mock.Of<IUserDataService>(),
|
||||
new FakeEmailSender(deliver: true),
|
||||
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
|
||||
Microsoft.Extensions.Options.Options.Create(NewOptions()),
|
||||
TimeProvider.System,
|
||||
Mock.Of<ILogger<UpliftLifecycleService>>());
|
||||
|
||||
var expired = await lifecycle.ExpireDueAsync(CancellationToken.None);
|
||||
|
||||
expired.Should().Be(1);
|
||||
orphanReq.Status.Should().Be(UpliftStatus.Pending, "orphan must not be mutated");
|
||||
orphanReq.DecidedAt.Should().BeNull("orphan must not be mutated");
|
||||
validReq.Status.Should().Be(UpliftStatus.Expired, "valid request must expire");
|
||||
validReq.DecidedAt.Should().NotBeNull("valid request must be decided");
|
||||
dispatchData.Verify(d => d.StageAuditLogAsync(It.IsAny<WorkOrderAuditLog>(), It.IsAny<CancellationToken>()), Times.Once, "only the valid request stages an audit");
|
||||
upliftData.Verify(u => u.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once, "only the valid request saves");
|
||||
}
|
||||
|
||||
// --- Escalation is once-per-request (dedup) ---
|
||||
|
||||
[Fact]
|
||||
public async Task EscalateDue_IsOncePerRequest_SecondRunIsNoop()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (_, _, dispatch) = await SeedAsync(context, status: "In Progress", nte: 1000m);
|
||||
var sentAt = DateTime.UtcNow.AddHours(-3);
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
CurrentNTE = 1000m,
|
||||
RequestedNTE = 1500m,
|
||||
VendorReason = "reason",
|
||||
Status = UpliftStatus.Pending,
|
||||
RequiredTier = 1,
|
||||
InitialNotificationSentAt = sentAt,
|
||||
CreatedDate = DateTime.UtcNow.AddHours(-4)
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var lifecycle = new UpliftLifecycleService(
|
||||
new UpliftDataService(context),
|
||||
new DispatchDataService(context),
|
||||
Mock.Of<IUserDataService>(),
|
||||
new FakeEmailSender(deliver: true),
|
||||
Microsoft.Extensions.Options.Options.Create(new FrontendOptions { FrontendBaseUrl = "https://shoc.test" }),
|
||||
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions
|
||||
{
|
||||
EscalationAfterHours = 1,
|
||||
EscalationRecipients = new[] { "escalate@example.com" }
|
||||
}),
|
||||
TimeProvider.System,
|
||||
Mock.Of<ILogger<UpliftLifecycleService>>());
|
||||
|
||||
var first = await lifecycle.EscalateDueAsync(CancellationToken.None);
|
||||
var second = await lifecycle.EscalateDueAsync(CancellationToken.None);
|
||||
|
||||
first.Should().Be(1);
|
||||
second.Should().Be(0);
|
||||
context.DispatchUpliftRequests.Single().EscalatedAt.Should().NotBeNull();
|
||||
context.WorkOrderAuditLogs.Should().ContainSingle(a => a.Action == "uplift_escalated");
|
||||
}
|
||||
|
||||
// --- Internal evidence download security ---
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvidenceForDownload_ReturnsOk_WhenPassedUpliftEvidence()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
var storage = new FakeDocumentStorage();
|
||||
await storage.SaveAsync(vendor.Id, dispatch.Id, "evidence.bin", new MemoryStream("%PDF"u8.ToArray()), CancellationToken.None);
|
||||
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
CurrentNTE = 1000m,
|
||||
RequestedNTE = 1500m,
|
||||
VendorReason = "reason",
|
||||
Status = UpliftStatus.Pending,
|
||||
RequiredTier = 1,
|
||||
EvidenceDocumentId = 1,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, storage);
|
||||
|
||||
var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None);
|
||||
|
||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Ok);
|
||||
result.ContentType.Should().Be("application/pdf");
|
||||
result.FileName.Should().Be("invoice.pdf");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvidenceForDownload_ThrowsForbidden_WhenUserLacksRequiredTier()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
CurrentNTE = 1000m,
|
||||
RequestedNTE = 4000m,
|
||||
VendorReason = "reason",
|
||||
Status = UpliftStatus.Pending,
|
||||
RequiredTier = 2,
|
||||
EvidenceDocumentId = 1,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, new FakeDocumentStorage());
|
||||
|
||||
var act = () => service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<UpliftForbiddenException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvidenceForDownload_ReturnsNotFound_WhenNoLinkedEvidence()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (_, _, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
CurrentNTE = 1000m,
|
||||
RequestedNTE = 1500m,
|
||||
VendorReason = "reason",
|
||||
Status = UpliftStatus.Pending,
|
||||
RequiredTier = 1,
|
||||
EvidenceDocumentId = null,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, new FakeDocumentStorage());
|
||||
|
||||
var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None);
|
||||
|
||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvidenceForDownload_ReturnsLocked_WhenScanNotPassed()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id, scanStatus: "Pending"));
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
CurrentNTE = 1000m,
|
||||
RequestedNTE = 1500m,
|
||||
VendorReason = "reason",
|
||||
Status = UpliftStatus.Pending,
|
||||
RequiredTier = 1,
|
||||
EvidenceDocumentId = 1,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, new FakeDocumentStorage());
|
||||
|
||||
var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None);
|
||||
|
||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.Locked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvidenceForDownload_ReturnsNotFound_WhenLinkedDocumentIsCompletionPurpose()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id, purpose: "Completion"));
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
CurrentNTE = 1000m,
|
||||
RequestedNTE = 1500m,
|
||||
VendorReason = "reason",
|
||||
Status = UpliftStatus.Pending,
|
||||
RequiredTier = 1,
|
||||
EvidenceDocumentId = 1,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, new FakeDocumentStorage());
|
||||
|
||||
var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None);
|
||||
|
||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvidenceForDownload_ReturnsNotFound_WhenDispatchLinkageBroken()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||
var otherWorkOrder = new WorkOrder { WorkerOrderTitle = "Other" };
|
||||
context.Add(otherWorkOrder);
|
||||
await context.SaveChangesAsync();
|
||||
var otherDispatch = new Dispatch { VendorId = vendor.Id, WorkOrderId = otherWorkOrder.Id, Status = "Completed" };
|
||||
context.Dispatches.Add(otherDispatch);
|
||||
// Evidence document belongs to a different dispatch than the uplift request.
|
||||
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, otherDispatch.Id, otherWorkOrder.Id));
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
CurrentNTE = 1000m,
|
||||
RequestedNTE = 1500m,
|
||||
VendorReason = "reason",
|
||||
Status = UpliftStatus.Pending,
|
||||
RequiredTier = 1,
|
||||
EvidenceDocumentId = 1,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewUpliftService(context, new FakeDocumentStorage());
|
||||
|
||||
var result = await service.GetEvidenceForDownloadAsync(UserWithRoles("Approver"), 1, CancellationToken.None);
|
||||
|
||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound);
|
||||
}
|
||||
}
|
||||
|
|
@ -300,7 +300,7 @@ public class VendorPortalControllerTests
|
|||
var service = new Mock<IVendorPortalService>();
|
||||
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Session);
|
||||
service.Setup(x => x.RequestUpliftAsync(Session, 10, 100m, null, It.IsAny<CancellationToken>()))
|
||||
service.Setup(x => x.RequestUpliftAsync(Session, 10, 100m, null, It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Requested NTE must be greater than the current NTE"));
|
||||
var controller = NewController(service);
|
||||
|
||||
|
|
@ -316,7 +316,7 @@ public class VendorPortalControllerTests
|
|||
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Session);
|
||||
var upliftResult = new UpliftRequestResultDTO { Id = 77, Status = "Pending", RequiredTier = 1 };
|
||||
service.Setup(x => x.RequestUpliftAsync(Session, 10, 500m, "Need more parts", It.IsAny<CancellationToken>()))
|
||||
service.Setup(x => x.RequestUpliftAsync(Session, 10, 500m, "Need more parts", It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(upliftResult);
|
||||
var controller = NewController(service);
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ using Moq;
|
|||
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;
|
||||
|
|
@ -68,7 +69,8 @@ public sealed class VendorPortalDocumentTests : IDisposable
|
|||
storage,
|
||||
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
|
||||
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()),
|
||||
Microsoft.Extensions.Options.Options.Create(new VendorDocumentsOptions()));
|
||||
Microsoft.Extensions.Options.Options.Create(new VendorDocumentsOptions()),
|
||||
TimeProvider.System);
|
||||
}
|
||||
|
||||
private static VendorPortalController NewController(VendorPortalService service)
|
||||
|
|
@ -177,6 +179,60 @@ public sealed class VendorPortalDocumentTests : IDisposable
|
|||
locked.StatusCode.Should().Be(StatusCodes.Status423Locked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDocumentStatus_ReturnsOwnedUpliftEvidenceMetadata()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, dispatch) = await SeedDispatch(context);
|
||||
var document = new VendorCompletionDocument
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
DispatchId = dispatch.Id,
|
||||
WorkOrderId = dispatch.WorkOrderId!.Value,
|
||||
OriginalFileName = "estimate.pdf",
|
||||
StoredFileName = "stored.pdf",
|
||||
ContentType = "application/pdf",
|
||||
Purpose = VendorDocumentPurpose.UpliftEvidence,
|
||||
ScanStatus = "Passed",
|
||||
ReviewStatus = "Accepted"
|
||||
};
|
||||
context.VendorCompletionDocuments.Add(document);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await NewService(context).GetDocumentStatusAsync(
|
||||
new VendorPortalSession { Id = vendor.Id }, dispatch.Id, document.Id, CancellationToken.None);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Id.Should().Be(document.Id);
|
||||
result.OriginalFileName.Should().Be("estimate.pdf");
|
||||
result.Purpose.Should().Be(VendorDocumentPurpose.UpliftEvidence);
|
||||
result.ScanStatus.Should().Be("Passed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDocumentStatus_DoesNotExposeAnotherVendorsDocument()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, dispatch) = await SeedDispatch(context);
|
||||
var document = new VendorCompletionDocument
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
DispatchId = dispatch.Id,
|
||||
WorkOrderId = dispatch.WorkOrderId!.Value,
|
||||
OriginalFileName = "estimate.pdf",
|
||||
StoredFileName = "stored.pdf",
|
||||
ContentType = "application/pdf",
|
||||
Purpose = VendorDocumentPurpose.UpliftEvidence
|
||||
};
|
||||
context.VendorCompletionDocuments.Add(document);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await NewService(context).GetDocumentStatusAsync(
|
||||
new VendorPortalSession { Id = vendor.Id + 1 }, dispatch.Id, document.Id, CancellationToken.None);
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadCompletionDocument_AcceptsMixedCasePdfContentType()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -85,7 +85,8 @@ public sealed class VendorPortalRefuseTests
|
|||
Mock.Of<IVendorDocumentStoragePort>(),
|
||||
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
|
||||
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()),
|
||||
Microsoft.Extensions.Options.Options.Create(new VendorDocumentsOptions()));
|
||||
Microsoft.Extensions.Options.Options.Create(new VendorDocumentsOptions()),
|
||||
TimeProvider.System);
|
||||
}
|
||||
|
||||
private static VendorPortalSession SessionFor(Vendor vendor) => new()
|
||||
|
|
@ -303,7 +304,7 @@ public sealed class VendorPortalRefuseTests
|
|||
};
|
||||
|
||||
var act = () => service.UploadCompletionDocumentAsync(
|
||||
SessionFor(vendor), dispatch.Id, file, null, CancellationToken.None);
|
||||
SessionFor(vendor), dispatch.Id, file, null, null, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>().WithMessage("*locked*");
|
||||
context.VendorCompletionDocuments.Should().BeEmpty();
|
||||
|
|
|
|||
|
|
@ -81,12 +81,81 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
}
|
||||
|
||||
// SH-101: canonical reject route (result is Rejected); deny alias stays compatible.
|
||||
[HttpPost("{id:int}/reject")]
|
||||
public async Task<IActionResult> Reject(int id, [FromBody] DecisionRequest? body, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _upliftService.RejectAsync(User, id, body?.Note, cancellationToken);
|
||||
return Ok(new DataResponse { Status = "Success", Data = result });
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Uplift request not found") });
|
||||
}
|
||||
catch (UpliftForbiddenException ex)
|
||||
{
|
||||
return StatusCode(403, new Response { Status = "Error", Message = _logger.Sanitize(ex, "You are not authorized to perform this action on this uplift request") });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "This uplift request cannot be modified in its current state") });
|
||||
}
|
||||
}
|
||||
|
||||
// SH-101: internal request-changes route (note + tier authorization + audit).
|
||||
[HttpPost("{id:int}/request-changes")]
|
||||
public async Task<IActionResult> RequestChanges(int id, [FromBody] DecisionRequest? body, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _upliftService.RequestChangesAsync(User, id, body?.Note ?? string.Empty, cancellationToken);
|
||||
return Ok(new DataResponse { Status = "Success", Data = result });
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Uplift request not found") });
|
||||
}
|
||||
catch (UpliftForbiddenException ex)
|
||||
{
|
||||
return StatusCode(403, new Response { Status = "Error", Message = _logger.Sanitize(ex, "You are not authorized to perform this action on this uplift request") });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "This uplift request cannot be modified in its current state") });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("can-approve")]
|
||||
public IActionResult CanApprove([FromQuery] int tier)
|
||||
{
|
||||
return Ok(new DataResponse { Status = "Success", Data = new { canApprove = _upliftService.CanApprove(User, tier) } });
|
||||
}
|
||||
|
||||
// SH-101: authorized internal download of the Passed UpliftEvidence file linked to
|
||||
// a specific uplift request. Server-side linkage only; no vendor/public path or
|
||||
// document id is accepted from the client. 404 covers missing request/evidence and
|
||||
// any non-UpliftEvidence document; 423 covers a scan that has not Passed.
|
||||
[HttpGet("{id:int}/evidence")]
|
||||
public async Task<IActionResult> DownloadEvidence(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _upliftService.GetEvidenceForDownloadAsync(User, id, cancellationToken);
|
||||
return result.Outcome switch
|
||||
{
|
||||
VendorDocumentDownloadOutcome.Ok => File(result.Content!, result.ContentType!, result.FileName!),
|
||||
VendorDocumentDownloadOutcome.Locked => StatusCode(StatusCodes.Status423Locked, new Response { Status = "Locked", Message = "The uplift evidence is not available for download yet." }),
|
||||
_ => NotFound(new Response { Status = "Error", Message = "Uplift evidence not found" })
|
||||
};
|
||||
}
|
||||
catch (UpliftForbiddenException ex)
|
||||
{
|
||||
return StatusCode(403, new Response { Status = "Error", Message = _logger.Sanitize(ex, "You are not authorized to view evidence for this uplift request") });
|
||||
}
|
||||
}
|
||||
|
||||
public class DecisionRequest
|
||||
{
|
||||
public string? Note { get; set; }
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
|
||||
try
|
||||
{
|
||||
var result = await _portalService.RequestUpliftAsync(session, id, body?.RequestedNTE ?? 0m, body?.Reason, cancellationToken);
|
||||
var result = await _portalService.RequestUpliftAsync(session, id, body?.RequestedNTE ?? 0m, body?.Reason, body?.RequestKey, body?.EvidenceDocumentId, cancellationToken);
|
||||
return Ok(new DataResponse { Status = "Success", Data = result });
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
|
|
@ -248,6 +248,50 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
}
|
||||
|
||||
// SH-101: vendor withdraw (canonical) endpoint.
|
||||
[HttpPost("dispatches/{id:int}/uplift-request/{requestId:int}/withdraw")]
|
||||
public async Task<IActionResult> WithdrawUpliftRequest(int id, int requestId, 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.WithdrawUpliftAsync(session, id, requestId, 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") });
|
||||
}
|
||||
}
|
||||
|
||||
// SH-101: vendor revise endpoint on ChangesRequested.
|
||||
[HttpPost("dispatches/{id:int}/uplift-request/{requestId:int}/revise")]
|
||||
public async Task<IActionResult> ReviseUpliftRequest(int id, int requestId, [FromBody] UpliftRequestBody? 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.ReviseUpliftAsync(session, id, requestId, body?.RequestedNTE ?? 0m, body?.Reason, body?.EvidenceDocumentId, 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}/completion-documents")]
|
||||
[HttpPost("dispatches/{id:int}/documents")]
|
||||
[RequestSizeLimit(10_000_000)]
|
||||
|
|
@ -255,6 +299,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
int id,
|
||||
[FromForm] IFormFile file,
|
||||
[FromForm] int? replacesDocumentId = null,
|
||||
[FromForm] string? purpose = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var session = await ResolveSessionAsync(cancellationToken);
|
||||
|
|
@ -267,6 +312,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
id,
|
||||
file,
|
||||
replacesDocumentId,
|
||||
purpose,
|
||||
cancellationToken);
|
||||
return Ok(new DataResponse { Status = "Success", Data = result });
|
||||
}
|
||||
|
|
@ -296,6 +342,18 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
};
|
||||
}
|
||||
|
||||
[HttpGet("dispatches/{id:int}/documents/{documentId:int}/status")]
|
||||
public async Task<IActionResult> GetDocumentStatus(int id, int documentId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var session = await ResolveSessionAsync(cancellationToken);
|
||||
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
|
||||
var result = await _portalService.GetDocumentStatusAsync(session, id, documentId, cancellationToken);
|
||||
return result == null
|
||||
? NotFound(new Response { Status = "Error", Message = "Document not found" })
|
||||
: Ok(new DataResponse { Status = "Success", Data = result });
|
||||
}
|
||||
|
||||
private async Task<VendorPortalSession?> ResolveSessionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Request.Headers.TryGetValue(TokenHeader, out var values)) return null;
|
||||
|
|
@ -340,6 +398,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
{
|
||||
public decimal RequestedNTE { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
// SH-101: client idempotency key and supporting evidence.
|
||||
public string? RequestKey { get; set; }
|
||||
public int? EvidenceDocumentId { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
using Microsoft.Extensions.Options;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.HostedServices
|
||||
{
|
||||
public class UpliftLifecycleHostedService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<UpliftLifecycleHostedService> _logger;
|
||||
private readonly ApprovalsOptions _options;
|
||||
|
||||
public UpliftLifecycleHostedService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<ApprovalsOptions> options,
|
||||
ILogger<UpliftLifecycleHostedService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SweepAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Uplift lifecycle sweep failed; will retry on next interval");
|
||||
}
|
||||
|
||||
await Task.Delay(_options.EffectiveSweepInterval, stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SweepAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var lifecycle = scope.ServiceProvider.GetRequiredService<IUpliftLifecycleService>();
|
||||
|
||||
await lifecycle.ExpireDueAsync(cancellationToken);
|
||||
await lifecycle.SendDueInitialNotificationsAsync(cancellationToken);
|
||||
await lifecycle.EscalateDueAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -111,6 +111,7 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService<WorkOrderJobRunStateAc
|
|||
builder.Services.AddScoped<Api.SeaHavenIndustries.Filters.IngestApiKeyFilter>();
|
||||
builder.Services.AddHostedService<WorkOrderWeekRolledHostedService>();
|
||||
builder.Services.AddHostedService<PastDueCacheHostedService>();
|
||||
builder.Services.AddHostedService<UpliftLifecycleHostedService>();
|
||||
builder.Services.AddOptions<SeaHaven.Services.Implementation.WorkOrderOpsHealthOptions>()
|
||||
.Configure<Microsoft.Extensions.Options.IOptions<SyncOptions>,
|
||||
Microsoft.Extensions.Options.IOptions<WorkOrderIngestOptions>,
|
||||
|
|
|
|||
|
|
@ -30,9 +30,18 @@ namespace Data.SeaHavenIndustries
|
|||
.IsUnique();
|
||||
|
||||
builder.Entity<DispatchUpliftRequest>()
|
||||
.HasIndex(u => new { u.DispatchId, u.Status })
|
||||
.HasIndex(u => u.DispatchId)
|
||||
.IsUnique()
|
||||
.HasFilter("[Status] = 'Pending'");
|
||||
.HasFilter("[Status] IN ('Pending', 'ChangesRequested')");
|
||||
|
||||
builder.Entity<DispatchUpliftRequest>()
|
||||
.HasIndex(u => new { u.DispatchId, u.RequestKey })
|
||||
.IsUnique()
|
||||
.HasFilter("[RequestKey] IS NOT NULL");
|
||||
|
||||
builder.Entity<DispatchUpliftRequest>()
|
||||
.Property(u => u.RowVersion)
|
||||
.IsRowVersion();
|
||||
|
||||
builder.Entity<WorkOrderFieldLock>()
|
||||
.HasIndex(l => new { l.WorkOrderId, l.FieldName })
|
||||
|
|
@ -252,6 +261,12 @@ namespace Data.SeaHavenIndustries
|
|||
if (entry.State == EntityState.Modified)
|
||||
entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion);
|
||||
}
|
||||
|
||||
foreach (var entry in ChangeTracker.Entries<DispatchUpliftRequest>())
|
||||
{
|
||||
if (entry.State == EntityState.Modified)
|
||||
entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] IncrementRowVersion(byte[]? current)
|
||||
|
|
|
|||
3835
Data.SeaHavenIndustries/Migrations/20260810183356_SH101_UpliftApprovalWorkflow.Designer.cs
generated
Normal file
3835
Data.SeaHavenIndustries/Migrations/20260810183356_SH101_UpliftApprovalWorkflow.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,170 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SH101_UpliftApprovalWorkflow : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_DispatchUpliftRequests_DispatchId_Status",
|
||||
table: "DispatchUpliftRequests");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Purpose",
|
||||
table: "VendorCompletionDocuments",
|
||||
type: "nvarchar(30)",
|
||||
maxLength: 30,
|
||||
nullable: false,
|
||||
defaultValue: "Completion");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "EscalatedAt",
|
||||
table: "DispatchUpliftRequests",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "EvidenceDocumentId",
|
||||
table: "DispatchUpliftRequests",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ExpiresAt",
|
||||
table: "DispatchUpliftRequests",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "InitialNotificationSentAt",
|
||||
table: "DispatchUpliftRequests",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "NotificationError",
|
||||
table: "DispatchUpliftRequests",
|
||||
type: "nvarchar(500)",
|
||||
maxLength: 500,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "NotificationStatus",
|
||||
table: "DispatchUpliftRequests",
|
||||
type: "nvarchar(20)",
|
||||
maxLength: 20,
|
||||
nullable: false,
|
||||
defaultValue: "Pending");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "RequestKey",
|
||||
table: "DispatchUpliftRequests",
|
||||
type: "nvarchar(100)",
|
||||
maxLength: 100,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<byte[]>(
|
||||
name: "RowVersion",
|
||||
table: "DispatchUpliftRequests",
|
||||
type: "rowversion",
|
||||
rowVersion: true,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DispatchUpliftRequests_DispatchId",
|
||||
table: "DispatchUpliftRequests",
|
||||
column: "DispatchId",
|
||||
unique: true,
|
||||
filter: "[Status] IN ('Pending', 'ChangesRequested')");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DispatchUpliftRequests_DispatchId_RequestKey",
|
||||
table: "DispatchUpliftRequests",
|
||||
columns: new[] { "DispatchId", "RequestKey" },
|
||||
unique: true,
|
||||
filter: "[RequestKey] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DispatchUpliftRequests_EvidenceDocumentId",
|
||||
table: "DispatchUpliftRequests",
|
||||
column: "EvidenceDocumentId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_DispatchUpliftRequests_VendorCompletionDocuments_EvidenceDocumentId",
|
||||
table: "DispatchUpliftRequests",
|
||||
column: "EvidenceDocumentId",
|
||||
principalTable: "VendorCompletionDocuments",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_DispatchUpliftRequests_VendorCompletionDocuments_EvidenceDocumentId",
|
||||
table: "DispatchUpliftRequests");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_DispatchUpliftRequests_DispatchId",
|
||||
table: "DispatchUpliftRequests");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_DispatchUpliftRequests_DispatchId_RequestKey",
|
||||
table: "DispatchUpliftRequests");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_DispatchUpliftRequests_EvidenceDocumentId",
|
||||
table: "DispatchUpliftRequests");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Purpose",
|
||||
table: "VendorCompletionDocuments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EscalatedAt",
|
||||
table: "DispatchUpliftRequests");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EvidenceDocumentId",
|
||||
table: "DispatchUpliftRequests");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ExpiresAt",
|
||||
table: "DispatchUpliftRequests");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "InitialNotificationSentAt",
|
||||
table: "DispatchUpliftRequests");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NotificationError",
|
||||
table: "DispatchUpliftRequests");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NotificationStatus",
|
||||
table: "DispatchUpliftRequests");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RequestKey",
|
||||
table: "DispatchUpliftRequests");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RowVersion",
|
||||
table: "DispatchUpliftRequests");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DispatchUpliftRequests_DispatchId_Status",
|
||||
table: "DispatchUpliftRequests",
|
||||
columns: new[] { "DispatchId", "Status" },
|
||||
unique: true,
|
||||
filter: "[Status] = 'Pending'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -952,6 +952,18 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<int>("DispatchId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("EscalatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int?>("EvidenceDocumentId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime?>("InitialNotificationSentAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
|
|
@ -961,6 +973,19 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<int?>("LastModifierUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("NotificationError")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<string>("NotificationStatus")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<string>("RequestKey")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("RequestedByVendorName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
|
@ -970,6 +995,11 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<int>("RequiredTier")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<byte[]>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("rowversion");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
|
|
@ -983,9 +1013,15 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DispatchId", "Status")
|
||||
b.HasIndex("DispatchId")
|
||||
.IsUnique()
|
||||
.HasFilter("[Status] = 'Pending'");
|
||||
.HasFilter("[Status] IN ('Pending', 'ChangesRequested')");
|
||||
|
||||
b.HasIndex("EvidenceDocumentId");
|
||||
|
||||
b.HasIndex("DispatchId", "RequestKey")
|
||||
.IsUnique()
|
||||
.HasFilter("[RequestKey] IS NOT NULL");
|
||||
|
||||
b.ToTable("DispatchUpliftRequests");
|
||||
});
|
||||
|
|
@ -2256,6 +2292,11 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Purpose")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("nvarchar(30)");
|
||||
|
||||
b.Property<string>("RejectionReason")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
|
@ -3230,7 +3271,14 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Data.SeaHavenIndustries.VendorCompletionDocument", "EvidenceDocument")
|
||||
.WithMany()
|
||||
.HasForeignKey("EvidenceDocumentId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("Dispatch");
|
||||
|
||||
b.Navigation("EvidenceDocument");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b =>
|
||||
|
|
|
|||
|
|
@ -28,5 +28,27 @@ namespace Data.SeaHavenIndustries
|
|||
public DateTime? DecidedAt { get; set; }
|
||||
public string? DecidedByUserId { get; set; }
|
||||
public string? DecisionNote { get; set; }
|
||||
|
||||
// SH-101: supporting evidence, idempotency, lifecycle, concurrency.
|
||||
public int? EvidenceDocumentId { get; set; }
|
||||
[ForeignKey(nameof(EvidenceDocumentId))]
|
||||
public virtual VendorCompletionDocument? EvidenceDocument { get; set; }
|
||||
|
||||
[MaxLength(100)]
|
||||
public string? RequestKey { get; set; }
|
||||
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
|
||||
public DateTime? InitialNotificationSentAt { get; set; }
|
||||
public DateTime? EscalatedAt { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(20)]
|
||||
public string NotificationStatus { get; set; } = "Pending";
|
||||
[MaxLength(500)]
|
||||
public string? NotificationError { get; set; }
|
||||
|
||||
[Timestamp]
|
||||
public byte[]? RowVersion { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
|
|
@ -27,5 +28,11 @@ namespace Data.SeaHavenIndustries
|
|||
public int? ReplacesDocumentId { get; set; }
|
||||
public DateTime? ScannedAt { get; set; }
|
||||
public DateTime? ReviewedAt { get; set; }
|
||||
|
||||
// SH-101: document purpose. Default "Completion" preserves legacy behavior;
|
||||
// "UpliftEvidence" is immutable supporting evidence for an uplift request.
|
||||
[Required]
|
||||
[MaxLength(30)]
|
||||
public string Purpose { get; set; } = "Completion";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,15 @@ namespace Data.SeaHavenIndustries
|
|||
public string? DecisionNote { get; set; }
|
||||
public string? DecidedByFirstName { get; set; }
|
||||
public string? DecidedByLastName { get; set; }
|
||||
// SH-101 additions
|
||||
public int? EvidenceDocumentId { get; set; }
|
||||
public string? EvidenceFileName { get; set; }
|
||||
public string? EvidenceContentType { get; set; }
|
||||
public long? EvidenceSizeBytes { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public string? NotificationStatus { get; set; }
|
||||
public string? NotificationError { get; set; }
|
||||
public bool EvidenceScanPassed { get; set; }
|
||||
}
|
||||
|
||||
public class PortalCommentData
|
||||
|
|
@ -80,6 +89,14 @@ namespace Data.SeaHavenIndustries
|
|||
public DateTime? CreatedDate { get; set; }
|
||||
public DateTime? DecidedAt { get; set; }
|
||||
public string? DecisionNote { get; set; }
|
||||
// SH-101 additions
|
||||
public int? EvidenceDocumentId { get; set; }
|
||||
public string? EvidenceFileName { get; set; }
|
||||
public string? EvidenceContentType { get; set; }
|
||||
public long? EvidenceSizeBytes { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public string? NotificationStatus { get; set; }
|
||||
public string? NotificationError { get; set; }
|
||||
}
|
||||
|
||||
public class UpliftForDispatchData
|
||||
|
|
@ -97,5 +114,30 @@ namespace Data.SeaHavenIndustries
|
|||
public string? DecisionNote { get; set; }
|
||||
public string? DecidedByFirstName { get; set; }
|
||||
public string? DecidedByLastName { get; set; }
|
||||
// SH-101 additions
|
||||
public int? EvidenceDocumentId { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public string? NotificationStatus { get; set; }
|
||||
public string? NotificationError { get; set; }
|
||||
public string? EvidenceFileName { get; set; }
|
||||
public string? EvidenceContentType { get; set; }
|
||||
public long? EvidenceSizeBytes { get; set; }
|
||||
public bool EvidenceScanPassed { get; set; }
|
||||
}
|
||||
|
||||
// SH-101: internal evidence-download projection. Server-side join enforces that the
|
||||
// returned document is the one linked to this specific uplift request and dispatch.
|
||||
public class UpliftEvidenceDownloadData
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int DispatchId { get; set; }
|
||||
public int VendorId { get; set; }
|
||||
public int RequiredTier { get; set; }
|
||||
public int? EvidenceDocumentId { get; set; }
|
||||
public string? StoredFileName { get; set; }
|
||||
public string? OriginalFileName { get; set; }
|
||||
public string? ContentType { get; set; }
|
||||
public string? Purpose { get; set; }
|
||||
public string? ScanStatus { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,10 @@ namespace SeaHaven.DataServices.Implementation
|
|||
join d in _context.Dispatches on u.DispatchId equals d.Id
|
||||
join v in _context.Vendors on d.VendorId equals v.Id into vendors
|
||||
from v in vendors.DefaultIfEmpty()
|
||||
join ev in _context.VendorCompletionDocuments on u.EvidenceDocumentId equals ev.Id into evidences
|
||||
from ev in evidences.DefaultIfEmpty()
|
||||
where (u.IsDeleted == null || u.IsDeleted == false)
|
||||
select new { u, d, v };
|
||||
select new { u, d, v, ev };
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
query = query.Where(x => x.u.Status == status);
|
||||
|
|
@ -49,7 +51,14 @@ namespace SeaHaven.DataServices.Implementation
|
|||
Status = x.u.Status,
|
||||
CreatedDate = x.u.CreatedDate,
|
||||
DecidedAt = x.u.DecidedAt,
|
||||
DecisionNote = x.u.DecisionNote
|
||||
DecisionNote = x.u.DecisionNote,
|
||||
EvidenceDocumentId = x.u.EvidenceDocumentId,
|
||||
EvidenceFileName = x.ev != null ? x.ev.OriginalFileName : null,
|
||||
EvidenceContentType = x.ev != null ? x.ev.ContentType : null,
|
||||
EvidenceSizeBytes = x.ev != null ? x.ev.SizeBytes : null,
|
||||
ExpiresAt = x.u.ExpiresAt,
|
||||
NotificationStatus = x.u.NotificationStatus,
|
||||
NotificationError = x.u.NotificationError
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
|
|
@ -62,6 +71,8 @@ namespace SeaHaven.DataServices.Implementation
|
|||
where u.DispatchId == dispatchId && (u.IsDeleted == null || u.IsDeleted == false)
|
||||
join dec in _context.Users on u.DecidedByUserId equals dec.Id into decs
|
||||
from dec in decs.DefaultIfEmpty()
|
||||
join ev in _context.VendorCompletionDocuments on u.EvidenceDocumentId equals ev.Id into evidences
|
||||
from ev in evidences.DefaultIfEmpty()
|
||||
orderby u.CreatedDate descending
|
||||
select new UpliftForDispatchData
|
||||
{
|
||||
|
|
@ -77,7 +88,15 @@ namespace SeaHaven.DataServices.Implementation
|
|||
DecidedAt = u.DecidedAt,
|
||||
DecisionNote = u.DecisionNote,
|
||||
DecidedByFirstName = dec != null ? dec.FirstName : null,
|
||||
DecidedByLastName = dec != null ? dec.LastName : null
|
||||
DecidedByLastName = dec != null ? dec.LastName : null,
|
||||
EvidenceDocumentId = u.EvidenceDocumentId,
|
||||
ExpiresAt = u.ExpiresAt,
|
||||
NotificationStatus = u.NotificationStatus,
|
||||
NotificationError = u.NotificationError,
|
||||
EvidenceFileName = ev != null ? ev.OriginalFileName : null,
|
||||
EvidenceContentType = ev != null ? ev.ContentType : null,
|
||||
EvidenceSizeBytes = ev != null ? ev.SizeBytes : null,
|
||||
EvidenceScanPassed = ev != null && ev.ScanStatus == "Passed"
|
||||
}).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
|
|
@ -87,6 +106,8 @@ namespace SeaHaven.DataServices.Implementation
|
|||
where u.DispatchId == dispatchId && (u.IsDeleted == null || u.IsDeleted == false)
|
||||
join dec in _context.Users on u.DecidedByUserId equals dec.Id into decs
|
||||
from dec in decs.DefaultIfEmpty()
|
||||
join ev in _context.VendorCompletionDocuments on u.EvidenceDocumentId equals ev.Id into evidences
|
||||
from ev in evidences.DefaultIfEmpty()
|
||||
orderby u.CreatedDate descending
|
||||
select new PortalUpliftData
|
||||
{
|
||||
|
|
@ -101,7 +122,15 @@ namespace SeaHaven.DataServices.Implementation
|
|||
DecidedAt = u.DecidedAt,
|
||||
DecisionNote = u.DecisionNote,
|
||||
DecidedByFirstName = dec != null ? dec.FirstName : null,
|
||||
DecidedByLastName = dec != null ? dec.LastName : null
|
||||
DecidedByLastName = dec != null ? dec.LastName : null,
|
||||
EvidenceDocumentId = u.EvidenceDocumentId,
|
||||
ExpiresAt = u.ExpiresAt,
|
||||
NotificationStatus = u.NotificationStatus,
|
||||
NotificationError = u.NotificationError,
|
||||
EvidenceFileName = ev != null ? ev.OriginalFileName : null,
|
||||
EvidenceContentType = ev != null ? ev.ContentType : null,
|
||||
EvidenceSizeBytes = ev != null ? ev.SizeBytes : null,
|
||||
EvidenceScanPassed = ev != null && ev.ScanStatus == "Passed"
|
||||
}).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
|
|
@ -117,6 +146,31 @@ namespace SeaHaven.DataServices.Implementation
|
|||
.FirstOrDefaultAsync(u => u.Id == requestId && u.DispatchId == dispatchId, cancellationToken);
|
||||
}
|
||||
|
||||
// SH-101: internal evidence download. The inner join on EvidenceDocumentId together
|
||||
// with the DispatchId equality filter enforces server-side request/document linkage:
|
||||
// a row is returned only when the document is the one linked to this exact request
|
||||
// and dispatch. Soft-deleted requests/documents never resolve.
|
||||
public async Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await (from u in _context.DispatchUpliftRequests
|
||||
where u.Id == upliftRequestId && (u.IsDeleted == null || u.IsDeleted == false)
|
||||
join ev in _context.VendorCompletionDocuments on u.EvidenceDocumentId equals ev.Id
|
||||
where ev.DispatchId == u.DispatchId && (ev.IsDeleted == null || ev.IsDeleted == false)
|
||||
select new UpliftEvidenceDownloadData
|
||||
{
|
||||
Id = u.Id,
|
||||
DispatchId = u.DispatchId,
|
||||
VendorId = ev.VendorId,
|
||||
RequiredTier = u.RequiredTier,
|
||||
EvidenceDocumentId = u.EvidenceDocumentId,
|
||||
StoredFileName = ev.StoredFileName,
|
||||
OriginalFileName = ev.OriginalFileName,
|
||||
ContentType = ev.ContentType,
|
||||
Purpose = ev.Purpose,
|
||||
ScanStatus = ev.ScanStatus
|
||||
}).FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.DispatchUpliftRequests
|
||||
|
|
@ -125,6 +179,64 @@ namespace SeaHaven.DataServices.Implementation
|
|||
&& (u.IsDeleted == null || u.IsDeleted == false), cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.DispatchUpliftRequests
|
||||
.AnyAsync(u => u.DispatchId == dispatchId
|
||||
&& (u.Status == "Pending" || u.Status == "ChangesRequested")
|
||||
&& (u.IsDeleted == null || u.IsDeleted == false), cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<DispatchUpliftRequest?> GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.DispatchUpliftRequests
|
||||
.FirstOrDefaultAsync(u => u.DispatchId == dispatchId
|
||||
&& (u.Status == "Pending" || u.Status == "ChangesRequested")
|
||||
&& (u.IsDeleted == null || u.IsDeleted == false), cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<DispatchUpliftRequest?> GetByRequestKeyAsync(int dispatchId, string requestKey, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.DispatchUpliftRequests
|
||||
.FirstOrDefaultAsync(u => u.DispatchId == dispatchId
|
||||
&& u.RequestKey == requestKey
|
||||
&& (u.IsDeleted == null || u.IsDeleted == false), cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<DispatchUpliftRequest>> GetDueForExpiryAsync(DateTime utcNow, int count, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.DispatchUpliftRequests
|
||||
.Where(u => (u.Status == "Pending" || u.Status == "ChangesRequested")
|
||||
&& u.ExpiresAt != null && u.ExpiresAt <= utcNow
|
||||
&& (u.IsDeleted == null || u.IsDeleted == false))
|
||||
.OrderBy(u => u.ExpiresAt)
|
||||
.Take(count)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<DispatchUpliftRequest>> GetDueForInitialNotificationAsync(int count, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.DispatchUpliftRequests
|
||||
.Where(u => (u.Status == "Pending" || u.Status == "ChangesRequested")
|
||||
&& u.InitialNotificationSentAt == null
|
||||
&& (u.IsDeleted == null || u.IsDeleted == false))
|
||||
.OrderBy(u => u.CreatedDate)
|
||||
.Take(count)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<DispatchUpliftRequest>> GetDueForEscalationAsync(int count, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.DispatchUpliftRequests
|
||||
.Where(u => (u.Status == "Pending" || u.Status == "ChangesRequested")
|
||||
&& u.InitialNotificationSentAt != null
|
||||
&& u.EscalatedAt == null
|
||||
&& (u.IsDeleted == null || u.IsDeleted == false))
|
||||
.OrderBy(u => u.InitialNotificationSentAt)
|
||||
.Take(count)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
await _context.DispatchUpliftRequests.AddAsync(request, cancellationToken);
|
||||
|
|
|
|||
|
|
@ -13,10 +13,15 @@ namespace SeaHaven.DataServices.Implementation
|
|||
_context = context;
|
||||
}
|
||||
|
||||
// Completion-document queries/version semantics exclude uplift evidence so that
|
||||
// supporting evidence never participates in completion versioning or replacement.
|
||||
private const string CompletionPurpose = "Completion";
|
||||
|
||||
public Task<VendorCompletionDocument?> GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken)
|
||||
{
|
||||
return _context.VendorCompletionDocuments
|
||||
.Where(document => document.DispatchId == dispatchId)
|
||||
.Where(document => document.DispatchId == dispatchId
|
||||
&& document.Purpose == CompletionPurpose)
|
||||
.OrderByDescending(document => document.Version)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
|
@ -24,6 +29,17 @@ namespace SeaHaven.DataServices.Implementation
|
|||
public Task<VendorCompletionDocument?> GetForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken)
|
||||
{
|
||||
return _context.VendorCompletionDocuments
|
||||
.FirstOrDefaultAsync(document => document.Id == documentId
|
||||
&& document.DispatchId == dispatchId
|
||||
&& document.VendorId == vendorId
|
||||
&& document.Purpose == CompletionPurpose
|
||||
&& (document.IsDeleted == null || document.IsDeleted == false), cancellationToken);
|
||||
}
|
||||
|
||||
public Task<VendorCompletionDocument?> GetMetadataForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken)
|
||||
{
|
||||
return _context.VendorCompletionDocuments
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(document => document.Id == documentId
|
||||
&& document.DispatchId == dispatchId
|
||||
&& document.VendorId == vendorId
|
||||
|
|
@ -35,11 +51,25 @@ namespace SeaHaven.DataServices.Implementation
|
|||
return _context.VendorCompletionDocuments
|
||||
.Where(document => document.DispatchId == dispatchId
|
||||
&& document.VendorId == vendorId
|
||||
&& document.Purpose == CompletionPurpose
|
||||
&& (document.IsDeleted == null || document.IsDeleted == false))
|
||||
.OrderByDescending(document => document.Version)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// SH-101: an uplift request > current requires a same-vendor, same-dispatch,
|
||||
// nondeleted UpliftEvidence document whose scan passed. This lookup enforces all of those.
|
||||
public Task<VendorCompletionDocument?> GetUpliftEvidenceAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken)
|
||||
{
|
||||
return _context.VendorCompletionDocuments
|
||||
.FirstOrDefaultAsync(document => document.Id == documentId
|
||||
&& document.DispatchId == dispatchId
|
||||
&& document.VendorId == vendorId
|
||||
&& document.Purpose == "UpliftEvidence"
|
||||
&& document.ScanStatus == "Passed"
|
||||
&& (document.IsDeleted == null || document.IsDeleted == false), cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
await _context.VendorCompletionDocuments.AddAsync(document, cancellationToken);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,17 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task<IReadOnlyList<PortalUpliftData>> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken);
|
||||
Task<DispatchUpliftRequest?> GetByIdAsync(int id, CancellationToken cancellationToken);
|
||||
Task<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken);
|
||||
// SH-101: server-side join of an uplift request with its linked evidence document.
|
||||
// Returns null when the request, the linked evidence, or the dispatch linkage is absent.
|
||||
Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken);
|
||||
Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken);
|
||||
// SH-101: active = Pending or ChangesRequested (the only states that block a new request).
|
||||
Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken);
|
||||
Task<DispatchUpliftRequest?> GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken);
|
||||
Task<DispatchUpliftRequest?> GetByRequestKeyAsync(int dispatchId, string requestKey, CancellationToken cancellationToken);
|
||||
Task<List<DispatchUpliftRequest>> GetDueForExpiryAsync(DateTime utcNow, int count, CancellationToken cancellationToken);
|
||||
Task<List<DispatchUpliftRequest>> GetDueForInitialNotificationAsync(int count, CancellationToken cancellationToken);
|
||||
Task<List<DispatchUpliftRequest>> GetDueForEscalationAsync(int count, CancellationToken cancellationToken);
|
||||
Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
{
|
||||
Task<VendorCompletionDocument?> GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken);
|
||||
Task<VendorCompletionDocument?> GetForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken);
|
||||
Task<VendorCompletionDocument?> GetMetadataForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken);
|
||||
Task<List<VendorCompletionDocument>> ListForVendorDispatchAsync(int dispatchId, int vendorId, CancellationToken cancellationToken);
|
||||
Task<VendorCompletionDocument?> GetUpliftEvidenceAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken);
|
||||
Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,18 @@ public sealed class ApprovalsOptions
|
|||
public decimal? UpliftTier1MaxUsd { get; set; }
|
||||
public string[] Tier1Roles { get; set; } = Array.Empty<string>();
|
||||
public string[] Tier2Roles { get; set; } = Array.Empty<string>();
|
||||
|
||||
// SH-101 uplift lifecycle configuration. Defaults preserve a safe behavior.
|
||||
public int? ExpirationHours { get; set; } = 72;
|
||||
public int? EscalationAfterHours { get; set; } = 48;
|
||||
public int SweepIntervalSeconds { get; set; } = 300;
|
||||
public string[] Tier1NotificationRecipients { get; set; } = Array.Empty<string>();
|
||||
public string[] Tier2NotificationRecipients { get; set; } = Array.Empty<string>();
|
||||
public string[] EscalationRecipients { get; set; } = Array.Empty<string>();
|
||||
|
||||
public TimeSpan EffectiveExpiration => TimeSpan.FromHours(ExpirationHours is > 0 ? ExpirationHours.Value : 72);
|
||||
public TimeSpan EffectiveEscalation => TimeSpan.FromHours(EscalationAfterHours is > 0 ? EscalationAfterHours.Value : 48);
|
||||
public TimeSpan EffectiveSweepInterval => TimeSpan.FromSeconds(SweepIntervalSeconds is > 0 ? SweepIntervalSeconds : 300);
|
||||
}
|
||||
|
||||
public sealed class VendorPortalOptions
|
||||
|
|
|
|||
|
|
@ -18,6 +18,14 @@ namespace SeaHaven.Services.DTOs
|
|||
public DateTime? DecidedAt { get; set; }
|
||||
public string? DecisionNote { get; set; }
|
||||
public bool CanDecide { get; set; }
|
||||
// SH-101 additions
|
||||
public int? EvidenceDocumentId { get; set; }
|
||||
public string? EvidenceFileName { get; set; }
|
||||
public string? EvidenceContentType { get; set; }
|
||||
public long? EvidenceSizeBytes { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public string? NotificationStatus { get; set; }
|
||||
public string? NotificationError { get; set; }
|
||||
}
|
||||
|
||||
public class UpliftListResultDTO
|
||||
|
|
@ -44,6 +52,15 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? DecisionNote { get; set; }
|
||||
public string? DecidedByName { get; set; }
|
||||
public bool CanDecide { get; set; }
|
||||
// SH-101 additions
|
||||
public int? EvidenceDocumentId { get; set; }
|
||||
public string? EvidenceFileName { get; set; }
|
||||
public string? EvidenceContentType { get; set; }
|
||||
public long? EvidenceSizeBytes { get; set; }
|
||||
public bool EvidenceScanPassed { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public string? NotificationStatus { get; set; }
|
||||
public string? NotificationError { get; set; }
|
||||
}
|
||||
|
||||
public class UpliftApproveResultDTO
|
||||
|
|
@ -53,6 +70,13 @@ namespace SeaHaven.Services.DTOs
|
|||
public decimal? NTEAmount { get; set; }
|
||||
}
|
||||
|
||||
public class UpliftDecisionResultDTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
// SH-101: deny alias retained for route compatibility; canonical result is Rejected.
|
||||
public class UpliftDenyResultDTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
|
@ -63,4 +87,26 @@ namespace SeaHaven.Services.DTOs
|
|||
{
|
||||
public UpliftForbiddenException(string message) : base(message) { }
|
||||
}
|
||||
|
||||
// SH-101: internal (authorized) download of a Passed UpliftEvidence file linked to a
|
||||
// specific uplift request. Outcome-driven so the controller returns 404/423 without
|
||||
// exception disclosure. Completion documents are never exposed through this path.
|
||||
public sealed class UpliftEvidenceDownloadResultDTO
|
||||
{
|
||||
public VendorDocumentDownloadOutcome Outcome { get; set; }
|
||||
public Stream? Content { get; set; }
|
||||
public string? ContentType { get; set; }
|
||||
public string? FileName { get; set; }
|
||||
|
||||
public static UpliftEvidenceDownloadResultDTO Ok(Stream content, string contentType, string fileName) => new()
|
||||
{
|
||||
Outcome = VendorDocumentDownloadOutcome.Ok,
|
||||
Content = content,
|
||||
ContentType = contentType,
|
||||
FileName = fileName
|
||||
};
|
||||
|
||||
public static UpliftEvidenceDownloadResultDTO Locked() => new() { Outcome = VendorDocumentDownloadOutcome.Locked };
|
||||
public static UpliftEvidenceDownloadResultDTO NotFound() => new() { Outcome = VendorDocumentDownloadOutcome.NotFound };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,17 @@ namespace SeaHaven.Services.DTOs
|
|||
public int Version { get; set; }
|
||||
public string ScanStatus { get; set; } = "Pending";
|
||||
public string ReviewStatus { get; set; } = "Processing";
|
||||
// SH-101: echoes the validated document purpose.
|
||||
public string Purpose { get; set; } = "Completion";
|
||||
}
|
||||
|
||||
public sealed class VendorDocumentStatusDTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string OriginalFileName { get; set; } = string.Empty;
|
||||
public string ScanStatus { get; set; } = "Pending";
|
||||
public string ReviewStatus { get; set; } = "Processing";
|
||||
public string Purpose { get; set; } = "Completion";
|
||||
}
|
||||
|
||||
public enum VendorDocumentDownloadOutcome
|
||||
|
|
|
|||
|
|
@ -58,6 +58,16 @@ namespace SeaHaven.Services.DTOs
|
|||
public DateTime? DecidedAt { get; set; }
|
||||
public string? DecisionNote { get; set; }
|
||||
public string? DecidedByName { get; set; }
|
||||
// SH-101 additions
|
||||
public int? EvidenceDocumentId { get; set; }
|
||||
public string? EvidenceFileName { get; set; }
|
||||
public string? EvidenceContentType { get; set; }
|
||||
public long? EvidenceSizeBytes { get; set; }
|
||||
public bool EvidenceScanPassed { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public string? NotificationStatus { get; set; }
|
||||
public string? NotificationError { get; set; }
|
||||
public bool HasChangesRequested { get; set; }
|
||||
}
|
||||
|
||||
public class PortalCommentDTO
|
||||
|
|
@ -193,6 +203,12 @@ namespace SeaHaven.Services.DTOs
|
|||
public int RequiredTier { get; set; }
|
||||
public decimal? CurrentNTE { get; set; }
|
||||
public decimal RequestedNTE { get; set; }
|
||||
// SH-101 additions
|
||||
public bool NoApprovalRequired { get; set; }
|
||||
public int? EvidenceDocumentId { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public string? NotificationStatus { get; set; }
|
||||
public bool IdempotentReplay { get; set; }
|
||||
}
|
||||
|
||||
public class CancelUpliftResultDTO
|
||||
|
|
@ -200,4 +216,23 @@ namespace SeaHaven.Services.DTOs
|
|||
public int Id { get; set; }
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
// SH-101: vendor withdraw (canonical) and revise results.
|
||||
public class WithdrawUpliftResultDTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
public class ReviseUpliftResultDTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public int RequiredTier { get; set; }
|
||||
public decimal? CurrentNTE { get; set; }
|
||||
public decimal RequestedNTE { get; set; }
|
||||
public int? EvidenceDocumentId { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public string? NotificationStatus { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
251
SeaHaven.Services/Implementation/UpliftLifecycleService.cs
Normal file
251
SeaHaven.Services/Implementation/UpliftLifecycleService.cs
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
public class UpliftLifecycleService : IUpliftLifecycleService
|
||||
{
|
||||
private readonly IUpliftDataService _upliftData;
|
||||
private readonly IDispatchDataService _dispatchData;
|
||||
private readonly IUserDataService _userData;
|
||||
private readonly IEmailSender _emailSender;
|
||||
private readonly FrontendOptions _frontendOptions;
|
||||
private readonly ApprovalsOptions _approvalsOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<UpliftLifecycleService> _logger;
|
||||
|
||||
public UpliftLifecycleService(
|
||||
IUpliftDataService upliftData,
|
||||
IDispatchDataService dispatchData,
|
||||
IUserDataService userData,
|
||||
IEmailSender emailSender,
|
||||
IOptions<FrontendOptions> frontendOptions,
|
||||
IOptions<ApprovalsOptions> approvalsOptions,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<UpliftLifecycleService> logger)
|
||||
{
|
||||
_upliftData = upliftData;
|
||||
_dispatchData = dispatchData;
|
||||
_userData = userData;
|
||||
_emailSender = emailSender;
|
||||
_frontendOptions = frontendOptions.Value;
|
||||
_approvalsOptions = approvalsOptions.Value;
|
||||
_timeProvider = timeProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<int> ExpireDueAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var due = await _upliftData.GetDueForExpiryAsync(now, 50, cancellationToken);
|
||||
var expired = 0;
|
||||
foreach (var req in due)
|
||||
{
|
||||
if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.Expired))
|
||||
continue;
|
||||
|
||||
var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId);
|
||||
if (dispatch == null)
|
||||
{
|
||||
_logger.LogError("Uplift expiry skipped for orphan request {UpliftId}; dispatch {DispatchId} was not found.", req.Id, req.DispatchId);
|
||||
continue;
|
||||
}
|
||||
|
||||
var previous = UpliftStatus.ToCanonical(req.Status);
|
||||
|
||||
req.Status = UpliftStatus.Expired;
|
||||
req.DecidedAt = now;
|
||||
req.LastModificationTime = now;
|
||||
|
||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch?.WorkOrderId ?? 0,
|
||||
FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift",
|
||||
OldValue = previous,
|
||||
NewValue = UpliftStatus.Expired,
|
||||
Action = "uplift_expired",
|
||||
CreatedAt = now
|
||||
}, cancellationToken);
|
||||
|
||||
await _upliftData.SaveChangesAsync(cancellationToken);
|
||||
expired++;
|
||||
}
|
||||
|
||||
return expired;
|
||||
}
|
||||
|
||||
public async Task<int> SendDueInitialNotificationsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var due = await _upliftData.GetDueForInitialNotificationAsync(50, cancellationToken);
|
||||
var sent = 0;
|
||||
foreach (var req in due)
|
||||
{
|
||||
var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId);
|
||||
if (dispatch == null) continue;
|
||||
|
||||
var recipients = await ResolveInitialRecipientsAsync(dispatch, req, cancellationToken);
|
||||
if (recipients.Count == 0)
|
||||
{
|
||||
await PersistNotificationOutcomeAsync(req, delivered: false, "No notification recipients are configured for this dispatch.", cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
var (subject, body) = UpliftNotificationMessage.BuildInitial(dispatch, req, req.RequestedByVendorName ?? "Vendor", _frontendOptions.FrontendBaseUrl ?? string.Empty);
|
||||
var delivered = await SendToAllAsync(recipients, subject, body);
|
||||
await PersistNotificationOutcomeAsync(req, delivered, delivered ? null : "The uplift notification could not be delivered.", cancellationToken);
|
||||
if (delivered) sent++;
|
||||
}
|
||||
|
||||
return sent;
|
||||
}
|
||||
|
||||
public async Task<int> EscalateDueAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var candidates = await _upliftData.GetDueForEscalationAsync(50, cancellationToken);
|
||||
var escalationThreshold = _approvalsOptions.EffectiveEscalation;
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var escalated = 0;
|
||||
|
||||
foreach (var req in candidates)
|
||||
{
|
||||
if (req.InitialNotificationSentAt == null || req.EscalatedAt != null)
|
||||
continue;
|
||||
if (now - req.InitialNotificationSentAt.Value < escalationThreshold)
|
||||
continue;
|
||||
|
||||
var recipients = await ResolveEscalationRecipientsAsync(req, cancellationToken);
|
||||
if (recipients.Count == 0)
|
||||
continue;
|
||||
|
||||
var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId);
|
||||
if (dispatch == null) continue;
|
||||
|
||||
var (subject, body) = UpliftNotificationMessage.BuildEscalation(dispatch, req, req.RequestedByVendorName ?? "Vendor", _frontendOptions.FrontendBaseUrl ?? string.Empty);
|
||||
var delivered = await SendToAllAsync(recipients, subject, body);
|
||||
if (!delivered)
|
||||
{
|
||||
// Escalation send failure is logged only; it does not alter workflow status.
|
||||
_logger.LogWarning("Uplift escalation delivery failed for request {UpliftId}", req.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
req.EscalatedAt = now;
|
||||
req.LastModificationTime = now;
|
||||
|
||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
|
||||
OldValue = "Pending",
|
||||
NewValue = "Escalated",
|
||||
Action = "uplift_escalated",
|
||||
CreatedAt = now
|
||||
}, cancellationToken);
|
||||
|
||||
await _upliftData.SaveChangesAsync(cancellationToken);
|
||||
escalated++;
|
||||
}
|
||||
|
||||
return escalated;
|
||||
}
|
||||
|
||||
private async Task<HashSet<string>> ResolveInitialRecipientsAsync(Dispatch dispatch, DispatchUpliftRequest req, CancellationToken cancellationToken)
|
||||
{
|
||||
var recipients = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var dispatcherUserId = await _dispatchData.GetDispatchDispatcherUserIdAsync(dispatch.WorkOrderId ?? 0, cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(dispatcherUserId))
|
||||
{
|
||||
var dispatcherEmail = await _userData.GetEmailByIdAsync(dispatcherUserId, cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(dispatcherEmail))
|
||||
recipients.Add(dispatcherEmail);
|
||||
}
|
||||
|
||||
var tierRecipients = req.RequiredTier == 2
|
||||
? _approvalsOptions.Tier2NotificationRecipients
|
||||
: _approvalsOptions.Tier1NotificationRecipients;
|
||||
if (tierRecipients != null)
|
||||
{
|
||||
foreach (var recipient in tierRecipients)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(recipient))
|
||||
recipients.Add(recipient.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
return recipients;
|
||||
}
|
||||
|
||||
private Task<HashSet<string>> ResolveEscalationRecipientsAsync(DispatchUpliftRequest req, CancellationToken cancellationToken)
|
||||
{
|
||||
var recipients = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (_approvalsOptions.EscalationRecipients != null)
|
||||
{
|
||||
foreach (var recipient in _approvalsOptions.EscalationRecipients)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(recipient))
|
||||
recipients.Add(recipient.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
var tierRecipients = req.RequiredTier == 2
|
||||
? _approvalsOptions.Tier2NotificationRecipients
|
||||
: _approvalsOptions.Tier1NotificationRecipients;
|
||||
if (tierRecipients != null)
|
||||
{
|
||||
foreach (var recipient in tierRecipients)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(recipient))
|
||||
recipients.Add(recipient.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(recipients);
|
||||
}
|
||||
|
||||
private async Task<bool> SendToAllAsync(HashSet<string> recipients, string subject, string body)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var recipient in recipients)
|
||||
{
|
||||
var delivered = await _emailSender.SendEmailAsync(recipient, subject, body);
|
||||
if (!delivered)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Never leak addresses or exception details; the persisted signal is safe text.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PersistNotificationOutcomeAsync(DispatchUpliftRequest req, bool delivered, string? errorMessage, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
if (delivered)
|
||||
{
|
||||
req.InitialNotificationSentAt = now;
|
||||
req.NotificationStatus = UpliftNotificationStatus.Sent;
|
||||
req.NotificationError = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
req.NotificationStatus = UpliftNotificationStatus.Error;
|
||||
req.NotificationError = errorMessage;
|
||||
}
|
||||
req.LastModificationTime = now;
|
||||
await _upliftData.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds uplift notification email content. Shared by the synchronous request path
|
||||
/// and the lifecycle sweep so content stays consistent without duplication.
|
||||
/// </summary>
|
||||
public static class UpliftNotificationMessage
|
||||
{
|
||||
public static (string Subject, string Body) BuildInitial(Dispatch dispatch, DispatchUpliftRequest req, string vendorName, string frontendBase)
|
||||
{
|
||||
var dispatchNum = System.Net.WebUtility.HtmlEncode(dispatch.DispatchNumber ?? "");
|
||||
var vendor = System.Net.WebUtility.HtmlEncode(vendorName);
|
||||
var reason = System.Net.WebUtility.HtmlEncode(req.VendorReason ?? "(no reason provided)");
|
||||
var tierText = req.RequiredTier == 2 ? "Tier 2 (Manager approval required)" : "Tier 1";
|
||||
|
||||
var subject = $"[Uplift Request] {dispatch.DispatchNumber} — {vendorName} requests ${req.RequestedNTE:F2} (was ${req.CurrentNTE ?? 0m:F2})";
|
||||
var body = $@"
|
||||
<h2>Vendor Uplift Request</h2>
|
||||
<p><strong>Dispatch:</strong> {dispatchNum}</p>
|
||||
<p><strong>Vendor:</strong> {vendor}</p>
|
||||
<table style='border-collapse:collapse;font-family:Arial,sans-serif;'>
|
||||
<tr><td style='padding:4px 10px;'><strong>Current NTE</strong></td><td style='padding:4px 10px;'>${req.CurrentNTE ?? 0m:F2}</td></tr>
|
||||
<tr><td style='padding:4px 10px;'><strong>Requested NTE</strong></td><td style='padding:4px 10px;'>${req.RequestedNTE:F2}</td></tr>
|
||||
<tr><td style='padding:4px 10px;'><strong>Delta</strong></td><td style='padding:4px 10px;'>${(req.RequestedNTE - (req.CurrentNTE ?? 0m)):F2}</td></tr>
|
||||
<tr><td style='padding:4px 10px;'><strong>Required Approval</strong></td><td style='padding:4px 10px;'>{tierText}</td></tr>
|
||||
</table>
|
||||
<h3>Vendor Reason</h3>
|
||||
<p>{reason}</p>
|
||||
{ReviewLink(dispatch, frontendBase)}";
|
||||
return (subject, body);
|
||||
}
|
||||
|
||||
public static (string Subject, string Body) BuildEscalation(Dispatch dispatch, DispatchUpliftRequest req, string vendorName, string frontendBase)
|
||||
{
|
||||
var dispatchNum = System.Net.WebUtility.HtmlEncode(dispatch.DispatchNumber ?? "");
|
||||
var vendor = System.Net.WebUtility.HtmlEncode(vendorName);
|
||||
var tierText = req.RequiredTier == 2 ? "Tier 2 (Manager approval required)" : "Tier 1";
|
||||
|
||||
var subject = $"[Escalation] Uplift request awaiting decision — {dispatch.DispatchNumber} ({vendorName})";
|
||||
var body = $@"
|
||||
<h2>Uplift Request Escalation</h2>
|
||||
<p>An uplift request is still awaiting a decision and has passed the escalation threshold.</p>
|
||||
<p><strong>Dispatch:</strong> {dispatchNum}</p>
|
||||
<p><strong>Vendor:</strong> {vendor}</p>
|
||||
<table style='border-collapse:collapse;font-family:Arial,sans-serif;'>
|
||||
<tr><td style='padding:4px 10px;'><strong>Current NTE</strong></td><td style='padding:4px 10px;'>${req.CurrentNTE ?? 0m:F2}</td></tr>
|
||||
<tr><td style='padding:4px 10px;'><strong>Requested NTE</strong></td><td style='padding:4px 10px;'>${req.RequestedNTE:F2}</td></tr>
|
||||
<tr><td style='padding:4px 10px;'><strong>Required Approval</strong></td><td style='padding:4px 10px;'>{tierText}</td></tr>
|
||||
</table>
|
||||
{ReviewLink(dispatch, frontendBase)}";
|
||||
return (subject, body);
|
||||
}
|
||||
|
||||
private static string ReviewLink(Dispatch dispatch, string frontendBase)
|
||||
{
|
||||
var link = dispatch.WorkOrderId.HasValue
|
||||
? $"{frontendBase.TrimEnd('/')}/workorders/{dispatch.WorkOrderId.Value}"
|
||||
: frontendBase;
|
||||
return $"<div style='margin:20px 0;'><a href='{link}' style='display:inline-block;padding:10px 22px;background:#2563eb;color:white;text-decoration:none;border-radius:6px;font-weight:bold;'>Review in SHOC</a></div>";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,12 +12,21 @@ namespace SeaHaven.Services.Implementation
|
|||
{
|
||||
private readonly IUpliftDataService _upliftData;
|
||||
private readonly IDispatchDataService _dispatchData;
|
||||
private readonly IVendorDocumentStoragePort _documentStorage;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ApprovalsOptions _approvalsOptions;
|
||||
|
||||
public UpliftService(IUpliftDataService upliftData, IDispatchDataService dispatchData, IOptions<ApprovalsOptions> approvalsOptions)
|
||||
public UpliftService(
|
||||
IUpliftDataService upliftData,
|
||||
IDispatchDataService dispatchData,
|
||||
IVendorDocumentStoragePort documentStorage,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<ApprovalsOptions> approvalsOptions)
|
||||
{
|
||||
_upliftData = upliftData;
|
||||
_dispatchData = dispatchData;
|
||||
_documentStorage = documentStorage;
|
||||
_timeProvider = timeProvider;
|
||||
_approvalsOptions = approvalsOptions.Value;
|
||||
}
|
||||
|
||||
|
|
@ -38,11 +47,18 @@ namespace SeaHaven.Services.Implementation
|
|||
Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m),
|
||||
VendorReason = r.VendorReason,
|
||||
RequiredTier = r.RequiredTier,
|
||||
Status = r.Status,
|
||||
Status = UpliftStatus.ToCanonical(r.Status),
|
||||
RequestedAt = r.CreatedDate,
|
||||
DecidedAt = r.DecidedAt,
|
||||
DecisionNote = r.DecisionNote,
|
||||
CanDecide = UserCanApprove(user, r.RequiredTier)
|
||||
CanDecide = UserCanApprove(user, r.RequiredTier),
|
||||
EvidenceDocumentId = r.EvidenceDocumentId,
|
||||
EvidenceFileName = r.EvidenceFileName,
|
||||
EvidenceContentType = r.EvidenceContentType,
|
||||
EvidenceSizeBytes = r.EvidenceSizeBytes,
|
||||
ExpiresAt = r.ExpiresAt,
|
||||
NotificationStatus = r.NotificationStatus,
|
||||
NotificationError = r.NotificationError
|
||||
}).ToList();
|
||||
|
||||
return new UpliftListResultDTO
|
||||
|
|
@ -66,7 +82,7 @@ namespace SeaHaven.Services.Implementation
|
|||
RequestedNTE = r.RequestedNTE,
|
||||
Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m),
|
||||
VendorReason = r.VendorReason,
|
||||
Status = r.Status,
|
||||
Status = UpliftStatus.ToCanonical(r.Status),
|
||||
RequiredTier = r.RequiredTier,
|
||||
RequestedByVendorName = r.RequestedByVendorName,
|
||||
RequestedAt = r.CreatedDate,
|
||||
|
|
@ -74,7 +90,15 @@ namespace SeaHaven.Services.Implementation
|
|||
DecisionNote = r.DecisionNote,
|
||||
DecidedByName = string.Join(" ", new[] { r.DecidedByFirstName, r.DecidedByLastName }
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim(),
|
||||
CanDecide = UserCanApprove(user, r.RequiredTier)
|
||||
CanDecide = UserCanApprove(user, r.RequiredTier),
|
||||
EvidenceDocumentId = r.EvidenceDocumentId,
|
||||
EvidenceFileName = r.EvidenceFileName,
|
||||
EvidenceContentType = r.EvidenceContentType,
|
||||
EvidenceSizeBytes = r.EvidenceSizeBytes,
|
||||
EvidenceScanPassed = r.EvidenceScanPassed,
|
||||
ExpiresAt = r.ExpiresAt,
|
||||
NotificationStatus = r.NotificationStatus,
|
||||
NotificationError = r.NotificationError
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
|
|
@ -82,23 +106,26 @@ namespace SeaHaven.Services.Implementation
|
|||
{
|
||||
var req = await _upliftData.GetByIdAsync(id, cancellationToken);
|
||||
if (req == null) throw new KeyNotFoundException("Uplift request not found");
|
||||
if (req.Status != "Pending") throw new InvalidOperationException($"Cannot approve a '{req.Status}' request");
|
||||
if (UpliftStatus.IsTerminal(req.Status))
|
||||
throw new InvalidOperationException($"Cannot approve a '{UpliftStatus.ToCanonical(req.Status)}' request");
|
||||
if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.Approved))
|
||||
throw new InvalidOperationException($"Cannot approve a '{UpliftStatus.ToCanonical(req.Status)}' request");
|
||||
if (!UserCanApprove(user, req.RequiredTier))
|
||||
throw new UpliftForbiddenException($"Approval requires a Tier {req.RequiredTier} role");
|
||||
|
||||
var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId);
|
||||
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
|
||||
if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled" || dispatch.Status == "Refused")
|
||||
if (IsTerminalForUplift(dispatch.Status))
|
||||
throw new InvalidOperationException($"Cannot approve uplift on a '{dispatch.Status}' dispatch");
|
||||
|
||||
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
var now = DateTime.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var oldNTE = dispatch.NTEAmount ?? 0m;
|
||||
|
||||
dispatch.NTEAmount = req.RequestedNTE;
|
||||
dispatch.LastModificationTime = now;
|
||||
|
||||
req.Status = "Approved";
|
||||
req.Status = UpliftStatus.Approved;
|
||||
req.DecidedAt = now;
|
||||
req.DecidedByUserId = userId;
|
||||
req.DecisionNote = string.IsNullOrWhiteSpace(note) ? null : note!.Trim();
|
||||
|
|
@ -115,27 +142,41 @@ namespace SeaHaven.Services.Implementation
|
|||
CreatedAt = now
|
||||
}, cancellationToken);
|
||||
|
||||
// One EF unit-of-work commit for the dispatch RowVersion-protected NTE + request + audit.
|
||||
await _upliftData.SaveChangesAsync(cancellationToken);
|
||||
return new UpliftApproveResultDTO { Id = req.Id, Status = req.Status, NTEAmount = dispatch.NTEAmount };
|
||||
return new UpliftApproveResultDTO { Id = req.Id, Status = UpliftStatus.Approved, NTEAmount = dispatch.NTEAmount };
|
||||
}
|
||||
|
||||
public async Task<UpliftDenyResultDTO> DenyAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await RejectInternalAsync(user, id, note, "deny", cancellationToken);
|
||||
return new UpliftDenyResultDTO { Id = result.Id, Status = result.Status };
|
||||
}
|
||||
|
||||
public Task<UpliftDecisionResultDTO> RejectAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken)
|
||||
=> RejectInternalAsync(user, id, note, "reject", cancellationToken);
|
||||
|
||||
private async Task<UpliftDecisionResultDTO> RejectInternalAsync(ClaimsPrincipal user, int id, string? note, string actionSuffix, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(note))
|
||||
throw new InvalidOperationException("A note is required when denying");
|
||||
throw new InvalidOperationException("A note is required when rejecting");
|
||||
|
||||
var req = await _upliftData.GetByIdAsync(id, cancellationToken);
|
||||
if (req == null) throw new KeyNotFoundException("Uplift request not found");
|
||||
if (req.Status != "Pending") throw new InvalidOperationException($"Cannot deny a '{req.Status}' request");
|
||||
if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.Rejected))
|
||||
throw new InvalidOperationException($"Cannot reject a '{UpliftStatus.ToCanonical(req.Status)}' request");
|
||||
if (!UserCanApprove(user, req.RequiredTier))
|
||||
throw new UpliftForbiddenException($"Decision requires a Tier {req.RequiredTier} role");
|
||||
|
||||
var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId);
|
||||
|
||||
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
var now = DateTime.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
|
||||
req.Status = "Denied";
|
||||
// Capture the pre-transition canonical status before mutating req.Status,
|
||||
// otherwise the audit OldValue would record the already-applied Rejected value.
|
||||
var previous = UpliftStatus.ToCanonical(req.Status);
|
||||
req.Status = UpliftStatus.Rejected;
|
||||
req.DecidedAt = now;
|
||||
req.DecidedByUserId = userId;
|
||||
req.DecisionNote = note!.Trim();
|
||||
|
|
@ -146,18 +187,84 @@ namespace SeaHaven.Services.Implementation
|
|||
WorkOrderId = dispatch?.WorkOrderId ?? 0,
|
||||
UserId = userId,
|
||||
FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift",
|
||||
OldValue = "Pending",
|
||||
NewValue = "Denied",
|
||||
Action = "uplift_denied",
|
||||
OldValue = previous,
|
||||
NewValue = UpliftStatus.Rejected,
|
||||
Action = $"uplift_{actionSuffix}",
|
||||
CreatedAt = now
|
||||
}, cancellationToken);
|
||||
|
||||
await _upliftData.SaveChangesAsync(cancellationToken);
|
||||
return new UpliftDenyResultDTO { Id = req.Id, Status = req.Status };
|
||||
return new UpliftDecisionResultDTO { Id = req.Id, Status = UpliftStatus.Rejected };
|
||||
}
|
||||
|
||||
public async Task<UpliftDecisionResultDTO> RequestChangesAsync(ClaimsPrincipal user, int id, string note, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(note))
|
||||
throw new InvalidOperationException("A note is required when requesting changes");
|
||||
|
||||
var req = await _upliftData.GetByIdAsync(id, cancellationToken);
|
||||
if (req == null) throw new KeyNotFoundException("Uplift request not found");
|
||||
if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.ChangesRequested))
|
||||
throw new InvalidOperationException($"Cannot request changes on a '{UpliftStatus.ToCanonical(req.Status)}' request");
|
||||
if (!UserCanApprove(user, req.RequiredTier))
|
||||
throw new UpliftForbiddenException($"Requesting changes requires a Tier {req.RequiredTier} role");
|
||||
|
||||
var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId);
|
||||
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
|
||||
var previous = UpliftStatus.ToCanonical(req.Status);
|
||||
req.Status = UpliftStatus.ChangesRequested;
|
||||
req.DecidedAt = now;
|
||||
req.DecidedByUserId = userId;
|
||||
req.DecisionNote = note.Trim();
|
||||
req.LastModificationTime = now;
|
||||
|
||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch?.WorkOrderId ?? 0,
|
||||
UserId = userId,
|
||||
FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift",
|
||||
OldValue = previous,
|
||||
NewValue = UpliftStatus.ChangesRequested,
|
||||
Action = "uplift_changes_requested",
|
||||
CreatedAt = now
|
||||
}, cancellationToken);
|
||||
|
||||
await _upliftData.SaveChangesAsync(cancellationToken);
|
||||
return new UpliftDecisionResultDTO { Id = req.Id, Status = UpliftStatus.ChangesRequested };
|
||||
}
|
||||
|
||||
public bool CanApprove(ClaimsPrincipal user, int tier) => UserCanApprove(user, tier);
|
||||
|
||||
// SH-101: authorized internal download of a Passed UpliftEvidence file. The data
|
||||
// service resolves the document by the request's own linkage (no client-supplied
|
||||
// document id or vendor/public path). Only UpliftEvidence documents are exposed;
|
||||
// completion documents resolve to NotFound. A scan that has not Passed is Locked.
|
||||
public async Task<UpliftEvidenceDownloadResultDTO> GetEvidenceForDownloadAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var evidence = await _upliftData.GetEvidenceForInternalDownloadAsync(id, cancellationToken);
|
||||
if (evidence == null
|
||||
|| evidence.EvidenceDocumentId == null
|
||||
|| !string.Equals(evidence.Purpose, VendorDocumentPurpose.UpliftEvidence, StringComparison.Ordinal))
|
||||
{
|
||||
return UpliftEvidenceDownloadResultDTO.NotFound();
|
||||
}
|
||||
|
||||
if (!UserCanApprove(user, evidence.RequiredTier))
|
||||
{
|
||||
throw new UpliftForbiddenException($"Evidence access requires a Tier {evidence.RequiredTier} role");
|
||||
}
|
||||
|
||||
if (!string.Equals(evidence.ScanStatus, "Passed", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return UpliftEvidenceDownloadResultDTO.Locked();
|
||||
}
|
||||
|
||||
var content = _documentStorage.OpenRead(evidence.VendorId, evidence.DispatchId, evidence.StoredFileName ?? string.Empty);
|
||||
return UpliftEvidenceDownloadResultDTO.Ok(content, evidence.ContentType ?? "application/octet-stream", evidence.OriginalFileName ?? "evidence");
|
||||
}
|
||||
|
||||
private bool UserCanApprove(ClaimsPrincipal user, int requiredTier)
|
||||
{
|
||||
var roles = RolesForTier(requiredTier);
|
||||
|
|
@ -174,5 +281,11 @@ namespace SeaHaven.Services.Implementation
|
|||
2 => _approvalsOptions.Tier2Roles,
|
||||
_ => Array.Empty<string>()
|
||||
};
|
||||
|
||||
// Terminal dispatch guard for uplift decisions. "Refused" is the SH-98
|
||||
// dispatch-refusal workflow; a refused dispatch is terminal for uplift just
|
||||
// like Verified/Cancelled.
|
||||
private static bool IsTerminalForUplift(string? status) =>
|
||||
status is "Verified" or "Cancelled" or "Canceled" or "Refused";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
94
SeaHaven.Services/Implementation/UpliftStatus.cs
Normal file
94
SeaHaven.Services/Implementation/UpliftStatus.cs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-owned uplift state machine (SH-101). Canonical states are
|
||||
/// Pending, Approved, Rejected, ChangesRequested, Withdrawn, Expired.
|
||||
/// Legacy stored values "Denied" (-> Rejected) and "Cancelled" (-> Withdrawn)
|
||||
/// are read as their canonical equivalents but never (re)written.
|
||||
/// </summary>
|
||||
public static class UpliftStatus
|
||||
{
|
||||
public const string Pending = "Pending";
|
||||
public const string Approved = "Approved";
|
||||
public const string Rejected = "Rejected";
|
||||
public const string ChangesRequested = "ChangesRequested";
|
||||
public const string Withdrawn = "Withdrawn";
|
||||
public const string Expired = "Expired";
|
||||
public const string NoApprovalRequired = "NoApprovalRequired";
|
||||
|
||||
// Legacy aliases retained only for reads; never written by new code.
|
||||
public const string LegacyDenied = "Denied";
|
||||
public const string LegacyCancelled = "Cancelled";
|
||||
|
||||
private static readonly HashSet<string> TerminalStates =
|
||||
new(StringComparer.Ordinal) { Approved, Rejected, Withdrawn, Expired };
|
||||
|
||||
private static readonly HashSet<string> ActiveStates =
|
||||
new(StringComparer.Ordinal) { Pending, ChangesRequested };
|
||||
|
||||
/// <summary>
|
||||
/// Maps a stored status to its canonical DTO value. Legacy "Denied" is
|
||||
/// read as "Rejected" and "Cancelled" as "Withdrawn".
|
||||
/// </summary>
|
||||
public static string ToCanonical(string? status) => status switch
|
||||
{
|
||||
LegacyDenied => Rejected,
|
||||
LegacyCancelled => Withdrawn,
|
||||
_ => status ?? Pending
|
||||
};
|
||||
|
||||
/// <summary>True for terminal (immutable) states, including legacy aliases.</summary>
|
||||
public static bool IsTerminal(string? status) =>
|
||||
TerminalStates.Contains(ToCanonical(status));
|
||||
|
||||
/// <summary>True for actionable states that block a new active request.</summary>
|
||||
public static bool IsActive(string? status) =>
|
||||
ActiveStates.Contains(ToCanonical(status));
|
||||
|
||||
/// <summary>
|
||||
/// Validates a server-owned transition. Only legal transitions return true.
|
||||
/// Terminal states (incl. legacy aliases) never transition out.
|
||||
/// </summary>
|
||||
public static bool CanTransition(string? from, string to)
|
||||
{
|
||||
var canonicalFrom = ToCanonical(from);
|
||||
|
||||
if (TerminalStates.Contains(canonicalFrom))
|
||||
return false;
|
||||
|
||||
return canonicalFrom switch
|
||||
{
|
||||
Pending => to is Approved or Rejected or ChangesRequested or Withdrawn or Expired,
|
||||
ChangesRequested => to is Pending or Withdrawn or Expired,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Snapshot-readable active states, used by data-service filters.</summary>
|
||||
public static IReadOnlyCollection<string> ActiveStatuses => ActiveStates;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notification pipeline status (separate from the workflow <see cref="UpliftStatus"/>).
|
||||
/// A notification failure never destroys an actionable request.
|
||||
/// </summary>
|
||||
public static class UpliftNotificationStatus
|
||||
{
|
||||
public const string Pending = "Pending";
|
||||
public const string Sent = "Sent";
|
||||
public const string Error = "Error";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vendor completion document purposes (SH-101). Completion is the legacy/default
|
||||
/// behavior; UpliftEvidence is immutable supporting evidence for an uplift request.
|
||||
/// </summary>
|
||||
public static class VendorDocumentPurpose
|
||||
{
|
||||
public const string Completion = "Completion";
|
||||
public const string UpliftEvidence = "UpliftEvidence";
|
||||
|
||||
public static bool IsValid(string? purpose) =>
|
||||
purpose is Completion or UpliftEvidence;
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ namespace SeaHaven.Services.Implementation
|
|||
private readonly FrontendOptions _frontendOptions;
|
||||
private readonly ApprovalsOptions _approvalsOptions;
|
||||
private readonly VendorDocumentsOptions _documentOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public VendorPortalService(
|
||||
IVendorPortalTokenService tokens,
|
||||
|
|
@ -40,7 +41,8 @@ namespace SeaHaven.Services.Implementation
|
|||
IVendorDocumentStoragePort documentStorage,
|
||||
IOptions<FrontendOptions> frontendOptions,
|
||||
IOptions<ApprovalsOptions> approvalsOptions,
|
||||
IOptions<VendorDocumentsOptions> documentOptions)
|
||||
IOptions<VendorDocumentsOptions> documentOptions,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_tokens = tokens;
|
||||
_dispatchData = dispatchData;
|
||||
|
|
@ -53,6 +55,7 @@ namespace SeaHaven.Services.Implementation
|
|||
_frontendOptions = frontendOptions.Value;
|
||||
_approvalsOptions = approvalsOptions.Value;
|
||||
_documentOptions = documentOptions.Value;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<VendorPortalSession?> ResolveSessionAsync(string? token, CancellationToken cancellationToken)
|
||||
|
|
@ -100,14 +103,23 @@ namespace SeaHaven.Services.Implementation
|
|||
CurrentNTE = u.CurrentNTE,
|
||||
RequestedNTE = u.RequestedNTE,
|
||||
VendorReason = u.VendorReason,
|
||||
Status = u.Status,
|
||||
Status = UpliftStatus.ToCanonical(u.Status),
|
||||
RequiredTier = u.RequiredTier,
|
||||
RequestedByVendorName = u.RequestedByVendorName,
|
||||
RequestedAt = u.CreatedDate,
|
||||
DecidedAt = u.DecidedAt,
|
||||
DecisionNote = u.DecisionNote,
|
||||
DecidedByName = string.Join(" ", new[] { u.DecidedByFirstName, u.DecidedByLastName }
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim()
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim(),
|
||||
EvidenceDocumentId = u.EvidenceDocumentId,
|
||||
EvidenceFileName = u.EvidenceFileName,
|
||||
EvidenceContentType = u.EvidenceContentType,
|
||||
EvidenceSizeBytes = u.EvidenceSizeBytes,
|
||||
EvidenceScanPassed = u.EvidenceScanPassed,
|
||||
ExpiresAt = u.ExpiresAt,
|
||||
NotificationStatus = u.NotificationStatus,
|
||||
NotificationError = u.NotificationError,
|
||||
HasChangesRequested = UpliftStatus.ToCanonical(u.Status) == UpliftStatus.ChangesRequested
|
||||
}).ToList();
|
||||
|
||||
var comments = rawComments.Select(c =>
|
||||
|
|
@ -503,7 +515,7 @@ namespace SeaHaven.Services.Implementation
|
|||
};
|
||||
}
|
||||
|
||||
public async Task<UpliftRequestResultDTO> RequestUpliftAsync(VendorPortalSession session, int id, decimal requestedNTE, string? reason, CancellationToken cancellationToken)
|
||||
public async Task<UpliftRequestResultDTO> RequestUpliftAsync(VendorPortalSession session, int id, decimal requestedNTE, string? reason, string? requestKey, int? evidenceDocumentId, CancellationToken cancellationToken)
|
||||
{
|
||||
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken);
|
||||
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
|
||||
|
|
@ -518,32 +530,112 @@ namespace SeaHaven.Services.Implementation
|
|||
throw new InvalidOperationException("Requested NTE must be greater than zero");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var current = dispatch.NTEAmount ?? 0m;
|
||||
|
||||
// NoApprovalRequired: requestedNTE <= current. Do not create an uplift or change NTE;
|
||||
// record an audit event documenting the routed non-uplift outcome.
|
||||
if (requestedNTE <= current)
|
||||
{
|
||||
throw new InvalidOperationException("Requested NTE must be greater than the current NTE");
|
||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
|
||||
OldValue = $"${current:F2}",
|
||||
NewValue = $"${requestedNTE:F2}",
|
||||
Action = "uplift_no_approval_required",
|
||||
ActorType = "vendor",
|
||||
CreatedAt = now
|
||||
}, cancellationToken);
|
||||
await _upliftData.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new UpliftRequestResultDTO
|
||||
{
|
||||
Id = 0,
|
||||
Status = UpliftStatus.NoApprovalRequired,
|
||||
RequiredTier = 0,
|
||||
CurrentNTE = current,
|
||||
RequestedNTE = requestedNTE,
|
||||
NoApprovalRequired = true
|
||||
};
|
||||
}
|
||||
|
||||
var pendingExists = await _upliftData.HasPendingAsync(id, cancellationToken);
|
||||
if (pendingExists)
|
||||
// requestedNTE > current: reason and scanned evidence are mandatory.
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
{
|
||||
throw new InvalidOperationException("A pending uplift request already exists for this dispatch");
|
||||
throw new InvalidOperationException("A reason is required when requesting an uplift above the current NTE");
|
||||
}
|
||||
|
||||
if (!evidenceDocumentId.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("Supporting evidence is required when requesting an uplift above the current NTE");
|
||||
}
|
||||
|
||||
var evidence = await _documentData.GetUpliftEvidenceAsync(evidenceDocumentId.Value, id, session.Id, cancellationToken);
|
||||
if (evidence == null)
|
||||
{
|
||||
throw new InvalidOperationException("The selected evidence document is not available or has not passed scanning");
|
||||
}
|
||||
|
||||
// Idempotency: a repeated identical RequestKey returns the same request; mismatched reuse rejects.
|
||||
if (!string.IsNullOrWhiteSpace(requestKey))
|
||||
{
|
||||
var normalizedKey = requestKey.Trim();
|
||||
if (normalizedKey.Length > 100)
|
||||
{
|
||||
throw new InvalidOperationException("RequestKey is too long");
|
||||
}
|
||||
var existing = await _upliftData.GetByRequestKeyAsync(id, normalizedKey, cancellationToken);
|
||||
if (existing != null)
|
||||
{
|
||||
if (existing.RequestedNTE != requestedNTE
|
||||
|| existing.EvidenceDocumentId != evidence.Id
|
||||
|| !string.Equals(existing.VendorReason ?? "", reason.Trim(), StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("RequestKey was already used with different uplift details");
|
||||
}
|
||||
|
||||
return new UpliftRequestResultDTO
|
||||
{
|
||||
Id = existing.Id,
|
||||
Status = UpliftStatus.ToCanonical(existing.Status),
|
||||
RequiredTier = existing.RequiredTier,
|
||||
CurrentNTE = existing.CurrentNTE,
|
||||
RequestedNTE = existing.RequestedNTE,
|
||||
NoApprovalRequired = false,
|
||||
EvidenceDocumentId = existing.EvidenceDocumentId,
|
||||
ExpiresAt = existing.ExpiresAt,
|
||||
NotificationStatus = existing.NotificationStatus,
|
||||
IdempotentReplay = true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// At most one active (Pending or ChangesRequested) request per dispatch.
|
||||
var activeExists = await _upliftData.HasActiveAsync(id, cancellationToken);
|
||||
if (activeExists)
|
||||
{
|
||||
throw new InvalidOperationException("An active uplift request already exists for this dispatch");
|
||||
}
|
||||
|
||||
var tier1Max = _approvalsOptions.UpliftTier1MaxUsd ?? 2500m;
|
||||
var delta = requestedNTE - current;
|
||||
var requiredTier = delta > tier1Max ? 2 : 1;
|
||||
var expiresAt = now + _approvalsOptions.EffectiveExpiration;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var req = new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = id,
|
||||
CurrentNTE = current,
|
||||
RequestedNTE = requestedNTE,
|
||||
VendorReason = string.IsNullOrWhiteSpace(reason) ? null : reason!.Trim(),
|
||||
Status = "Pending",
|
||||
VendorReason = reason.Trim(),
|
||||
Status = UpliftStatus.Pending,
|
||||
RequiredTier = requiredTier,
|
||||
RequestedByVendorName = session.CompanyName,
|
||||
EvidenceDocumentId = evidence.Id,
|
||||
RequestKey = string.IsNullOrWhiteSpace(requestKey) ? null : requestKey.Trim(),
|
||||
ExpiresAt = expiresAt,
|
||||
NotificationStatus = UpliftNotificationStatus.Pending,
|
||||
CreatedDate = now
|
||||
};
|
||||
await _upliftData.StageAsync(req, cancellationToken);
|
||||
|
|
@ -555,11 +647,14 @@ namespace SeaHaven.Services.Implementation
|
|||
OldValue = $"${current:F2}",
|
||||
NewValue = $"${requestedNTE:F2}",
|
||||
Action = "uplift_requested",
|
||||
ActorType = "vendor",
|
||||
CreatedAt = now
|
||||
}, cancellationToken);
|
||||
|
||||
await _upliftData.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Notification is best-effort and persisted separately from workflow state: a failure
|
||||
// never destroys the actionable request (the lifecycle sweep retries by sentinel).
|
||||
await NotifyDispatcherOfUpliftAsync(dispatch, req, session, cancellationToken);
|
||||
|
||||
return new UpliftRequestResultDTO
|
||||
|
|
@ -568,11 +663,24 @@ namespace SeaHaven.Services.Implementation
|
|||
Status = req.Status,
|
||||
RequiredTier = req.RequiredTier,
|
||||
CurrentNTE = req.CurrentNTE,
|
||||
RequestedNTE = req.RequestedNTE
|
||||
RequestedNTE = req.RequestedNTE,
|
||||
EvidenceDocumentId = req.EvidenceDocumentId,
|
||||
ExpiresAt = req.ExpiresAt,
|
||||
NotificationStatus = req.NotificationStatus
|
||||
};
|
||||
}
|
||||
|
||||
// Cancel alias stays route-compatible; canonical stored status is Withdrawn.
|
||||
public async Task<CancelUpliftResultDTO> CancelUpliftRequestAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await WithdrawInternalAsync(session, id, requestId, "cancel", cancellationToken);
|
||||
return new CancelUpliftResultDTO { Id = result.Id, Status = result.Status };
|
||||
}
|
||||
|
||||
public Task<WithdrawUpliftResultDTO> WithdrawUpliftAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken)
|
||||
=> WithdrawInternalAsync(session, id, requestId, "withdraw", cancellationToken);
|
||||
|
||||
private async Task<WithdrawUpliftResultDTO> WithdrawInternalAsync(VendorPortalSession session, int id, int requestId, string actionSuffix, CancellationToken cancellationToken)
|
||||
{
|
||||
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken);
|
||||
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
|
||||
|
|
@ -580,13 +688,14 @@ namespace SeaHaven.Services.Implementation
|
|||
var req = await _upliftData.GetByIdAndDispatchAsync(requestId, id, cancellationToken);
|
||||
if (req == null) throw new KeyNotFoundException("Uplift request not found");
|
||||
|
||||
if (req.Status != "Pending")
|
||||
if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.Withdrawn))
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot cancel a '{req.Status}' uplift request");
|
||||
throw new InvalidOperationException($"Cannot withdraw a '{UpliftStatus.ToCanonical(req.Status)}' uplift request");
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
req.Status = "Cancelled";
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var previous = UpliftStatus.ToCanonical(req.Status);
|
||||
req.Status = UpliftStatus.Withdrawn;
|
||||
req.DecidedAt = now;
|
||||
req.LastModificationTime = now;
|
||||
|
||||
|
|
@ -594,14 +703,108 @@ namespace SeaHaven.Services.Implementation
|
|||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
|
||||
OldValue = "Pending",
|
||||
NewValue = "Cancelled",
|
||||
Action = "uplift_cancelled",
|
||||
OldValue = previous,
|
||||
NewValue = UpliftStatus.Withdrawn,
|
||||
Action = $"uplift_{actionSuffix}",
|
||||
ActorType = "vendor",
|
||||
CreatedAt = now
|
||||
}, cancellationToken);
|
||||
|
||||
await _upliftData.SaveChangesAsync(cancellationToken);
|
||||
return new CancelUpliftResultDTO { Id = req.Id, Status = req.Status };
|
||||
return new WithdrawUpliftResultDTO { Id = req.Id, Status = UpliftStatus.Withdrawn };
|
||||
}
|
||||
|
||||
// Vendor revise endpoint on ChangesRequested: updates requested amount/reason/evidence,
|
||||
// recomputes tier/expiry, returns Pending, and audits old/new values while retaining the
|
||||
// same row/history.
|
||||
public async Task<ReviseUpliftResultDTO> ReviseUpliftAsync(VendorPortalSession session, int id, int requestId, decimal requestedNTE, string? reason, int? evidenceDocumentId, CancellationToken cancellationToken)
|
||||
{
|
||||
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken);
|
||||
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
|
||||
|
||||
if (requestedNTE <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Requested NTE must be greater than zero");
|
||||
}
|
||||
|
||||
var req = await _upliftData.GetByIdAndDispatchAsync(requestId, id, cancellationToken);
|
||||
if (req == null) throw new KeyNotFoundException("Uplift request not found");
|
||||
|
||||
if (UpliftStatus.ToCanonical(req.Status) != UpliftStatus.ChangesRequested)
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot revise a '{UpliftStatus.ToCanonical(req.Status)}' uplift request");
|
||||
}
|
||||
|
||||
var current = dispatch.NTEAmount ?? 0m;
|
||||
if (requestedNTE <= current)
|
||||
{
|
||||
throw new InvalidOperationException("Requested NTE must be greater than the current NTE");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
{
|
||||
throw new InvalidOperationException("A reason is required when revising an uplift request");
|
||||
}
|
||||
|
||||
if (!evidenceDocumentId.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("Supporting evidence is required when revising an uplift request");
|
||||
}
|
||||
|
||||
var evidence = await _documentData.GetUpliftEvidenceAsync(evidenceDocumentId.Value, id, session.Id, cancellationToken);
|
||||
if (evidence == null)
|
||||
{
|
||||
throw new InvalidOperationException("The selected evidence document is not available or has not passed scanning");
|
||||
}
|
||||
|
||||
var tier1Max = _approvalsOptions.UpliftTier1MaxUsd ?? 2500m;
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var oldRequested = req.RequestedNTE;
|
||||
var oldReason = req.VendorReason;
|
||||
var oldTier = req.RequiredTier;
|
||||
|
||||
req.RequestedNTE = requestedNTE;
|
||||
req.VendorReason = reason.Trim();
|
||||
req.EvidenceDocumentId = evidence.Id;
|
||||
req.CurrentNTE = current;
|
||||
req.RequiredTier = (requestedNTE - current) > tier1Max ? 2 : 1;
|
||||
req.ExpiresAt = now + _approvalsOptions.EffectiveExpiration;
|
||||
req.Status = UpliftStatus.Pending;
|
||||
req.DecisionNote = null;
|
||||
req.DecidedAt = null;
|
||||
req.DecidedByUserId = null;
|
||||
req.NotificationStatus = UpliftNotificationStatus.Pending;
|
||||
req.NotificationError = null;
|
||||
req.InitialNotificationSentAt = null;
|
||||
req.EscalatedAt = null;
|
||||
req.LastModificationTime = now;
|
||||
|
||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
|
||||
OldValue = $"${oldRequested:F2} (Tier {oldTier})",
|
||||
NewValue = $"${requestedNTE:F2} (Tier {req.RequiredTier})",
|
||||
Action = "uplift_revised",
|
||||
ActorType = "vendor",
|
||||
CreatedAt = now
|
||||
}, cancellationToken);
|
||||
|
||||
await _upliftData.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await NotifyDispatcherOfUpliftAsync(dispatch, req, session, cancellationToken);
|
||||
|
||||
return new ReviseUpliftResultDTO
|
||||
{
|
||||
Id = req.Id,
|
||||
Status = req.Status,
|
||||
RequiredTier = req.RequiredTier,
|
||||
CurrentNTE = req.CurrentNTE,
|
||||
RequestedNTE = req.RequestedNTE,
|
||||
EvidenceDocumentId = req.EvidenceDocumentId,
|
||||
ExpiresAt = req.ExpiresAt,
|
||||
NotificationStatus = req.NotificationStatus
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<UploadCompletionDocumentResultDTO> UploadCompletionDocumentAsync(
|
||||
|
|
@ -609,6 +812,7 @@ namespace SeaHaven.Services.Implementation
|
|||
int dispatchId,
|
||||
IFormFile file,
|
||||
int? replacesDocumentId,
|
||||
string? purpose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (file is null || file.Length == 0)
|
||||
|
|
@ -627,6 +831,22 @@ namespace SeaHaven.Services.Implementation
|
|||
throw new InvalidOperationException("Only PDF, JPG, and PNG completion documents are accepted.");
|
||||
}
|
||||
|
||||
var resolvedPurpose = string.IsNullOrWhiteSpace(purpose)
|
||||
? VendorDocumentPurpose.Completion
|
||||
: purpose.Trim();
|
||||
|
||||
if (!VendorDocumentPurpose.IsValid(resolvedPurpose))
|
||||
{
|
||||
throw new InvalidOperationException("The document purpose is not valid.");
|
||||
}
|
||||
|
||||
// Uplift evidence is immutable supporting evidence: it never participates in completion
|
||||
// replacement/version semantics.
|
||||
if (resolvedPurpose == VendorDocumentPurpose.UpliftEvidence && replacesDocumentId.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("Uplift evidence documents cannot replace another document.");
|
||||
}
|
||||
|
||||
using var buffer = new MemoryStream();
|
||||
await file.CopyToAsync(buffer, cancellationToken);
|
||||
var bytes = buffer.ToArray();
|
||||
|
|
@ -679,18 +899,29 @@ namespace SeaHaven.Services.Implementation
|
|||
ReviewStatus = "Processing",
|
||||
Version = version,
|
||||
ReplacesDocumentId = replacedDocument?.Id,
|
||||
Purpose = resolvedPurpose,
|
||||
CreatedDate = now
|
||||
};
|
||||
|
||||
await _documentData.AddAsync(document, cancellationToken);
|
||||
|
||||
var isUpliftEvidence = resolvedPurpose == VendorDocumentPurpose.UpliftEvidence;
|
||||
var fieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document";
|
||||
|
||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
DispatchId = dispatchId,
|
||||
FieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document",
|
||||
OldValue = replacedDocument == null ? null : $"v{replacedDocument.Version}",
|
||||
NewValue = $"v{version}",
|
||||
Action = "vendor_completion_document_uploaded",
|
||||
FieldName = fieldName,
|
||||
OldValue = isUpliftEvidence || replacedDocument == null
|
||||
? null
|
||||
: $"v{replacedDocument.Version}",
|
||||
NewValue = isUpliftEvidence
|
||||
? $"evidence {document.OriginalFileName}"
|
||||
: $"v{version}",
|
||||
Action = isUpliftEvidence
|
||||
? "vendor_uplift_evidence_uploaded"
|
||||
: "vendor_completion_document_uploaded",
|
||||
ActorType = "vendor",
|
||||
CreatedAt = now
|
||||
}, cancellationToken);
|
||||
|
|
@ -704,7 +935,8 @@ namespace SeaHaven.Services.Implementation
|
|||
Id = document.Id,
|
||||
Version = document.Version,
|
||||
ScanStatus = document.ScanStatus,
|
||||
ReviewStatus = document.ReviewStatus
|
||||
ReviewStatus = document.ReviewStatus,
|
||||
Purpose = document.Purpose
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -732,6 +964,26 @@ namespace SeaHaven.Services.Implementation
|
|||
return DownloadCompletionDocumentResultDTO.Ok(content, document.ContentType, document.OriginalFileName);
|
||||
}
|
||||
|
||||
public async Task<VendorDocumentStatusDTO?> GetDocumentStatusAsync(
|
||||
VendorPortalSession session, int dispatchId, int documentId, CancellationToken cancellationToken)
|
||||
{
|
||||
var document = await _documentData.GetMetadataForVendorDispatchAsync(
|
||||
documentId, dispatchId, session.Id, cancellationToken);
|
||||
if (document == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new VendorDocumentStatusDTO
|
||||
{
|
||||
Id = document.Id,
|
||||
OriginalFileName = document.OriginalFileName,
|
||||
ScanStatus = document.ScanStatus,
|
||||
ReviewStatus = document.ReviewStatus,
|
||||
Purpose = document.Purpose
|
||||
};
|
||||
}
|
||||
|
||||
private static bool MatchesSignature(string contentType, byte[] bytes)
|
||||
{
|
||||
if (bytes.Length == 0)
|
||||
|
|
@ -769,46 +1021,69 @@ namespace SeaHaven.Services.Implementation
|
|||
|
||||
private async Task NotifyDispatcherOfUpliftAsync(Dispatch dispatch, DispatchUpliftRequest req, VendorPortalSession session, CancellationToken cancellationToken)
|
||||
{
|
||||
var recipients = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var dispatcherUserId = await _dispatchData.GetDispatchDispatcherUserIdAsync(dispatch.WorkOrderId ?? 0, cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(dispatcherUserId))
|
||||
{
|
||||
var dispatcherEmail = await _userData.GetEmailByIdAsync(dispatcherUserId, cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(dispatcherEmail))
|
||||
recipients.Add(dispatcherEmail);
|
||||
}
|
||||
|
||||
// Tier recipients come from configured Approvals options; dedupe against the dispatcher.
|
||||
var tierRecipients = req.RequiredTier == 2
|
||||
? _approvalsOptions.Tier2NotificationRecipients
|
||||
: _approvalsOptions.Tier1NotificationRecipients;
|
||||
if (tierRecipients != null)
|
||||
{
|
||||
foreach (var recipient in tierRecipients)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(recipient))
|
||||
recipients.Add(recipient.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (recipients.Count == 0)
|
||||
{
|
||||
// No one to notify: record a safe persisted signal without leaking internal detail.
|
||||
req.NotificationStatus = UpliftNotificationStatus.Error;
|
||||
req.NotificationError = "No notification recipients are configured for this dispatch.";
|
||||
req.LastModificationTime = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
await _upliftData.SaveChangesAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var frontendBase = _frontendOptions.FrontendBaseUrl ?? string.Empty;
|
||||
var (subject, body) = UpliftNotificationMessage.BuildInitial(dispatch, req, session.CompanyName ?? "Vendor", frontendBase);
|
||||
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
try
|
||||
{
|
||||
var dispatcherUserId = await _dispatchData.GetDispatchDispatcherUserIdAsync(dispatch.WorkOrderId ?? 0, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(dispatcherUserId)) return;
|
||||
foreach (var recipient in recipients)
|
||||
{
|
||||
var delivered = await _emailSender.SendEmailAsync(recipient, subject, body);
|
||||
if (!delivered)
|
||||
throw new InvalidOperationException("The notification provider reported a delivery failure.");
|
||||
}
|
||||
|
||||
var dispatcherEmail = await _userData.GetEmailByIdAsync(dispatcherUserId, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(dispatcherEmail)) return;
|
||||
|
||||
var frontendBase = _frontendOptions.FrontendBaseUrl?.TrimEnd('/') ?? "";
|
||||
var workOrderLink = dispatch.WorkOrderId.HasValue
|
||||
? $"{frontendBase}/workorders/{dispatch.WorkOrderId.Value}"
|
||||
: frontendBase;
|
||||
|
||||
var vendorName = System.Net.WebUtility.HtmlEncode(session.CompanyName ?? "Vendor");
|
||||
var reason = System.Net.WebUtility.HtmlEncode(req.VendorReason ?? "(no reason provided)");
|
||||
var dispatchNum = System.Net.WebUtility.HtmlEncode(dispatch.DispatchNumber ?? "");
|
||||
var tierText = req.RequiredTier == 2 ? "Tier 2 (Manager approval required)" : "Tier 1";
|
||||
|
||||
var subject = $"[Uplift Request] {dispatch.DispatchNumber} — {session.CompanyName} requests ${req.RequestedNTE:F2} (was ${req.CurrentNTE ?? 0m:F2})";
|
||||
var body = $@"
|
||||
<h2>Vendor Uplift Request</h2>
|
||||
<p><strong>Dispatch:</strong> {dispatchNum}</p>
|
||||
<p><strong>Vendor:</strong> {vendorName}</p>
|
||||
<table style='border-collapse:collapse;font-family:Arial,sans-serif;'>
|
||||
<tr><td style='padding:4px 10px;'><strong>Current NTE</strong></td><td style='padding:4px 10px;'>${req.CurrentNTE ?? 0m:F2}</td></tr>
|
||||
<tr><td style='padding:4px 10px;'><strong>Requested NTE</strong></td><td style='padding:4px 10px;'>${req.RequestedNTE:F2}</td></tr>
|
||||
<tr><td style='padding:4px 10px;'><strong>Delta</strong></td><td style='padding:4px 10px;'>${(req.RequestedNTE - (req.CurrentNTE ?? 0m)):F2}</td></tr>
|
||||
<tr><td style='padding:4px 10px;'><strong>Required Approval</strong></td><td style='padding:4px 10px;'>{tierText}</td></tr>
|
||||
</table>
|
||||
<h3>Vendor Reason</h3>
|
||||
<p>{reason}</p>
|
||||
<div style='margin:20px 0;'>
|
||||
<a href='{workOrderLink}' style='display:inline-block;padding:10px 22px;background:#2563eb;color:white;text-decoration:none;border-radius:6px;font-weight:bold;'>Review in SHOC</a>
|
||||
</div>";
|
||||
|
||||
await _emailSender.SendEmailAsync(dispatcherEmail, subject, body);
|
||||
req.InitialNotificationSentAt = now;
|
||||
req.NotificationStatus = UpliftNotificationStatus.Sent;
|
||||
req.NotificationError = null;
|
||||
}
|
||||
catch
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Persist a safe signal; never store exception text or addresses.
|
||||
req.NotificationStatus = UpliftNotificationStatus.Error;
|
||||
req.NotificationError = "The uplift notification could not be delivered.";
|
||||
}
|
||||
|
||||
req.LastModificationTime = now;
|
||||
await _upliftData.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static bool IsAllowedVendorTransition(string? from, string? to)
|
||||
|
|
@ -819,6 +1094,6 @@ namespace SeaHaven.Services.Implementation
|
|||
}
|
||||
|
||||
private static bool IsPortalLockedStatus(string? status)
|
||||
=> status == "Verified" || status == "Cancelled" || status == "Refused";
|
||||
=> status is "Verified" or "Cancelled" or "Canceled" or "Refused";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
SeaHaven.Services/Interfaces/IUpliftLifecycleService.cs
Normal file
13
SeaHaven.Services/Interfaces/IUpliftLifecycleService.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// Uplift lifecycle processing (SH-101). Owns expiry, initial-notification backstop,
|
||||
/// and once-only escalation. Scoped; invoked by the hosted sweep.
|
||||
/// </summary>
|
||||
public interface IUpliftLifecycleService
|
||||
{
|
||||
Task<int> ExpireDueAsync(CancellationToken cancellationToken);
|
||||
Task<int> SendDueInitialNotificationsAsync(CancellationToken cancellationToken);
|
||||
Task<int> EscalateDueAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,12 @@ namespace SeaHaven.Services.Interfaces
|
|||
Task<IEnumerable<UpliftForDispatchDTO>> ListForDispatchAsync(ClaimsPrincipal user, int dispatchId, CancellationToken cancellationToken);
|
||||
Task<UpliftApproveResultDTO> ApproveAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken);
|
||||
Task<UpliftDenyResultDTO> DenyAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken);
|
||||
// SH-101: canonical reject (alias of deny; writes Rejected).
|
||||
Task<UpliftDecisionResultDTO> RejectAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken);
|
||||
// SH-101: internal request-changes route (note + tier authorization + audit).
|
||||
Task<UpliftDecisionResultDTO> RequestChangesAsync(ClaimsPrincipal user, int id, string note, CancellationToken cancellationToken);
|
||||
// SH-101: authorized internal download of a Passed UpliftEvidence file linked to an uplift request.
|
||||
Task<UpliftEvidenceDownloadResultDTO> GetEvidenceForDownloadAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken);
|
||||
bool CanApprove(ClaimsPrincipal user, int tier);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,9 +14,12 @@ namespace SeaHaven.Services.Interfaces
|
|||
Task<ChecklistItemResultDTO> UpdateChecklistItemAsync(VendorPortalSession session, int id, int itemId, bool isCompleted, CancellationToken cancellationToken);
|
||||
Task<SignoffResultDTO> AddSignoffAsync(VendorPortalSession session, int id, string? signoffType, string? name, string? signature, string? signatureMethod, CancellationToken cancellationToken);
|
||||
Task<CommentResultDTO> AddCommentAsync(VendorPortalSession session, int id, string? commentText, CancellationToken cancellationToken);
|
||||
Task<UpliftRequestResultDTO> RequestUpliftAsync(VendorPortalSession session, int id, decimal requestedNTE, string? reason, CancellationToken cancellationToken);
|
||||
Task<UpliftRequestResultDTO> RequestUpliftAsync(VendorPortalSession session, int id, decimal requestedNTE, string? reason, string? requestKey, int? evidenceDocumentId, CancellationToken cancellationToken);
|
||||
Task<CancelUpliftResultDTO> CancelUpliftRequestAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken);
|
||||
Task<UploadCompletionDocumentResultDTO> UploadCompletionDocumentAsync(VendorPortalSession session, int dispatchId, Microsoft.AspNetCore.Http.IFormFile file, int? replacesDocumentId, CancellationToken cancellationToken);
|
||||
Task<WithdrawUpliftResultDTO> WithdrawUpliftAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken);
|
||||
Task<ReviseUpliftResultDTO> ReviseUpliftAsync(VendorPortalSession session, int id, int requestId, decimal requestedNTE, string? reason, int? evidenceDocumentId, CancellationToken cancellationToken);
|
||||
Task<UploadCompletionDocumentResultDTO> UploadCompletionDocumentAsync(VendorPortalSession session, int dispatchId, Microsoft.AspNetCore.Http.IFormFile file, int? replacesDocumentId, string? purpose, CancellationToken cancellationToken);
|
||||
Task<VendorDocumentStatusDTO?> GetDocumentStatusAsync(VendorPortalSession session, int dispatchId, int documentId, CancellationToken cancellationToken);
|
||||
Task<DownloadCompletionDocumentResultDTO> DownloadCompletionDocumentAsync(VendorPortalSession session, int dispatchId, int documentId, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue