diff --git a/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs b/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs index abad676..7b3f0ec 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs @@ -52,6 +52,70 @@ public class UpliftControllerTests envelope.Status.Should().Be("Success"); } + [Fact] + public async Task List_PassesStatusAndTierFiltersToService() + { + var service = new Mock(); + service.Setup(x => x.ListAsync(It.IsAny(), "Approved", 2, 3, 50, It.IsAny())) + .ReturnsAsync(new UpliftListResultDTO()); + + var controller = NewController(service); + + await controller.List("Approved", 2, 3, 50); + + service.Verify( + x => x.ListAsync(It.IsAny(), "Approved", 2, 3, 50, It.IsAny()), + Times.Once); + } + + [Fact] + public async Task List_ReturnsQueueContractFieldsInEnvelope() + { + var service = new Mock(); + 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(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .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().Subject; + var envelope = ok.Value.Should().BeOfType().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(); } + [Fact] + public async Task Revoke_WithReason_DelegatesToService() + { + var service = new Mock(); + service.Setup(x => x.RevokeAsync( + It.IsAny(), + 7, + "Policy change", + It.IsAny())) + .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(); + service.Verify(x => x.RevokeAsync( + It.IsAny(), + 7, + "Policy change", + It.IsAny()), Times.Once); + } + + [Fact] + public async Task Revoke_WithoutReason_ReturnsBadRequest() + { + var service = new Mock(); + service.Setup(x => x.RevokeAsync( + It.IsAny(), + 7, + string.Empty, + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("reason required")); + + var controller = NewController(service, "Admin"); + + var result = await controller.Revoke(7, null); + + result.Should().BeOfType(); + } + [Fact] public async Task Approve_Forbidden_ReturnsSanitized403AndLogsInternally() { diff --git a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs new file mode 100644 index 0000000..0973536 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs @@ -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() + .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(), + TimeProvider.System, + Microsoft.Extensions.Options.Options.Create(NewOptions())); + + private static ClaimsPrincipal UserWithRoles(params string[] roles) + { + var claims = new List { 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 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); + } +} diff --git a/Api.SeaHavenIndustries.Tests/UpliftServiceRefusedTests.cs b/Api.SeaHavenIndustries.Tests/UpliftServiceRefusedTests.cs index 9fa989c..a420208 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftServiceRefusedTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftServiceRefusedTests.cs @@ -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(); + upliftData.Setup(data => data.GetByIdAsync(7, It.IsAny())) + .ReturnsAsync(request); + upliftData.Setup(data => data.GetWorkOrderIdForUpliftAsync(7, It.IsAny())) + .ReturnsAsync(77); + var workOrderFlow = new Mock(); + workOrderFlow.Setup(flow => flow.RevokeAsync( + 77, + 7, + It.Is(r => r.Reason == "Policy change"), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new WorkOrderUpliftDto { Id = 7, Status = "revoked" }); + + var service = new UpliftService( + upliftData.Object, + Mock.Of(), + Mock.Of(), + 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(r => r.Reason == "Policy change"), + It.IsAny(), + It.IsAny()), Times.Once); + } + [Fact] public async Task ApproveAsync_RefusedDispatch_RejectsWithoutChangingNteOrRequest() { diff --git a/Api.SeaHavenIndustries/Controllers/UpliftController.cs b/Api.SeaHavenIndustries/Controllers/UpliftController.cs index cc2d9ca..fcba99f 100644 --- a/Api.SeaHavenIndustries/Controllers/UpliftController.cs +++ b/Api.SeaHavenIndustries/Controllers/UpliftController.cs @@ -127,6 +127,35 @@ namespace Api.SeaHavenIndustries.Controllers } } + [HttpPost("{id:int}/revoke")] + public async Task 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) { diff --git a/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs index 28371b9..885419d 100644 --- a/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs +++ b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs @@ -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 diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index fa8d76a..48c0391 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -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 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 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> GetApprovedExposureForWorkOrdersAsync( + IReadOnlyCollection workOrderIds, CancellationToken cancellationToken) + { + var workOrderIdsScope = workOrderIds.Distinct().ToList(); + if (workOrderIdsScope.Count == 0) + return Array.Empty(); + + 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(); + + 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(); + 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 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> GetPendingForWorkOrderAsync( int workOrderId, CancellationToken cancellationToken) diff --git a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs index 1c4062b..3909447 100644 --- a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs @@ -11,6 +11,7 @@ namespace SeaHaven.DataServices.Interfaces Task GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken); Task> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken); Task GetByIdAsync(int id, CancellationToken cancellationToken); + Task GetWorkOrderIdForUpliftAsync(int upliftRequestId, CancellationToken cancellationToken); Task 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 HasPendingAsync(int dispatchId, CancellationToken cancellationToken); Task HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); Task 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> GetApprovedExposureForWorkOrdersAsync( + IReadOnlyCollection 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 GetPendingExposureTotalAsync(CancellationToken cancellationToken); Task> GetPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); // SH-101: active = Pending or ChangesRequested (the only states that block a new request). Task HasActiveAsync(int dispatchId, CancellationToken cancellationToken); diff --git a/SeaHaven.Services/DTOs/UpliftDTOs.cs b/SeaHaven.Services/DTOs/UpliftDTOs.cs index c9893ee..ad1e2a9 100644 --- a/SeaHaven.Services/DTOs/UpliftDTOs.cs +++ b/SeaHaven.Services/DTOs/UpliftDTOs.cs @@ -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 Items { get; set; } = Enumerable.Empty(); } diff --git a/SeaHaven.Services/Implementation/UpliftService.cs b/SeaHaven.Services/Implementation/UpliftService.cs index 2cfe96f..39e3b6b 100644 --- a/SeaHaven.Services/Implementation/UpliftService.cs +++ b/SeaHaven.Services/Implementation/UpliftService.cs @@ -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) + IOptions approvalsOptions, + IWorkOrderUpliftService? workOrderUpliftService = null) { _upliftData = upliftData; _dispatchData = dispatchData; _documentStorage = documentStorage; _timeProvider = timeProvider; _approvalsOptions = approvalsOptions.Value; + _workOrderUpliftService = workOrderUpliftService; } public async Task 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 RejectAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken) => RejectInternalAsync(user, id, note, "reject", cancellationToken); + public async Task 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 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, diff --git a/SeaHaven.Services/Interfaces/IUpliftService.cs b/SeaHaven.Services/Interfaces/IUpliftService.cs index 04dcadc..c84142f 100644 --- a/SeaHaven.Services/Interfaces/IUpliftService.cs +++ b/SeaHaven.Services/Interfaces/IUpliftService.cs @@ -11,6 +11,7 @@ namespace SeaHaven.Services.Interfaces Task DenyAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken); // SH-101: canonical reject (alias of deny; writes Rejected). Task RejectAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken); + Task RevokeAsync(ClaimsPrincipal user, int id, string reason, CancellationToken cancellationToken); // SH-101: internal request-changes route (note + tier authorization + audit). Task RequestChangesAsync(ClaimsPrincipal user, int id, string note, CancellationToken cancellationToken); // SH-101: authorized internal download of a Passed UpliftEvidence file linked to an uplift request. diff --git a/SeaHavenIndustries.Tests/UpliftQueueReadRelationalTests.cs b/SeaHavenIndustries.Tests/UpliftQueueReadRelationalTests.cs new file mode 100644 index 0000000..b4b6be5 --- /dev/null +++ b/SeaHavenIndustries.Tests/UpliftQueueReadRelationalTests.cs @@ -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() + .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 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; + } + } + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index f53f1ff..eccc526 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -436,11 +436,14 @@ public class WorkOrderBoardCancelServiceTests public Task GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetByIdAsync(int id, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task GetWorkOrderIdForUpliftAsync(int upliftRequestId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task HasPendingAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> GetApprovedExposureForWorkOrdersAsync(IReadOnlyCollection workOrderIds, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task GetPendingExposureTotalAsync(CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task HasActiveAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();