From f40ad7c1eff5ca2de9ac7f8b808208683cf18cb1 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 02:10:41 -0300 Subject: [PATCH] fix(uplifts): pending exposure header sums the row deltas The approvals header summed the whole RequestedNTE for work-order-path requests, while each Pending row shows RequestedNTE - CurrentNTE. When a work order already had an NTE the header overstated exposure by that NTE. The header now sums the same Delta the rows display, over the same rows the Pending tab lists (non-deleted request on a non-deleted dispatch). The unused duplicate aggregate is removed so one definition remains. Co-Authored-By: Claude Opus 5.5 --- .../UpliftQueueReadTests.cs | 39 ++++++++++++++++--- .../Implementation/UpliftDataService.cs | 19 +++------ .../Interfaces/IUpliftDataService.cs | 1 - .../Implementation/UpliftService.cs | 2 +- .../WorkOrderBoardCancelServiceTests.cs | 1 - 5 files changed, 40 insertions(+), 22 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs index 78fe0ac..5bd4a36 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs @@ -558,7 +558,7 @@ public sealed class UpliftQueueReadTests } [Fact] - public async Task List_PendingExposureTotal_SumsGrantedAmountsAcrossAllPendingRequests() + public async Task List_PendingExposureTotal_SumsRowDeltasAcrossAllPendingRequests() { using var context = NewContext(); var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); @@ -566,11 +566,10 @@ public sealed class UpliftQueueReadTests context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" }); await context.SaveChangesAsync(); context.DispatchUpliftRequests.AddRange( - // Vendor-portal rows (createdby null) store the requested new NTE total, - // so the pending exposure is the delta: 400 - 100 = 300 and 600 - 250 = 350. + // Each pending row contributes its displayed Delta (RequestedNTE - CurrentNTE), + // whichever path created it: 400 - 100 = 300, 600 - 250 = 350, 90 - 600 = -510. Request(dispatch, "Pending", new DateTime(2026, 3, 1), requested: 400m, currentNte: 100m), Request(dispatch, "Pending", new DateTime(2026, 3, 2), requested: 600m, currentNte: 250m), - // Work-order-path rows (createdby set) store the requested increment: 90. Request(dispatch, "Pending", new DateTime(2026, 3, 3), requested: 90m, currentNte: 600m, createdBy: "user-7"), // Non-pending rows never add pending exposure. Request(dispatch, "Approved", new DateTime(2026, 2, 1), decided: new DateTime(2026, 2, 2), requested: 500m, currentNte: 100m)); @@ -581,7 +580,37 @@ public sealed class UpliftQueueReadTests var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 1, CancellationToken.None); result.Items.Should().HaveCount(1); - result.PendingExposureTotal.Should().Be(740m); + result.PendingExposureTotal.Should().Be(140m); + } + + [Fact] + public async Task List_PendingExposureTotal_EqualsSumOfPendingRowDeltas_WhenWorkOrderAlreadyHasNte() + { + 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 (vendorC, workOrderC) = await SeedWorkOrderAsync(context, "WO-C", "SITE-C", "HVAC"); + var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A"); + var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B"); + var deletedDispatch = await SeedDispatchAsync(context, vendorC, workOrderC, "DIS-C"); + deletedDispatch.IsDeleted = true; + context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" }); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.AddRange( + // Raised from the work order, which already carries a 3,000 NTE. + Request(dispatchA, "Pending", new DateTime(2026, 3, 1), requested: 5_000_000_000m, currentNte: 3_000m, createdBy: "user-7"), + // Vendor-portal request on a dispatch with no NTE recorded yet. + Request(dispatchB, "Pending", new DateTime(2026, 3, 2), requested: 333m), + // A pending request on a deleted dispatch is not a queue row, so it adds nothing. + Request(deletedDispatch, "Pending", new DateTime(2026, 3, 3), requested: 700m, currentNte: 100m)); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + result.Items.Should().HaveCount(2); + result.PendingExposureTotal.Should().Be(4_999_997_333m); + result.PendingExposureTotal.Should().Be(result.Items.Sum(i => i.Delta)); } // --- Approved-on-WO exposure totals --- diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 3c11978..3d2d68c 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -128,16 +128,6 @@ namespace SeaHaven.DataServices.Implementation return (total, items); } - public Task GetPendingExposureAsync(CancellationToken cancellationToken) - { - return _context.DispatchUpliftRequests - .Where(u => (u.IsDeleted == null || u.IsDeleted == false) - && u.Status == "Pending") - .SumAsync(u => u.createdby == null - ? u.RequestedNTE - (u.CurrentNTE ?? 0m) - : u.RequestedNTE, cancellationToken); - } - public async Task> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken) { return await (from u in _context.DispatchUpliftRequests @@ -446,9 +436,10 @@ 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. + // Queue-wide pending exposure for the approvals header: the sum of the Delta each + // Pending queue row displays (RequestedNTE - CurrentNTE), over the same rows the + // Pending tab lists (non-deleted request on a non-deleted dispatch), independent + // of page or filters. public async Task GetPendingExposureTotalAsync(CancellationToken cancellationToken) { return await _context.DispatchUpliftRequests @@ -456,7 +447,7 @@ namespace SeaHaven.DataServices.Implementation && u.Status == "Pending" && u.Dispatch != null && (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)) - .SumAsync(u => (decimal?)u.RequestedNTE, cancellationToken) ?? 0m; + .SumAsync(u => (decimal?)(u.RequestedNTE - (u.CurrentNTE ?? 0m)), cancellationToken) ?? 0m; } public Task> GetPendingForWorkOrderAsync( diff --git a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs index ed89f5b..4dfe871 100644 --- a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs @@ -6,7 +6,6 @@ namespace SeaHaven.DataServices.Interfaces { Task<(int TotalCount, IReadOnlyList Items)> GetPagedAsync( string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken); - Task GetPendingExposureAsync(CancellationToken cancellationToken); Task> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken); Task> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); Task GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken); diff --git a/SeaHaven.Services/Implementation/UpliftService.cs b/SeaHaven.Services/Implementation/UpliftService.cs index 134ec66..c72ddc3 100644 --- a/SeaHaven.Services/Implementation/UpliftService.cs +++ b/SeaHaven.Services/Implementation/UpliftService.cs @@ -36,7 +36,7 @@ namespace SeaHaven.Services.Implementation public async Task ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken) { var (total, items) = await _upliftData.GetPagedAsync(status, tier, page, pageSize, cancellationToken); - var pendingExposureTotal = await _upliftData.GetPendingExposureAsync(cancellationToken); + var pendingExposureTotal = await _upliftData.GetPendingExposureTotalAsync(cancellationToken); var mapped = items.Select(r => new UpliftListItemDTO { diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index aad09c3..46e86fc 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -447,7 +447,6 @@ public class WorkOrderBoardCancelServiceTests public Task<(int TotalCount, IReadOnlyList Items)> GetPagedAsync( string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken) => throw new NotSupportedException(); - public Task GetPendingExposureAsync(CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();