This commit is contained in:
Alexandre Brandizzi 2026-09-17 06:56:25 +00:00 • committed by GitHub
commit 88e4469c9d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1228 additions and 4 deletions

View file

@ -52,6 +52,70 @@ public class UpliftControllerTests
envelope.Status.Should().Be("Success");
}
[Fact]
public async Task List_PassesStatusAndTierFiltersToService()
{
var service = new Mock<IUpliftService>();
service.Setup(x => x.ListAsync(It.IsAny<ClaimsPrincipal>(), "Approved", 2, 3, 50, It.IsAny<CancellationToken>()))
.ReturnsAsync(new UpliftListResultDTO());
var controller = NewController(service);
await controller.List("Approved", 2, 3, 50);
service.Verify(
x => x.ListAsync(It.IsAny<ClaimsPrincipal>(), "Approved", 2, 3, 50, It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task List_ReturnsQueueContractFieldsInEnvelope()
{
var service = new Mock<IUpliftService>();
var item = new UpliftListItemDTO
{
Id = 7,
WorkOrderId = 11,
WorkOrderNumber = "WO-77",
WorkOrderSite = "SITE-EAST",
WorkOrderService = "HVAC",
WorkOrderClosed = true,
AttachmentCount = 2,
RequestedByName = "Gateway",
DecidedByName = "Grace Hopper",
WorkOrderAutoApprovedTotal = 150m,
WorkOrderAdminApprovedTotal = 300m,
WorkOrderApprovedExposureTotal = 450m
};
service.Setup(x => x.ListAsync(It.IsAny<ClaimsPrincipal>(), It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new UpliftListResultDTO
{
Total = 1,
Page = 1,
PageSize = 25,
PendingExposureTotal = 200m,
Items = new[] { item }
});
var controller = NewController(service);
var result = await controller.List();
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
var data = envelope.Data as UpliftListResultDTO;
var returned = data!.Items.Should().ContainSingle().Subject;
returned.WorkOrderNumber.Should().Be("WO-77");
returned.WorkOrderSite.Should().Be("SITE-EAST");
returned.WorkOrderService.Should().Be("HVAC");
returned.WorkOrderClosed.Should().BeTrue();
returned.AttachmentCount.Should().Be(2);
returned.RequestedByName.Should().Be("Gateway");
returned.DecidedByName.Should().Be("Grace Hopper");
returned.WorkOrderApprovedExposureTotal.Should().Be(450m);
data!.PendingExposureTotal.Should().Be(200m);
}
[Fact]
public async Task Approve_NotFound_Returns404()
{
@ -67,6 +131,49 @@ public class UpliftControllerTests
nf.Value.Should().BeOfType<Response>();
}
[Fact]
public async Task Revoke_WithReason_DelegatesToService()
{
var service = new Mock<IUpliftService>();
service.Setup(x => x.RevokeAsync(
It.IsAny<ClaimsPrincipal>(),
7,
"Policy change",
It.IsAny<CancellationToken>()))
.ReturnsAsync(new UpliftDecisionResultDTO { Id = 7, Status = "Revoked" });
var controller = NewController(service, "Admin");
var result = await controller.Revoke(
7,
new UpliftController.DecisionRequest { Note = "Policy change" });
result.Should().BeOfType<OkObjectResult>();
service.Verify(x => x.RevokeAsync(
It.IsAny<ClaimsPrincipal>(),
7,
"Policy change",
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Revoke_WithoutReason_ReturnsBadRequest()
{
var service = new Mock<IUpliftService>();
service.Setup(x => x.RevokeAsync(
It.IsAny<ClaimsPrincipal>(),
7,
string.Empty,
It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("reason required"));
var controller = NewController(service, "Admin");
var result = await controller.Revoke(7, null);
result.Should().BeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task Approve_Forbidden_ReturnsSanitized403AndLogsInternally()
{

View file

@ -0,0 +1,642 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Moq;
using SeaHaven.DataServices.Implementation;
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;
// Behavior tests for the approval queue read contract. These exercise the real
// service + data-service layers against an in-memory DbContext so that queue
// ordering, filters, flattened work-order fields, exposure aggregation, and
// per-tier read authorization are validated through the public contract.
public sealed class UpliftQueueReadTests
{
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 UpliftService NewService(ApplicationDbContext context) =>
new(new UpliftDataService(context),
new DispatchDataService(context),
Mock.Of<IVendorDocumentStoragePort>(),
TimeProvider.System,
Microsoft.Extensions.Options.Options.Create(NewOptions()));
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 static DispatchUpliftRequest Request(
Dispatch dispatch,
string status,
DateTime created,
DateTime? decided = null,
int tier = 1,
decimal requested = 100m,
string? reason = null,
string? requestedBy = null,
string? createdBy = null,
decimal? currentNte = null) => new()
{
DispatchId = dispatch.Id,
Status = status,
CreatedDate = created,
DecidedAt = decided,
RequiredTier = tier,
RequestedNTE = requested,
CurrentNTE = currentNte,
VendorReason = reason,
RequestedByVendorName = requestedBy,
createdby = createdBy,
NotificationStatus = "Pending"
};
private static async Task<(Vendor Vendor, WorkOrder WorkOrder)> SeedWorkOrderAsync(
ApplicationDbContext context,
string number,
string site,
string service,
string vendorName = "Gateway")
{
var vendor = new Vendor { CompanyName = vendorName, IsActive = true };
var workOrder = new WorkOrder
{
WorkerOrderNumber = number,
SiteCode = site,
Service = service,
WorkerOrderTitle = "Repair"
};
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
return (vendor, workOrder);
}
private static async Task<Dispatch> SeedDispatchAsync(
ApplicationDbContext context, Vendor vendor, WorkOrder workOrder, string number)
{
var dispatch = new Dispatch
{
VendorId = vendor.Id,
WorkOrderId = workOrder.Id,
DispatchNumber = number,
Status = "Completed",
NTEAmount = 1000m
};
context.Dispatches.Add(dispatch);
await context.SaveChangesAsync();
return dispatch;
}
// --- Ordering ---
[Fact]
public async Task List_PendingStatus_OrdersOldestRequestFirst()
{
using var context = NewContext();
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
context.DispatchUpliftRequests.AddRange(
Request(dispatch, "Pending", new DateTime(2026, 3, 3)),
Request(dispatch, "Pending", new DateTime(2026, 3, 1)),
Request(dispatch, "Pending", new DateTime(2026, 3, 2)));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
result.Items.Select(i => i.RequestedAt).Should().Equal(
new DateTime(2026, 3, 1),
new DateTime(2026, 3, 2),
new DateTime(2026, 3, 3));
}
[Fact]
public async Task List_ApprovedStatus_OrdersMostRecentlyDecidedFirst()
{
using var context = NewContext();
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
context.DispatchUpliftRequests.AddRange(
Request(dispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 10)),
Request(dispatch, "Approved", new DateTime(2026, 3, 2), decided: new DateTime(2026, 3, 20)),
Request(dispatch, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 15)));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None);
result.Items.Select(i => i.DecidedAt).Should().Equal(
new DateTime(2026, 3, 20),
new DateTime(2026, 3, 15),
new DateTime(2026, 3, 10));
}
[Fact]
public async Task List_WithoutStatusFilter_KeepsHistoricalNewestRequestFirstOrder()
{
using var context = NewContext();
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
context.DispatchUpliftRequests.AddRange(
Request(dispatch, "Rejected", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)),
Request(dispatch, "Approved", new DateTime(2026, 3, 10), decided: new DateTime(2026, 3, 11)));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), null, null, 1, 25, CancellationToken.None);
result.Items.Select(i => i.RequestedAt).Should().ContainInOrder(
new DateTime(2026, 3, 10),
new DateTime(2026, 3, 1));
}
// --- Filters and scoping ---
[Fact]
public async Task List_StatusAndTierFilters_Combine()
{
using var context = NewContext();
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
context.DispatchUpliftRequests.AddRange(
Request(dispatch, "Pending", new DateTime(2026, 3, 1), tier: 1),
Request(dispatch, "Pending", new DateTime(2026, 3, 2), tier: 2),
Request(dispatch, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), tier: 2));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Manager"), "Pending", 2, 1, 25, CancellationToken.None);
result.Total.Should().Be(1);
result.Items.Should().ContainSingle(i => i.RequiredTier == 2 && i.Status == "Pending");
}
[Fact]
public async Task List_ExcludesDeletedRequestsAndRequestsOnDeletedDispatches()
{
using var context = NewContext();
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
var deletedDispatch = new Dispatch
{
VendorId = vendor.Id,
WorkOrderId = workOrder.Id,
DispatchNumber = "DIS-DELETED",
Status = "Completed",
IsDeleted = true
};
context.Dispatches.Add(deletedDispatch);
await context.SaveChangesAsync();
context.DispatchUpliftRequests.AddRange(
Request(dispatch, "Pending", new DateTime(2026, 3, 1)),
new DispatchUpliftRequest
{
DispatchId = dispatch.Id,
Status = "Pending",
CreatedDate = new DateTime(2026, 3, 2),
IsDeleted = true,
RequiredTier = 1,
RequestedNTE = 100m
},
Request(deletedDispatch, "Pending", new DateTime(2026, 3, 3)));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
result.Total.Should().Be(1);
result.Items.Should().ContainSingle(i => i.RequestedAt == new DateTime(2026, 3, 1));
}
// --- Flattened queue fields ---
[Fact]
public async Task List_MapsFlattenedWorkOrderAndRequesterFields()
{
using var context = NewContext();
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC");
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" });
await context.SaveChangesAsync();
context.DispatchUpliftRequests.Add(Request(
dispatch, "Pending", new DateTime(2026, 3, 2),
tier: 2, requested: 400m, reason: "Scope grew",
requestedBy: "Gateway", createdBy: "user-7", currentNte: 100m));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Manager"), "Pending", null, 1, 25, CancellationToken.None);
var item = result.Items.Should().ContainSingle().Subject;
item.WorkOrderId.Should().Be(workOrder.Id);
item.WorkOrderNumber.Should().Be("WO-77");
item.WorkOrderSite.Should().Be("SITE-EAST");
item.WorkOrderService.Should().Be("HVAC");
item.VendorCompanyName.Should().Be("Gateway");
item.RequestedByName.Should().Be("Gateway");
item.RequestedAt.Should().Be(new DateTime(2026, 3, 2));
item.VendorReason.Should().Be("Scope grew");
item.Delta.Should().Be(300m);
item.Status.Should().Be("Pending");
}
[Fact]
public async Task List_ResolvesWorkOrderContext_ForDispatchLinkedOnlyThroughDispatchWorkOrders()
{
// A dispatch can carry no primary WorkOrderId yet be linked to a work order
// through DispatchWorkOrders (the supported shape exercised by
// Exposure_IncludesLinkedDispatchesViaServerDerivedWorkOrderLinks). The queue
// read must resolve the same effective work order the sibling reads use, or the
// Approved tab surfaces blank WO context, workOrderClosed:false, and zero
// exposure on a terminal work order.
using var context = NewContext();
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-LINKED", "SITE-Z", "Electrical");
workOrder.LifecycleStatus = LifecycleStatus.Completed;
var linkedDispatch = new Dispatch
{
VendorId = vendor.Id,
DispatchNumber = "DIS-LINKED",
Status = "Completed"
};
context.Dispatches.Add(linkedDispatch);
await context.SaveChangesAsync();
context.DispatchWorkOrders.Add(new DispatchWorkOrder
{
DispatchId = linkedDispatch.Id,
WorkOrderId = workOrder.Id
});
context.DispatchUpliftRequests.Add(Request(
linkedDispatch, "Approved", new DateTime(2026, 3, 1),
decided: new DateTime(2026, 3, 2), requested: 90m));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None);
var item = result.Items.Should().ContainSingle().Subject;
item.WorkOrderId.Should().Be(workOrder.Id);
item.WorkOrderNumber.Should().Be("WO-LINKED");
item.WorkOrderSite.Should().Be("SITE-Z");
item.WorkOrderService.Should().Be("Electrical");
item.WorkOrderClosed.Should().BeTrue();
item.WorkOrderApprovedExposureTotal.Should().Be(90m);
}
[Fact]
public async Task List_RequesterLabelFallsBackToCreatingUserName()
{
using var context = NewContext();
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC");
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" });
await context.SaveChangesAsync();
context.DispatchUpliftRequests.Add(Request(
dispatch, "Pending", new DateTime(2026, 3, 2),
requestedBy: null, createdBy: "user-7"));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
result.Items.Single().RequestedByName.Should().Be("Ada Smith");
}
[Fact]
public async Task List_MapsEvidenceAttachmentSummary()
{
using var context = NewContext();
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC");
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
var evidence = new VendorCompletionDocument
{
VendorId = vendor.Id,
DispatchId = dispatch.Id,
WorkOrderId = workOrder.Id,
OriginalFileName = "quote.pdf",
StoredFileName = "evidence.bin",
ContentType = "application/pdf",
SizeBytes = 512,
ScanStatus = "Passed",
ReviewStatus = "Approved",
Purpose = "UpliftEvidence",
Version = 1
};
context.VendorCompletionDocuments.Add(evidence);
await context.SaveChangesAsync();
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
DispatchId = dispatch.Id,
Status = "Pending",
CreatedDate = new DateTime(2026, 3, 2),
RequiredTier = 1,
RequestedNTE = 200m,
EvidenceDocumentId = evidence.Id
});
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
var item = result.Items.Single();
item.EvidenceDocumentId.Should().Be(evidence.Id);
item.EvidenceFileName.Should().Be("quote.pdf");
item.EvidenceContentType.Should().Be("application/pdf");
item.EvidenceSizeBytes.Should().Be(512);
}
// --- Approved-on-WO exposure totals ---
[Fact]
public async Task List_AggregatesApprovedExposurePerWorkOrder_ExcludingNonApprovedStatuses()
{
using var context = NewContext();
var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC", "Gateway-A");
var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "Plumbing", "Gateway-B");
var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A");
var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B");
context.DispatchUpliftRequests.AddRange(
Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 300m),
new DispatchUpliftRequest
{
DispatchId = dispatchA.Id,
Status = "NoApprovalRequired",
CreatedDate = new DateTime(2026, 3, 1),
RequiredTier = 0,
RequestedNTE = 150m
},
Request(dispatchA, "Pending", new DateTime(2026, 3, 1), requested: 999m),
Request(dispatchA, "Rejected", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 999m),
new DispatchUpliftRequest
{
DispatchId = dispatchA.Id,
Status = "Withdrawn",
CreatedDate = new DateTime(2026, 3, 1),
DecidedAt = new DateTime(2026, 3, 2),
RequiredTier = 1,
RequestedNTE = 999m
},
new DispatchUpliftRequest
{
DispatchId = dispatchA.Id,
Status = "Revoked",
CreatedDate = new DateTime(2026, 3, 1),
DecidedAt = new DateTime(2026, 3, 2),
RequiredTier = 1,
RequestedNTE = 999m
},
new DispatchUpliftRequest
{
DispatchId = dispatchA.Id,
Status = "Expired",
CreatedDate = new DateTime(2026, 3, 1),
RequiredTier = 1,
RequestedNTE = 999m
},
Request(dispatchB, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 75m));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None);
var itemA = result.Items.Single(i => i.WorkOrderNumber == "WO-A");
itemA.WorkOrderAutoApprovedTotal.Should().Be(150m);
itemA.WorkOrderAdminApprovedTotal.Should().Be(300m);
itemA.WorkOrderApprovedExposureTotal.Should().Be(450m);
var itemB = result.Items.Single(i => i.WorkOrderNumber == "WO-B");
itemB.WorkOrderAutoApprovedTotal.Should().Be(0m);
itemB.WorkOrderAdminApprovedTotal.Should().Be(75m);
itemB.WorkOrderApprovedExposureTotal.Should().Be(75m);
}
[Fact]
public async Task Exposure_IncludesLinkedDispatchesViaServerDerivedWorkOrderLinks()
{
using var context = NewContext();
var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A");
var linkedDispatch = new Dispatch
{
VendorId = vendorA.Id,
DispatchNumber = "DIS-LINKED",
Status = "Completed"
};
context.Dispatches.Add(linkedDispatch);
await context.SaveChangesAsync();
context.DispatchWorkOrders.Add(new DispatchWorkOrder
{
DispatchId = linkedDispatch.Id,
WorkOrderId = workOrderA.Id
});
await context.SaveChangesAsync();
context.DispatchUpliftRequests.Add(Request(
linkedDispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 60m));
await context.SaveChangesAsync();
var data = new UpliftDataService(context);
var exposure = await data.GetApprovedExposureForWorkOrdersAsync(
new[] { workOrderA.Id }, CancellationToken.None);
var total = exposure.Should().ContainSingle(e => e.WorkOrderId == workOrderA.Id).Subject;
total.AdminApprovedTotal.Should().Be(60m);
total.AutoApprovedTotal.Should().Be(0m);
}
// --- Read authorization (tier roles) ---
[Fact]
public async Task List_CanDecide_ReflectsTierRolesForTheCaller()
{
using var context = NewContext();
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC");
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
context.DispatchUpliftRequests.AddRange(
Request(dispatch, "Pending", new DateTime(2026, 3, 1), tier: 1),
Request(dispatch, "Pending", new DateTime(2026, 3, 2), tier: 2));
await context.SaveChangesAsync();
var service = NewService(context);
var tier1Only = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
var tier2User = await service.ListAsync(UserWithRoles("Manager"), "Pending", null, 1, 25, CancellationToken.None);
tier1Only.Items.Single(i => i.RequiredTier == 1).CanDecide.Should().BeTrue();
tier1Only.Items.Single(i => i.RequiredTier == 2).CanDecide.Should().BeFalse();
tier2User.Items.Single(i => i.RequiredTier == 2).CanDecide.Should().BeTrue();
tier2User.Items.Single(i => i.RequiredTier == 1).CanDecide.Should().BeFalse();
}
// --- Closed-work-order flag and decider (SH-208) ---
[Fact]
public async Task List_MarksWorkOrderClosedOnlyForTerminalLifecycleStatuses()
{
using var context = NewContext();
var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-OPEN", "SITE-A", "HVAC");
var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-DONE", "SITE-B", "HVAC");
var (vendorC, workOrderC) = await SeedWorkOrderAsync(context, "WO-CANCEL", "SITE-C", "HVAC");
workOrderA.LifecycleStatus = LifecycleStatus.InProgress;
workOrderB.LifecycleStatus = LifecycleStatus.Completed;
workOrderC.LifecycleStatus = LifecycleStatus.Canceled;
await context.SaveChangesAsync();
var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A");
var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B");
var dispatchC = await SeedDispatchAsync(context, vendorC, workOrderC, "DIS-C");
context.DispatchUpliftRequests.AddRange(
Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)),
Request(dispatchB, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)),
Request(dispatchC, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None);
result.Items.Single(i => i.WorkOrderNumber == "WO-OPEN").WorkOrderClosed.Should().BeFalse();
result.Items.Single(i => i.WorkOrderNumber == "WO-DONE").WorkOrderClosed.Should().BeTrue();
result.Items.Single(i => i.WorkOrderNumber == "WO-CANCEL").WorkOrderClosed.Should().BeTrue();
}
[Fact]
public async Task List_MapsDecidedByNameFromTheDecidingUser()
{
using var context = NewContext();
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC");
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
context.Users.Add(new ApplicationUser { Id = "user-9", FirstName = "Grace", LastName = "Hopper" });
await context.SaveChangesAsync();
var request = Request(dispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2));
request.DecidedByUserId = "user-9";
context.DispatchUpliftRequests.Add(request);
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None);
result.Items.Single().DecidedByName.Should().Be("Grace Hopper");
}
// --- Attachment count (SH-207) ---
[Fact]
public async Task List_CountsOnlyNonDeletedUpliftEvidenceAttachments()
{
using var context = NewContext();
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC");
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
context.VendorCompletionDocuments.AddRange(
Document(dispatch, vendor, workOrder, "quote.pdf"),
Document(dispatch, vendor, workOrder, "photo.jpg"),
Document(dispatch, vendor, workOrder, "signed-completion.pdf", purpose: "Completion"),
Document(dispatch, vendor, workOrder, "deleted-estimate.pdf", isDeleted: true));
await context.SaveChangesAsync();
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
DispatchId = dispatch.Id,
Status = "Pending",
CreatedDate = new DateTime(2026, 3, 2),
RequiredTier = 1,
RequestedNTE = 200m
});
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
result.Items.Single().AttachmentCount.Should().Be(2);
}
private static VendorCompletionDocument Document(
Dispatch dispatch,
Vendor vendor,
WorkOrder workOrder,
string fileName,
string purpose = "UpliftEvidence",
bool isDeleted = false) => new()
{
VendorId = vendor.Id,
DispatchId = dispatch.Id,
WorkOrderId = workOrder.Id,
OriginalFileName = fileName,
StoredFileName = $"{fileName}.bin",
ContentType = "application/octet-stream",
SizeBytes = 128,
ScanStatus = "Passed",
ReviewStatus = "Approved",
Purpose = purpose,
Version = 1,
IsDeleted = isDeleted
};
// --- Queue-wide pending exposure (SH-207) ---
[Fact]
public async Task List_PendingExposureTotal_SumsPendingRequestsAcrossTheQueueRegardlessOfFilter()
{
using var context = NewContext();
var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "HVAC");
var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A");
var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B");
context.DispatchUpliftRequests.AddRange(
Request(dispatchA, "Pending", new DateTime(2026, 3, 1), requested: 125m),
Request(dispatchB, "Pending", new DateTime(2026, 3, 2), requested: 75m),
Request(dispatchA, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), requested: 999m),
new DispatchUpliftRequest
{
DispatchId = dispatchA.Id,
Status = "Pending",
CreatedDate = new DateTime(2026, 3, 5),
RequiredTier = 1,
RequestedNTE = 500m,
IsDeleted = true
});
await context.SaveChangesAsync();
var service = NewService(context);
// Even when the caller reads the Approved decision log, the header total
// still reflects the whole pending queue.
var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None);
result.PendingExposureTotal.Should().Be(200m);
}
}

