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();