From dfd248cfcbc9fe629be96a4fdbc17b592bc40c6b Mon Sep 17 00:00:00 2001 From: albrand Date: Wed, 16 Sep 2026 22:48:47 -0300 Subject: [PATCH] feat(uplifts): expose queue closed flag, attachments, decider, pending exposure (SH-207, SH-208) The approvals queue frontend needs four list-contract additions the read contract PRs do not carry yet: workOrderClosed so the Approved tab can disable Revoke on terminal work orders (mirroring the SH-196 revoke guard), attachmentCount from non-deleted UpliftEvidence documents so the +N chip renders, decidedByName for the Approved By column, and the queue-wide pendingExposureTotal for the header total. --- .../UpliftControllerTests.cs | 16 ++- .../UpliftQueueReadTests.cs | 135 ++++++++++++++++++ .../Models/VendorPortalReadModels.cs | 4 + .../Implementation/UpliftDataService.cs | 30 +++- .../Interfaces/IUpliftDataService.cs | 3 + SeaHaven.Services/DTOs/UpliftDTOs.cs | 5 + .../Implementation/UpliftService.cs | 9 ++ .../WorkOrderBoardCancelServiceTests.cs | 1 + 8 files changed, 201 insertions(+), 2 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs b/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs index 7d49f24..7b3f0ec 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs @@ -79,13 +79,23 @@ public class UpliftControllerTests 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, Items = new[] { item } }); + .ReturnsAsync(new UpliftListResultDTO + { + Total = 1, + Page = 1, + PageSize = 25, + PendingExposureTotal = 200m, + Items = new[] { item } + }); var controller = NewController(service); @@ -98,8 +108,12 @@ public class UpliftControllerTests 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] diff --git a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs index ad539bd..ad3edd6 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -462,4 +463,138 @@ public sealed class UpliftQueueReadTests 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/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs index 05ea0ab..885419d 100644 --- a/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs +++ b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs @@ -85,9 +85,13 @@ namespace Data.SeaHavenIndustries 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; } diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 6c52434..3535e0e 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; @@ -28,9 +29,11 @@ namespace SeaHaven.DataServices.Implementation from wo in workOrders.DefaultIfEmpty() 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) && (d.IsDeleted == null || d.IsDeleted == false) - select new { u, d, v, ev, wo, reqUser }; + select new { u, d, v, ev, wo, reqUser, decUser }; if (!string.IsNullOrWhiteSpace(status)) query = query.Where(x => x.u.Status == status); @@ -64,9 +67,21 @@ namespace SeaHaven.DataServices.Implementation WorkOrderNumber = x.wo != null ? x.wo.WorkerOrderNumber : null, WorkOrderSiteCode = x.wo != null ? x.wo.SiteCode : null, WorkOrderService = x.wo != null ? x.wo.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.wo != null + && (x.wo.LifecycleStatus == LifecycleStatus.Completed + || x.wo.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, @@ -366,6 +381,19 @@ namespace SeaHaven.DataServices.Implementation 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 9ca8a5d..3909447 100644 --- a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs @@ -25,6 +25,9 @@ namespace SeaHaven.DataServices.Interfaces // 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 5333f07..ad1e2a9 100644 --- a/SeaHaven.Services/DTOs/UpliftDTOs.cs +++ b/SeaHaven.Services/DTOs/UpliftDTOs.cs @@ -12,7 +12,10 @@ namespace SeaHaven.Services.DTOs 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; } @@ -44,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 9e86960..39e3b6b 100644 --- a/SeaHaven.Services/Implementation/UpliftService.cs +++ b/SeaHaven.Services/Implementation/UpliftService.cs @@ -48,7 +48,10 @@ namespace SeaHaven.Services.Implementation 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), @@ -93,6 +96,7 @@ namespace SeaHaven.Services.Implementation Total = total, Page = page, PageSize = pageSize, + PendingExposureTotal = await _upliftData.GetPendingExposureTotalAsync(cancellationToken), Items = mapped }; } @@ -345,6 +349,11 @@ namespace SeaHaven.Services.Implementation 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; diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index bfc163f..eccc526 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -443,6 +443,7 @@ public class WorkOrderBoardCancelServiceTests 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();