View file

@ -5,6 +5,7 @@ using Microsoft.Extensions.Options;
using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
@ -13,6 +14,58 @@ namespace Api.SeaHavenIndustries.Tests;
public sealed class UpliftServiceRefusedTests
{
[Fact]
public async Task RevokeAsync_DelegatesApprovedAdminRevocationToWorkOrderFlow()
{
var request = new DispatchUpliftRequest
{
Id = 7,
DispatchId = 42,
Status = "Approved",
RequiredTier = 1,
RequestedNTE = 900m
};
var upliftData = new Mock<IUpliftDataService>();
upliftData.Setup(data => data.GetByIdAsync(7, It.IsAny<CancellationToken>()))
.ReturnsAsync(request);
upliftData.Setup(data => data.GetWorkOrderIdForUpliftAsync(7, It.IsAny<CancellationToken>()))
.ReturnsAsync(77);
var workOrderFlow = new Mock<IWorkOrderUpliftService>();
workOrderFlow.Setup(flow => flow.RevokeAsync(
77,
7,
It.Is<RevokeWorkOrderUpliftRequestDto>(r => r.Reason == "Policy change"),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new WorkOrderUpliftDto { Id = 7, Status = "revoked" });
var service = new UpliftService(
upliftData.Object,
Mock.Of<IDispatchDataService>(),
Mock.Of<IVendorDocumentStoragePort>(),
TimeProvider.System,
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()),
workOrderFlow.Object);
var user = new ClaimsPrincipal(new ClaimsIdentity(
new[]
{
new Claim(ClaimTypes.NameIdentifier, "admin-1"),
new Claim(ClaimTypes.Role, "Admin")
},
"test"));
var result = await service.RevokeAsync(user, 7, " Policy change ", CancellationToken.None);
result.Id.Should().Be(7);
result.Status.Should().Be("Revoked");
workOrderFlow.Verify(flow => flow.RevokeAsync(
77,
7,
It.Is<RevokeWorkOrderUpliftRequestDto>(r => r.Reason == "Policy change"),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ApproveAsync_RefusedDispatch_RejectsWithoutChangingNteOrRequest()
{

View file

@ -127,6 +127,35 @@ namespace Api.SeaHavenIndustries.Controllers
}
}
[HttpPost("{id:int}/revoke")]
public async Task<IActionResult> Revoke(
int id,
[FromBody] DecisionRequest? body,
CancellationToken cancellationToken = default)
{
try
{
var result = await _upliftService.RevokeAsync(
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 revoke this uplift") });
}
catch (InvalidOperationException ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "This uplift request cannot be revoked") });
}
}
[HttpGet("can-approve")]
public IActionResult CanApprove([FromQuery] int tier)
{

View file

@ -81,6 +81,17 @@ namespace Data.SeaHavenIndustries
public string? PONumber { get; set; }
public int? WorkOrderId { get; set; }
public string? VendorCompanyName { get; set; }
// Approval queue read contract: flattened work-order context.
public string? WorkOrderNumber { get; set; }
public string? WorkOrderSiteCode { get; set; }
public string? WorkOrderService { get; set; }
public bool WorkOrderClosed { get; set; }
public int AttachmentCount { get; set; }
public string? RequestedByVendorName { get; set; }
public string? RequestedByFirstName { get; set; }
public string? RequestedByLastName { get; set; }
public string? DecidedByFirstName { get; set; }
public string? DecidedByLastName { get; set; }
public decimal? CurrentNTE { get; set; }
public decimal RequestedNTE { get; set; }
public string? VendorReason { get; set; }
@ -130,6 +141,16 @@ namespace Data.SeaHavenIndustries
public bool EvidenceScanPassed { get; set; }
}
// Approval queue read contract: per-work-order exposure totals. Approved exposure is
// the sum of uplift amounts that were auto-approved or admin-approved on the work
// order; pending, rejected, cancelled/withdrawn, and revoked requests are excluded.
public class WorkOrderUpliftExposureData
{
public int WorkOrderId { get; set; }
public decimal AutoApprovedTotal { get; set; }
public decimal AdminApprovedTotal { 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

View file

@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
@ -24,8 +25,24 @@ namespace SeaHaven.DataServices.Implementation
from v in vendors.DefaultIfEmpty()
join ev in _context.VendorCompletionDocuments on u.EvidenceDocumentId equals ev.Id into evidences
from ev in evidences.DefaultIfEmpty()
// SH-207: resolve the work order through the same primary-plus-linked
// convention the sibling reads use (GetWorkOrderIdForUpliftAsync,
// GetApprovedExposureForWorkOrdersAsync). A dispatch whose work order is
// linked only through DispatchWorkOrders still surfaces its work-order id,
// flattened WO context, the closed flag, and approved exposure. The
// effective id feeds a single work-order lookup so every field resolves
// from one row.
let effectiveWorkOrderId = d.WorkOrderId
?? d.DispatchWorkOrders!.Select(link => (int?)link.WorkOrderId).FirstOrDefault()
let workOrder = _context.workOrders
.FirstOrDefault(candidate => candidate.Id == effectiveWorkOrderId)
join reqUser in _context.Users on u.createdby equals reqUser.Id into requestUsers
from reqUser in requestUsers.DefaultIfEmpty()
join decUser in _context.Users on u.DecidedByUserId equals decUser.Id into decUsers
from decUser in decUsers.DefaultIfEmpty()
where (u.IsDeleted == null || u.IsDeleted == false)
select new { u, d, v, ev };
&& (d.IsDeleted == null || d.IsDeleted == false)
select new { u, d, v, ev, effectiveWorkOrderId, workOrder, reqUser, decUser };
if (!string.IsNullOrWhiteSpace(status))
query = query.Where(x => x.u.Status == status);
@ -34,8 +51,18 @@ namespace SeaHaven.DataServices.Implementation
var total = await query.CountAsync(cancellationToken);
// Approval queue read contract: the actionable queue (Pending) surfaces the
// oldest request first; the decision log (Approved) surfaces the most
// recently decided first. Every other read keeps the historical
// newest-request-first order. Id is the deterministic tiebreaker.
if (string.Equals(status, "Pending", StringComparison.Ordinal))
query = query.OrderBy(x => x.u.CreatedDate).ThenBy(x => x.u.Id);
else if (string.Equals(status, "Approved", StringComparison.Ordinal))
query = query.OrderByDescending(x => x.u.DecidedAt).ThenByDescending(x => x.u.Id);
else
query = query.OrderByDescending(x => x.u.CreatedDate).ThenByDescending(x => x.u.Id);
var items = await query
.OrderByDescending(x => x.u.CreatedDate)
.Skip((Math.Max(page, 1) - 1) * pageSize)
.Take(pageSize)
.Select(x => new UpliftListItemData
@ -44,8 +71,26 @@ namespace SeaHaven.DataServices.Implementation
DispatchId = x.d.Id,
DispatchNumber = x.d.DispatchNumber,
PONumber = x.d.PONumber,
WorkOrderId = x.d.WorkOrderId,
WorkOrderId = x.effectiveWorkOrderId,
VendorCompanyName = x.v != null ? x.v.CompanyName : x.u.RequestedByVendorName,
WorkOrderNumber = x.workOrder != null ? x.workOrder.WorkerOrderNumber : null,
WorkOrderSiteCode = x.workOrder != null ? x.workOrder.SiteCode : null,
WorkOrderService = x.workOrder != null ? x.workOrder.Service : null,
// SH-208: a work order is closed for uplift decisions once its
// lifecycle reaches a terminal state; mirrors the SH-196 revoke guard.
WorkOrderClosed = x.workOrder != null
&& (x.workOrder.LifecycleStatus == LifecycleStatus.Completed
|| x.workOrder.LifecycleStatus == LifecycleStatus.Canceled),
// SH-207: every non-deleted UpliftEvidence document on the dispatch is
// an attachment reviewers can open; the linked evidence is one of them.
AttachmentCount = x.d.CompletionDocuments.Count(doc =>
doc.Purpose == "UpliftEvidence"
&& (doc.IsDeleted == null || doc.IsDeleted == false)),
RequestedByVendorName = x.u.RequestedByVendorName,
RequestedByFirstName = x.reqUser != null ? x.reqUser.FirstName : null,
RequestedByLastName = x.reqUser != null ? x.reqUser.LastName : null,
DecidedByFirstName = x.decUser != null ? x.decUser.FirstName : null,
DecidedByLastName = x.decUser != null ? x.decUser.LastName : null,
CurrentNTE = x.u.CurrentNTE,
RequestedNTE = x.u.RequestedNTE,
VendorReason = x.u.VendorReason,
@ -200,6 +245,27 @@ namespace SeaHaven.DataServices.Implementation
.FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
}
public async Task<int?> GetWorkOrderIdForUpliftAsync(
int upliftRequestId,
CancellationToken cancellationToken)
{
var link = await _context.DispatchUpliftRequests
.Where(u => u.Id == upliftRequestId
&& (u.IsDeleted == null || u.IsDeleted == false)
&& u.Dispatch != null
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false))
.Select(u => new
{
PrimaryWorkOrderId = u.Dispatch!.WorkOrderId,
LinkedWorkOrderId = u.Dispatch.DispatchWorkOrders!
.Select(dispatchWorkOrder => (int?)dispatchWorkOrder.WorkOrderId)
.FirstOrDefault()
})
.FirstOrDefaultAsync(cancellationToken);
return link?.PrimaryWorkOrderId ?? link?.LinkedWorkOrderId;
}
public async Task<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken)
{
return await _context.DispatchUpliftRequests
@ -252,6 +318,91 @@ namespace SeaHaven.DataServices.Implementation
.SumAsync(u => u.RequestedNTE, cancellationToken);
}
// Approval queue read contract: set-based exposure aggregation. Dispatches map to
// work orders through the server-derived linkage (primary work order plus linked
// work orders), matching the ForWorkOrder scope; approved amounts are summed per
// dispatch in SQL and folded into per-work-order totals in memory (three set-based
// round trips, no query-per-work-order).
public async Task<IReadOnlyList<WorkOrderUpliftExposureData>> GetApprovedExposureForWorkOrdersAsync(
IReadOnlyCollection<int> workOrderIds, CancellationToken cancellationToken)
{
var workOrderIdsScope = workOrderIds.Distinct().ToList();
if (workOrderIdsScope.Count == 0)
return Array.Empty<WorkOrderUpliftExposureData>();
var primaryPairs = await _context.Dispatches
.Where(d => (d.IsDeleted == null || d.IsDeleted == false)
&& d.WorkOrderId != null
&& workOrderIdsScope.Contains(d.WorkOrderId.Value))
.Select(d => new { DispatchId = d.Id, WorkOrderId = d.WorkOrderId!.Value })
.ToListAsync(cancellationToken);
var linkedPairs = await _context.DispatchWorkOrders
.Where(l => workOrderIdsScope.Contains(l.WorkOrderId)
&& l.Dispatch != null
&& (l.Dispatch.IsDeleted == null || l.Dispatch.IsDeleted == false))
.Select(l => new { l.DispatchId, l.WorkOrderId })
.ToListAsync(cancellationToken);
var workOrdersByDispatch = primaryPairs
.Concat(linkedPairs)
.GroupBy(p => p.DispatchId)
.ToDictionary(g => g.Key, g => g.Select(p => p.WorkOrderId).ToHashSet());
if (workOrdersByDispatch.Count == 0)
return Array.Empty<WorkOrderUpliftExposureData>();
var dispatchIds = workOrdersByDispatch.Keys.ToList();
var sums = await _context.DispatchUpliftRequests
.Where(u => (u.IsDeleted == null || u.IsDeleted == false)
&& dispatchIds.Contains(u.DispatchId)
&& (u.Status == "Approved" || u.Status == "NoApprovalRequired"))
.GroupBy(u => u.DispatchId)
.Select(g => new
{
DispatchId = g.Key,
AutoApproved = g.Where(x => x.Status == "NoApprovalRequired")
.Sum(x => (decimal?)x.RequestedNTE),
AdminApproved = g.Where(x => x.Status == "Approved")
.Sum(x => (decimal?)x.RequestedNTE)
})
.ToListAsync(cancellationToken);
var totals = new Dictionary<int, WorkOrderUpliftExposureData>();
foreach (var sum in sums)
{
if (!workOrdersByDispatch.TryGetValue(sum.DispatchId, out var linkedWorkOrders))
continue;
foreach (var workOrderId in linkedWorkOrders)
{
if (!totals.TryGetValue(workOrderId, out var total))
{
total = new WorkOrderUpliftExposureData { WorkOrderId = workOrderId };
totals[workOrderId] = total;
}
total.AutoApprovedTotal += sum.AutoApproved ?? 0m;
total.AdminApprovedTotal += sum.AdminApproved ?? 0m;
}
}
return totals.Values.ToList();
}
// SH-207: queue-wide pending exposure for the approvals header. Mirrors the
// approved-exposure convention (sum of RequestedNTE) over every non-deleted
// Pending request on a non-deleted dispatch, independent of page or filters.
public async Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken)
{
return await _context.DispatchUpliftRequests
.Where(u => (u.IsDeleted == null || u.IsDeleted == false)
&& u.Status == "Pending"
&& u.Dispatch != null
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false))
.SumAsync(u => (decimal?)u.RequestedNTE, cancellationToken) ?? 0m;
}
public Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(
int workOrderId,
CancellationToken cancellationToken)

View file

@ -11,6 +11,7 @@ namespace SeaHaven.DataServices.Interfaces
Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken);
Task<IReadOnlyList<PortalUpliftData>> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken);
Task<DispatchUpliftRequest?> GetByIdAsync(int id, CancellationToken cancellationToken);
Task<int?> GetWorkOrderIdForUpliftAsync(int upliftRequestId, 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.
@ -18,6 +19,15 @@ namespace SeaHaven.DataServices.Interfaces
Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken);
Task<bool> HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
// Approval queue read contract: set-based per-work-order exposure totals covering
// auto-approved and admin-approved uplift amounts (pending, rejected,
// cancelled/withdrawn, and revoked requests are excluded). Work orders absent
// from the result have no approved exposure.
Task<IReadOnlyList<WorkOrderUpliftExposureData>> GetApprovedExposureForWorkOrdersAsync(
IReadOnlyCollection<int> workOrderIds, CancellationToken cancellationToken);
// SH-207: queue-wide pending exposure for the approvals header (sum of
// RequestedNTE over non-deleted Pending requests on non-deleted dispatches).
Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken);
Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
// SH-101: active = Pending or ChangesRequested (the only states that block a new request).
Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken);

View file

@ -8,6 +8,14 @@ namespace SeaHaven.Services.DTOs
public string? PONumber { get; set; }
public int? WorkOrderId { get; set; }
public string? VendorCompanyName { get; set; }
// Approval queue read contract: flattened work-order context and requester.
public string? WorkOrderNumber { get; set; }
public string? WorkOrderSite { get; set; }
public string? WorkOrderService { get; set; }
public bool WorkOrderClosed { get; set; }
public int AttachmentCount { get; set; }
public string? RequestedByName { get; set; }
public string? DecidedByName { get; set; }
public decimal? CurrentNTE { get; set; }
public decimal RequestedNTE { get; set; }
public decimal Delta { get; set; }
@ -26,6 +34,12 @@ namespace SeaHaven.Services.DTOs
public DateTime? ExpiresAt { get; set; }
public string? NotificationStatus { get; set; }
public string? NotificationError { get; set; }
// Approved-on-WO exposure totals for this item's work order: auto-approved and
// admin-approved amounts; pending, rejected, cancelled/withdrawn, and revoked
// requests are excluded.
public decimal WorkOrderAutoApprovedTotal { get; set; }
public decimal WorkOrderAdminApprovedTotal { get; set; }
public decimal WorkOrderApprovedExposureTotal { get; set; }
}
public class UpliftListResultDTO
@ -33,6 +47,8 @@ namespace SeaHaven.Services.DTOs
public int Total { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
// SH-207: queue-wide pending exposure for the approvals header.
public decimal PendingExposureTotal { get; set; }
public IEnumerable<UpliftListItemDTO> Items { get; set; } = Enumerable.Empty<UpliftListItemDTO>();
}

View file

@ -15,19 +15,22 @@ namespace SeaHaven.Services.Implementation
private readonly IVendorDocumentStoragePort _documentStorage;
private readonly TimeProvider _timeProvider;
private readonly ApprovalsOptions _approvalsOptions;
private readonly IWorkOrderUpliftService? _workOrderUpliftService;
public UpliftService(
IUpliftDataService upliftData,
IDispatchDataService dispatchData,
IVendorDocumentStoragePort documentStorage,
TimeProvider timeProvider,
IOptions<ApprovalsOptions> approvalsOptions)
IOptions<ApprovalsOptions> approvalsOptions,
IWorkOrderUpliftService? workOrderUpliftService = null)
{
_upliftData = upliftData;
_dispatchData = dispatchData;
_documentStorage = documentStorage;
_timeProvider = timeProvider;
_approvalsOptions = approvalsOptions.Value;
_workOrderUpliftService = workOrderUpliftService;
}
public async Task<UpliftListResultDTO> ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken)
@ -42,6 +45,13 @@ namespace SeaHaven.Services.Implementation
PONumber = r.PONumber,
WorkOrderId = r.WorkOrderId,
VendorCompanyName = r.VendorCompanyName,
WorkOrderNumber = r.WorkOrderNumber,
WorkOrderSite = r.WorkOrderSiteCode,
WorkOrderService = r.WorkOrderService,
WorkOrderClosed = r.WorkOrderClosed,
AttachmentCount = r.AttachmentCount,
RequestedByName = ResolveRequestedByName(r.RequestedByVendorName, r.RequestedByFirstName, r.RequestedByLastName),
DecidedByName = ComposeDisplayName(r.DecidedByFirstName, r.DecidedByLastName),
CurrentNTE = r.CurrentNTE,
RequestedNTE = r.RequestedNTE,
Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m),
@ -61,11 +71,32 @@ namespace SeaHaven.Services.Implementation
NotificationError = r.NotificationError
}).ToList();
// Approved-on-WO exposure totals are aggregated once for the whole page
// (single set-based data-service call) and then attached per item.
var workOrderIds = mapped
.Where(i => i.WorkOrderId.HasValue)
.Select(i => i.WorkOrderId!.Value)
.Distinct()
.ToList();
var exposureByWorkOrder = (await _upliftData
.GetApprovedExposureForWorkOrdersAsync(workOrderIds, cancellationToken))
.ToDictionary(e => e.WorkOrderId);
foreach (var item in mapped)
{
if (!item.WorkOrderId.HasValue) continue;
if (!exposureByWorkOrder.TryGetValue(item.WorkOrderId.Value, out var exposure)) continue;
item.WorkOrderAutoApprovedTotal = exposure.AutoApprovedTotal;
item.WorkOrderAdminApprovedTotal = exposure.AdminApprovedTotal;
item.WorkOrderApprovedExposureTotal = exposure.AutoApprovedTotal + exposure.AdminApprovedTotal;
}
return new UpliftListResultDTO
{
Total = total,
Page = page,
PageSize = pageSize,
PendingExposureTotal = await _upliftData.GetPendingExposureTotalAsync(cancellationToken),
Items = mapped
};
}
@ -156,6 +187,41 @@ namespace SeaHaven.Services.Implementation
public Task<UpliftDecisionResultDTO> RejectAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken)
=> RejectInternalAsync(user, id, note, "reject", cancellationToken);
public async Task<UpliftDecisionResultDTO> RevokeAsync(
ClaimsPrincipal user,
int id,
string reason,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(reason))
throw new InvalidOperationException("A reason is required when revoking an approved uplift");
var request = await _upliftData.GetByIdAsync(id, cancellationToken);
if (request == null)
throw new KeyNotFoundException("Uplift request not found");
if (!string.Equals(UpliftStatus.ToCanonical(request.Status), UpliftStatus.Approved, StringComparison.Ordinal))
throw new InvalidOperationException($"Cannot revoke a '{UpliftStatus.ToCanonical(request.Status)}' uplift request");
if (!user.IsInRole("Admin"))
throw new UpliftForbiddenException("Admin role is required to revoke an approved uplift");
if (_workOrderUpliftService == null)
throw new InvalidOperationException("The work-order uplift flow is unavailable");
var workOrderId = await _upliftData.GetWorkOrderIdForUpliftAsync(id, cancellationToken);
if (!workOrderId.HasValue)
throw new KeyNotFoundException("Work order not found");
var revoked = await _workOrderUpliftService.RevokeAsync(
workOrderId.Value,
id,
new RevokeWorkOrderUpliftRequestDto { Reason = reason.Trim() },
user,
cancellationToken);
if (revoked == null)
throw new KeyNotFoundException("Work order not found");
return new UpliftDecisionResultDTO { Id = revoked.Id, Status = UpliftStatus.Revoked };
}
private async Task<UpliftDecisionResultDTO> RejectInternalAsync(ClaimsPrincipal user, int id, string? note, string actionSuffix, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(note))
@ -275,6 +341,24 @@ namespace SeaHaven.Services.Implementation
return false;
}
// Approval queue read contract: the requester label prefers the recorded
// requester (vendor or internal display name captured at creation) and falls
// back to the creating user's name resolved server-side.
private static string? ResolveRequestedByName(string? vendorName, string? firstName, string? lastName)
{
if (!string.IsNullOrWhiteSpace(vendorName))
return vendorName;
return ComposeDisplayName(firstName, lastName);
}
private static string? ComposeDisplayName(string? firstName, string? lastName)
{
var composed = string.Join(" ", new[] { firstName, lastName }
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim();
return composed.Length > 0 ? composed : null;
}
private string[] RolesForTier(int requiredTier) => requiredTier switch
{
1 => _approvalsOptions.Tier1Roles,

View file

@ -11,6 +11,7 @@ namespace SeaHaven.Services.Interfaces
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);
Task<UpliftDecisionResultDTO> RevokeAsync(ClaimsPrincipal user, int id, string reason, 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.

View file

@ -0,0 +1,107 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
namespace SeaHavenIndustries.Tests;
// Relational (SQLite) coverage for the uplift queue read. The in-memory provider
// evaluates LINQ in memory and cannot prove the query translates to SQL, so this
// executes GetPagedAsync against a real relational provider and asserts a dispatch
// linked only through DispatchWorkOrders still resolves its work-order context and
// closed flag through the effective primary-plus-linked work order.
public sealed class UpliftQueueReadRelationalTests
{
[Fact]
public async Task GetPagedAsync_ResolvesWorkOrder_ForDispatchLinkedOnlyThroughDispatchWorkOrders()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
await using var context = new SqliteUpliftTestDbContext(options);
await context.Database.EnsureCreatedAsync();
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
var workOrder = new WorkOrder
{
WorkerOrderNumber = "WO-LINKED",
SiteCode = "SITE-Z",
Service = "Electrical",
WorkerOrderTitle = "Repair",
LifecycleStatus = LifecycleStatus.Completed
};
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
var dispatch = new Dispatch
{
VendorId = vendor.Id,
DispatchNumber = "DIS-LINKED",
Status = "Completed"
};
context.Dispatches.Add(dispatch);
await context.SaveChangesAsync();
context.DispatchWorkOrders.Add(new DispatchWorkOrder
{
DispatchId = dispatch.Id,
WorkOrderId = workOrder.Id
});
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
DispatchId = dispatch.Id,
Status = "Approved",
CreatedDate = new DateTime(2026, 3, 1),
DecidedAt = new DateTime(2026, 3, 2),
RequiredTier = 1,
RequestedNTE = 90m,
NotificationStatus = "Pending"
});
await context.SaveChangesAsync();
var data = new UpliftDataService(context);
var (total, items) = await data.GetPagedAsync("Approved", null, 1, 25, CancellationToken.None);
Assert.Equal(1, total);
var item = Assert.Single(items);
Assert.Equal(workOrder.Id, item.WorkOrderId);
Assert.Equal("WO-LINKED", item.WorkOrderNumber);
Assert.Equal("SITE-Z", item.WorkOrderSiteCode);
Assert.Equal("Electrical", item.WorkOrderService);
Assert.True(item.WorkOrderClosed);
}
private sealed class SqliteUpliftTestDbContext : ApplicationDbContext
{
public SqliteUpliftTestDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// SQL Server filtered index syntax is invalid on SQLite.
foreach (var index in builder.Model.GetEntityTypes().SelectMany(e => e.GetIndexes()))
{
if (index.GetFilter() != null)
index.SetFilter(null);
}
// SQLite has no rowversion type; treat as plain nullable blobs.
foreach (var entityType in new[] { typeof(WorkOrder), typeof(Dispatch) })
{
var property = builder.Entity(entityType).Property("RowVersion").Metadata;
property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never;
property.IsConcurrencyToken = false;
}
}
}
}

View file

@ -436,11 +436,14 @@ public class WorkOrderBoardCancelServiceTests
public Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<IReadOnlyList<PortalUpliftData>> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<DispatchUpliftRequest?> GetByIdAsync(int id, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<int?> GetWorkOrderIdForUpliftAsync(int upliftRequestId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<bool> HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<IReadOnlyList<WorkOrderUpliftExposureData>> GetApprovedExposureForWorkOrdersAsync(IReadOnlyCollection<int> workOrderIds, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<DispatchUpliftRequest?> GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();