From f40ad7c1eff5ca2de9ac7f8b808208683cf18cb1 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 02:10:41 -0300 Subject: [PATCH 1/4] 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(); From eb2b442775c26364afddcdbdc333968ff895b977 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 02:32:58 -0300 Subject: [PATCH 2/4] fix(uplifts): one per-path uplift amount for queue, approval and exposure Work-order requests store the requested increase in RequestedNTE; vendor portal requests store the requested NTE total. The queue Delta, the pending and approved exposure totals, the work-order uplift list, the board summary and the notification Delta now all read one definition (UpliftAmount) that honours both meanings and translates to SQL. Approving a work-order request now adds its increase to the dispatch NTE instead of replacing the NTE with the increase; vendor requests still end at their requested total. Co-Authored-By: Claude Opus 5.5 --- .../UpliftQueueReadTests.cs | 24 +- .../Models/VendorPortalReadModels.cs | 4 + SeaHaven.DataServices/Helpers/UpliftAmount.cs | 33 ++ .../Helpers/WorkOrderBoardProjection.cs | 10 +- .../Implementation/DispatchDataService.cs | 4 +- .../Implementation/UpliftDataService.cs | 44 ++- .../Helpers/WorkOrderUpliftContractMapper.cs | 5 +- .../UpliftNotificationMessage.cs | 3 +- .../Implementation/UpliftService.cs | 14 +- .../UpliftAmountPerCreationPathTests.cs | 298 ++++++++++++++++++ 10 files changed, 394 insertions(+), 45 deletions(-) create mode 100644 SeaHaven.DataServices/Helpers/UpliftAmount.cs create mode 100644 SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs diff --git a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs index 5bd4a36..5c398cf 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs @@ -278,7 +278,8 @@ public sealed class UpliftQueueReadTests 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); + // A work-order request (createdby set) stores the increase itself in RequestedNTE. + item.Delta.Should().Be(400m); item.Status.Should().Be("Pending"); } @@ -558,7 +559,7 @@ public sealed class UpliftQueueReadTests } [Fact] - public async Task List_PendingExposureTotal_SumsRowDeltasAcrossAllPendingRequests() + public async Task List_PendingExposureTotal_SumsEachPendingRequestsIncreaseAcrossTheQueue() { using var context = NewContext(); var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); @@ -566,10 +567,11 @@ public sealed class UpliftQueueReadTests context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" }); await context.SaveChangesAsync(); context.DispatchUpliftRequests.AddRange( - // Each pending row contributes its displayed Delta (RequestedNTE - CurrentNTE), - // whichever path created it: 400 - 100 = 300, 600 - 250 = 350, 90 - 600 = -510. + // Vendor-portal rows (createdby null) store the requested new NTE total, + // so their increase is 400 - 100 = 300 and 600 - 250 = 350. 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 rows (createdby set) store the increase itself: 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)); @@ -580,11 +582,11 @@ 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(140m); + result.PendingExposureTotal.Should().Be(740m); } [Fact] - public async Task List_PendingExposureTotal_EqualsSumOfPendingRowDeltas_WhenWorkOrderAlreadyHasNte() + public async Task List_PendingExposureTotal_EqualsSumOfPendingRowAmounts_ForBothCreationPaths() { using var context = NewContext(); var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); @@ -597,10 +599,11 @@ public sealed class UpliftQueueReadTests 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. + // Raised from the work order, which already carries a 3,000 NTE: RequestedNTE + // is the 5,000,000,000 increase itself. 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), + // Vendor-portal request for a 3,333 total on a 3,000 NTE: a 333 increase. + Request(dispatchB, "Pending", new DateTime(2026, 3, 2), requested: 3_333m, currentNte: 3_000m), // 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(); @@ -609,7 +612,8 @@ public sealed class UpliftQueueReadTests 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.Items.Select(i => i.Delta).Should().BeEquivalentTo(new[] { 5_000_000_000m, 333m }); + result.PendingExposureTotal.Should().Be(5_000_000_333m); result.PendingExposureTotal.Should().Be(result.Items.Sum(i => i.Delta)); } diff --git a/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs index 031cf01..84a00e8 100644 --- a/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs +++ b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs @@ -100,6 +100,8 @@ namespace Data.SeaHavenIndustries public string? DecidedByLastName { get; set; } public decimal? CurrentNTE { get; set; } public decimal RequestedNTE { get; set; } + /// The requested NTE increase; see UpliftAmount in SeaHaven.DataServices. + public decimal Amount { get; set; } public string? VendorReason { get; set; } public int RequiredTier { get; set; } public string? Status { get; set; } @@ -127,6 +129,8 @@ namespace Data.SeaHavenIndustries public int DispatchId { get; set; } public decimal? CurrentNTE { get; set; } public decimal RequestedNTE { get; set; } + /// The requested NTE increase; see UpliftAmount in SeaHaven.DataServices. + public decimal Amount { get; set; } public string? VendorReason { get; set; } public string? Status { get; set; } public int RequiredTier { get; set; } diff --git a/SeaHaven.DataServices/Helpers/UpliftAmount.cs b/SeaHaven.DataServices/Helpers/UpliftAmount.cs new file mode 100644 index 0000000..6f17961 --- /dev/null +++ b/SeaHaven.DataServices/Helpers/UpliftAmount.cs @@ -0,0 +1,33 @@ +using System.Linq.Expressions; +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Helpers +{ + /// + /// The single definition of an uplift's amount: the NTE increase being requested. + /// The two creation paths store different meanings in RequestedNTE. Vendor-portal + /// requests store the requested new NTE total, so the amount is RequestedNTE minus + /// the NTE at request time. Work-order requests store the increase itself. Vendor + /// sessions have no identity user, so createdby is null only on vendor-portal rows. + /// + public static class UpliftAmount + { + /// Translatable form for EF queries and aggregates. + public static readonly Expression> ForQuery = + u => u.createdby == null + ? u.RequestedNTE - (u.CurrentNTE ?? 0m) + : u.RequestedNTE; + + private static readonly Func Compiled = ForQuery.Compile(); + + public static decimal Of(DispatchUpliftRequest request) => Compiled(request); + + public static decimal Of(string? createdBy, decimal requestedNte, decimal? currentNte) => + Compiled(new DispatchUpliftRequest + { + createdby = createdBy, + RequestedNTE = requestedNte, + CurrentNTE = currentNte, + }); + } +} diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index d4557f0..a9f9861 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -255,6 +255,8 @@ namespace SeaHaven.DataServices.Helpers WorkOrderId = u.Dispatch!.WorkOrderId!.Value, u.Status, u.RequestedNTE, + u.CurrentNTE, + u.createdby, u.CreatedDate }); @@ -268,6 +270,8 @@ namespace SeaHaven.DataServices.Helpers WorkOrderId = link.WorkOrderId, u.Status, u.RequestedNTE, + u.CurrentNTE, + u.createdby, u.CreatedDate }); @@ -276,7 +280,7 @@ namespace SeaHaven.DataServices.Helpers l.UpliftId, l.WorkOrderId, l.Status, - l.RequestedNTE, + UpliftAmount.Of(l.createdby, l.RequestedNTE, l.CurrentNTE), l.CreatedDate))); } @@ -316,7 +320,7 @@ namespace SeaHaven.DataServices.Helpers pendingCount, HasUplift: true, primary?.Status, - primary?.RequestedNTE); + primary?.Amount); } return result; @@ -337,7 +341,7 @@ namespace SeaHaven.DataServices.Helpers int UpliftId, int WorkOrderId, string Status, - decimal RequestedNTE, + decimal Amount, DateTime? CreatedDate); private sealed record BoardUpliftAgg( diff --git a/SeaHaven.DataServices/Implementation/DispatchDataService.cs b/SeaHaven.DataServices/Implementation/DispatchDataService.cs index 6511ad6..3274713 100644 --- a/SeaHaven.DataServices/Implementation/DispatchDataService.cs +++ b/SeaHaven.DataServices/Implementation/DispatchDataService.cs @@ -1,6 +1,7 @@ using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Models; @@ -457,6 +458,7 @@ namespace SeaHaven.DataServices.Implementation u.Id, u.CurrentNTE, u.RequestedNTE, + u.createdby, u.VendorReason, u.Status, u.RequiredTier, @@ -474,7 +476,7 @@ namespace SeaHaven.DataServices.Implementation Id = u.Id, CurrentNTE = u.CurrentNTE, RequestedNTE = u.RequestedNTE, - Delta = u.RequestedNTE - (u.CurrentNTE ?? 0m), + Delta = UpliftAmount.Of(u.createdby, u.RequestedNTE, u.CurrentNTE), VendorReason = u.VendorReason, Status = u.Status, RequiredTier = u.RequiredTier, diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 3d2d68c..cbcf76d 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -3,6 +3,7 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Exceptions; +using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; namespace SeaHaven.DataServices.Implementation @@ -109,6 +110,7 @@ namespace SeaHaven.DataServices.Implementation DecidedByLastName = x.decUser != null ? x.decUser.LastName : null, CurrentNTE = x.u.CurrentNTE, RequestedNTE = x.u.RequestedNTE, + Amount = UpliftAmount.Of(x.u.createdby, x.u.RequestedNTE, x.u.CurrentNTE), VendorReason = x.u.VendorReason, RequiredTier = x.u.RequiredTier, Status = x.u.Status, @@ -143,6 +145,7 @@ namespace SeaHaven.DataServices.Implementation DispatchId = u.DispatchId, CurrentNTE = u.CurrentNTE, RequestedNTE = u.RequestedNTE, + Amount = UpliftAmount.Of(u.createdby, u.RequestedNTE, u.CurrentNTE), VendorReason = u.VendorReason, Status = u.Status, RequiredTier = u.RequiredTier, @@ -183,6 +186,7 @@ namespace SeaHaven.DataServices.Implementation DispatchId = u.DispatchId, CurrentNTE = u.CurrentNTE, RequestedNTE = u.RequestedNTE, + Amount = UpliftAmount.Of(u.createdby, u.RequestedNTE, u.CurrentNTE), VendorReason = u.VendorReason, Status = u.Status, RequiredTier = u.RequiredTier, @@ -392,25 +396,14 @@ namespace SeaHaven.DataServices.Implementation .Where(u => (u.IsDeleted == null || u.IsDeleted == false) && dispatchIds.Contains(u.DispatchId) && (u.Status == "Approved" || u.Status == "NoApprovalRequired")) - .GroupBy(u => u.DispatchId) + // Summing each request's amount (the granted increase) keeps sequential + // approvals from double-counting whole NTE totals in both buckets. + .GroupBy(u => new { u.DispatchId, u.Status }, UpliftAmount.ForQuery) .Select(g => new { - DispatchId = g.Key, - // The two creation paths store different meanings in RequestedNTE. - // Vendor-portal rows store the requested new NTE total, so the - // granted amount is RequestedNTE - CurrentNTE; work-order-path rows - // store the granted increment directly. Vendor sessions have no - // identity user, so createdby is null only on vendor-portal rows. Summing - // granted amounts keeps sequential approvals from double-counting whole - // NTE totals. This applies to both buckets. - AutoApproved = g.Where(x => x.Status == "NoApprovalRequired") - .Sum(x => (decimal?)(x.createdby == null - ? x.RequestedNTE - (x.CurrentNTE ?? 0m) - : x.RequestedNTE)), - AdminApproved = g.Where(x => x.Status == "Approved") - .Sum(x => (decimal?)(x.createdby == null - ? x.RequestedNTE - (x.CurrentNTE ?? 0m) - : x.RequestedNTE)) + g.Key.DispatchId, + g.Key.Status, + Total = g.Sum() }) .ToListAsync(cancellationToken); @@ -428,18 +421,20 @@ namespace SeaHaven.DataServices.Implementation totals[workOrderId] = total; } - total.AutoApprovedTotal += sum.AutoApproved ?? 0m; - total.AdminApprovedTotal += sum.AdminApproved ?? 0m; + if (sum.Status == "NoApprovalRequired") + total.AutoApprovedTotal += sum.Total; + else + total.AdminApprovedTotal += sum.Total; } } return totals.Values.ToList(); } - // 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. + // Queue-wide pending exposure for the approvals header: the sum of the amount each + // Pending queue row displays (UpliftAmount), 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 @@ -447,7 +442,8 @@ namespace SeaHaven.DataServices.Implementation && u.Status == "Pending" && u.Dispatch != null && (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)) - .SumAsync(u => (decimal?)(u.RequestedNTE - (u.CurrentNTE ?? 0m)), cancellationToken) ?? 0m; + .Select(UpliftAmount.ForQuery) + .SumAsync(cancellationToken); } public Task> GetPendingForWorkOrderAsync( diff --git a/SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs b/SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs index 7596b8d..667dc4b 100644 --- a/SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs +++ b/SeaHaven.Services/Helpers/WorkOrderUpliftContractMapper.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Implementation; @@ -42,7 +43,7 @@ namespace SeaHaven.Services.Helpers { Id = row.Id, Status = ToFrontendStatus(row.Status), - Amount = row.RequestedNTE, + Amount = row.Amount, Notes = row.VendorReason ?? "", RequestedAt = row.CreatedDate, RequestedById = row.CreatedByUserId, @@ -58,7 +59,7 @@ namespace SeaHaven.Services.Helpers { Id = request.Id, Status = ToFrontendStatus(request.Status), - Amount = request.RequestedNTE, + Amount = UpliftAmount.Of(request), Notes = request.VendorReason ?? "", RequestedAt = request.CreatedDate, RequestedById = request.createdby, diff --git a/SeaHaven.Services/Implementation/UpliftNotificationMessage.cs b/SeaHaven.Services/Implementation/UpliftNotificationMessage.cs index dd3c3f8..1a1331c 100644 --- a/SeaHaven.Services/Implementation/UpliftNotificationMessage.cs +++ b/SeaHaven.Services/Implementation/UpliftNotificationMessage.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Helpers; namespace SeaHaven.Services.Implementation { @@ -23,7 +24,7 @@ namespace SeaHaven.Services.Implementation - +
Current NTE${req.CurrentNTE ?? 0m:F2}
Requested NTE${req.RequestedNTE:F2}
Delta${(req.RequestedNTE - (req.CurrentNTE ?? 0m)):F2}
Delta${UpliftAmount.Of(req):F2}
Required Approval{tierText}

Vendor Reason

diff --git a/SeaHaven.Services/Implementation/UpliftService.cs b/SeaHaven.Services/Implementation/UpliftService.cs index c72ddc3..450b76b 100644 --- a/SeaHaven.Services/Implementation/UpliftService.cs +++ b/SeaHaven.Services/Implementation/UpliftService.cs @@ -1,5 +1,6 @@ using Data.SeaHavenIndustries; using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; @@ -56,7 +57,7 @@ namespace SeaHaven.Services.Implementation DecidedByName = ResolveRequestedByName(null, r.DecidedByFirstName, r.DecidedByLastName), CurrentNTE = r.CurrentNTE, RequestedNTE = r.RequestedNTE, - Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m), + Delta = r.Amount, VendorReason = r.VendorReason, RequiredTier = r.RequiredTier, Status = UpliftStatus.ToCanonical(r.Status), @@ -115,7 +116,7 @@ namespace SeaHaven.Services.Implementation DispatchId = r.DispatchId, CurrentNTE = r.CurrentNTE, RequestedNTE = r.RequestedNTE, - Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m), + Delta = r.Amount, VendorReason = r.VendorReason, Status = UpliftStatus.ToCanonical(r.Status), RequiredTier = r.RequiredTier, @@ -157,7 +158,12 @@ namespace SeaHaven.Services.Implementation var now = _timeProvider.GetUtcNow().UtcDateTime; var oldNTE = dispatch.NTEAmount ?? 0m; - dispatch.NTEAmount = req.RequestedNTE; + // Approval raises the NTE by the request's amount. A vendor-portal request + // stores its requested NTE total and ends exactly there; a work-order request + // stores the increase, so it is added to the dispatch's current NTE. + dispatch.NTEAmount = req.createdby == null + ? req.RequestedNTE + : oldNTE + UpliftAmount.Of(req); dispatch.LastModificationTime = now; req.Status = UpliftStatus.Approved; @@ -172,7 +178,7 @@ namespace SeaHaven.Services.Implementation UserId = userId, FieldName = $"Dispatch {dispatch.DispatchNumber} NTE", OldValue = $"${oldNTE:F2}", - NewValue = $"${req.RequestedNTE:F2}", + NewValue = $"${dispatch.NTEAmount:F2}", Action = "uplift_approved", CreatedAt = now }, cancellationToken); diff --git a/SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs b/SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs new file mode 100644 index 0000000..a378b60 --- /dev/null +++ b/SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs @@ -0,0 +1,298 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Helpers; +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 SeaHavenIndustries.Tests; + +// An uplift's amount is the NTE increase being requested. Work-order requests store +// that increase in RequestedNTE; vendor-portal requests store the requested NTE total. +// These tests walk each path through the approvals queue, approve and revoke, and the +// queue-wide and per-work-order exposure totals. +public sealed class UpliftAmountPerCreationPathTests +{ + private const string DispatcherId = "dispatcher-1"; + private const int WorkOrderId = 1; + private const int PrimaryDispatchId = 10; + private const int SecondDispatchId = 11; + + private static ApplicationDbContext CreateContext() => + new(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options); + + private static ApprovalsOptions NewOptions() => new() + { + UpliftTier1MaxUsd = 2500m, + Tier1Roles = new[] { "Approver" }, + Tier2Roles = new[] { "Manager" }, + }; + + private static WorkOrderUpliftService NewWorkOrderUpliftService(ApplicationDbContext context) => + new(new UpliftDataService(context), + new DispatchDataService(context), + new WorkOrderDetailDataService(context), + WorkOrderAccountTestHelpers.Resolver(context), + new UserDataService(context), + new TeamPermissionOverrideDataService(context), + new TeamPermissionPolicy(), + TimeProvider.System, + Options.Create(NewOptions())); + + private static UpliftService NewQueueService(ApplicationDbContext context) => + new(new UpliftDataService(context), + new DispatchDataService(context), + new NoDocumentStorage(), + TimeProvider.System, + Options.Create(NewOptions())); + + private static ClaimsPrincipal Admin(string userId = DispatcherId) => + WorkOrderAccountTestHelpers.OrgWideAdmin(userId); + + private static Dispatch DispatchOf(ApplicationDbContext context, int id) => + context.Dispatches.Single(d => d.Id == id); + + // A work order with a 600 NTE on its primary dispatch, and a second dispatch on the + // same work order, also at 600. + private static async Task SeedWorkOrderAsync(ApplicationDbContext context) + { + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); + context.Users.Add(new ApplicationUser + { + Id = DispatcherId, + UserName = DispatcherId, + FirstName = "Alex", + LastName = "Dispatcher", + }); + var role = new IdentityRole("Admin"); + context.Roles.Add(role); + context.UserRoles.Add(new IdentityUserRole { UserId = DispatcherId, RoleId = role.Id }); + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); + context.Dispatches.AddRange( + new Dispatch + { + Id = PrimaryDispatchId, + VendorId = 1, + WorkOrderId = WorkOrderId, + NTEAmount = 600m, + DispatchNumber = "DIS-10", + Status = "Completed", + }, + new Dispatch + { + Id = SecondDispatchId, + VendorId = 1, + WorkOrderId = WorkOrderId, + NTEAmount = 600m, + DispatchNumber = "DIS-11", + Status = "Completed", + }); + context.workOrders.Add(new WorkOrder + { + Id = WorkOrderId, + InternalWONumber = "10000000001", + PrimaryDispatchId = PrimaryDispatchId, + AccountId = 1, + WorkOrderType = WorkOrderType.PM, + }); + await context.SaveChangesAsync(); + } + + // Uses up the work order's auto-approval allowance, so a new work-order request + // waits for approval instead of auto-approving. + private static async Task ConsumeAutoApprovalAllowanceAsync(ApplicationDbContext context) + { + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = SecondDispatchId, + CurrentNTE = 100m, + RequestedNTE = 500m, + Status = "NoApprovalRequired", + RequiredTier = 0, + NotificationStatus = "Sent", + createdby = DispatcherId, + CreatedDate = new DateTime(2026, 1, 2), + }); + await context.SaveChangesAsync(); + } + + private static async Task CreatePendingWorkOrderRequestAsync(ApplicationDbContext context, decimal amount) + { + await ConsumeAutoApprovalAllowanceAsync(context); + var created = await NewWorkOrderUpliftService(context).CreateAsync( + WorkOrderId, + new CreateWorkOrderUpliftRequestDto { Amount = amount, Notes = "Extra parts" }, + Admin(), + CancellationToken.None); + Assert.Equal("pending", created!.Status); + return created.Id; + } + + // Stored exactly as the vendor portal writes it: no identity user, and RequestedNTE + // is the requested new NTE total. + private static async Task SeedVendorRequestAsync( + ApplicationDbContext context, int dispatchId, decimal currentNte, decimal requestedTotal) + { + var request = new DispatchUpliftRequest + { + DispatchId = dispatchId, + CurrentNTE = currentNte, + RequestedNTE = requestedTotal, + Status = "Pending", + RequiredTier = 1, + RequestedByVendorName = "Acme HVAC", + NotificationStatus = "Sent", + CreatedDate = new DateTime(2026, 3, 1), + }; + context.DispatchUpliftRequests.Add(request); + await context.SaveChangesAsync(); + return request.Id; + } + + private static Task RevokeAsync(ApplicationDbContext context, int upliftId) => + NewWorkOrderUpliftService(context).RevokeAsync( + WorkOrderId, + upliftId, + new RevokeWorkOrderUpliftRequestDto { Reason = "Scope reduced" }, + Admin(), + CancellationToken.None); + + [Fact] + public async Task WorkOrderRequest_QueueShowsTheRequestedIncrease() + { + await using var context = CreateContext(); + await SeedWorkOrderAsync(context); + var upliftId = await CreatePendingWorkOrderRequestAsync(context, 90m); + + var pending = await NewQueueService(context) + .ListAsync(Admin(), "Pending", null, 1, 25, CancellationToken.None); + + Assert.Equal(90m, Assert.Single(pending.Items, i => i.Id == upliftId).Delta); + } + + [Fact] + public async Task WorkOrderRequest_ApproveAddsIncreaseToNte_RevokeRestoresNte() + { + await using var context = CreateContext(); + await SeedWorkOrderAsync(context); + var upliftId = await CreatePendingWorkOrderRequestAsync(context, 90m); + var queue = NewQueueService(context); + + var approved = await queue.ApproveAsync(Admin(), upliftId, null, CancellationToken.None); + Assert.Equal(690m, approved.NTEAmount); + Assert.Equal(690m, DispatchOf(context, PrimaryDispatchId).NTEAmount); + + var revoked = await RevokeAsync(context, upliftId); + Assert.Equal("revoked", revoked!.Status); + Assert.Equal(600m, DispatchOf(context, PrimaryDispatchId).NTEAmount); + } + + [Fact] + public async Task VendorRequest_QueueShowsIncrease_ApproveEndsAtRequestedTotal_RevokeRestoresNte() + { + await using var context = CreateContext(); + await SeedWorkOrderAsync(context); + var upliftId = await SeedVendorRequestAsync(context, PrimaryDispatchId, currentNte: 600m, requestedTotal: 900m); + var queue = NewQueueService(context); + + var pending = await queue.ListAsync(Admin(), "Pending", null, 1, 25, CancellationToken.None); + Assert.Equal(300m, Assert.Single(pending.Items).Delta); + + var approved = await queue.ApproveAsync(Admin(), upliftId, null, CancellationToken.None); + Assert.Equal(900m, approved.NTEAmount); + Assert.Equal(900m, DispatchOf(context, PrimaryDispatchId).NTEAmount); + + var revoked = await RevokeAsync(context, upliftId); + Assert.Equal("revoked", revoked!.Status); + Assert.Equal(600m, DispatchOf(context, PrimaryDispatchId).NTEAmount); + } + + [Fact] + public async Task PendingExposureAndApprovedOnWorkOrder_SumEachRequestsIncrease() + { + await using var context = CreateContext(); + await SeedWorkOrderAsync(context); + var workOrderRequestId = await CreatePendingWorkOrderRequestAsync(context, 90m); + var vendorRequestId = await SeedVendorRequestAsync(context, SecondDispatchId, currentNte: 600m, requestedTotal: 900m); + var queue = NewQueueService(context); + + var pending = await queue.ListAsync(Admin(), "Pending", null, 1, 25, CancellationToken.None); + Assert.Equal(390m, pending.PendingExposureTotal); + Assert.Equal(pending.Items.Sum(i => i.Delta), pending.PendingExposureTotal); + + await queue.ApproveAsync(Admin(), workOrderRequestId, null, CancellationToken.None); + await queue.ApproveAsync(Admin(), vendorRequestId, null, CancellationToken.None); + + var decided = await queue.ListAsync(Admin(), "Approved", null, 1, 25, CancellationToken.None); + var row = Assert.Single(decided.Items, i => i.Id == workOrderRequestId); + Assert.Equal(390m, row.WorkOrderAdminApprovedTotal); + // The earlier auto-approved work-order request counts its stored increase. + Assert.Equal(500m, row.WorkOrderAutoApprovedTotal); + Assert.Equal(0m, (await queue.ListAsync(Admin(), "Pending", null, 1, 25, CancellationToken.None)).PendingExposureTotal); + } + + [Fact] + public async Task AutoApprovedWorkOrderRequest_ConsumesAllowanceByItsRequestedIncrease() + { + await using var context = CreateContext(); + await SeedWorkOrderAsync(context); + + var created = await NewWorkOrderUpliftService(context).CreateAsync( + WorkOrderId, + new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "Within limit" }, + Admin(), + CancellationToken.None); + + Assert.Equal("auto_approved", created!.Status); + var stored = context.DispatchUpliftRequests.Single(u => u.Id == created.Id); + Assert.Equal(DispatcherId, stored.createdby); + Assert.Equal(400m, stored.RequestedNTE); + Assert.Equal(1000m, DispatchOf(context, PrimaryDispatchId).NTEAmount); + Assert.Equal(400m, await new UpliftDataService(context) + .SumAutoApprovedAmountForWorkOrderAsync(WorkOrderId, CancellationToken.None)); + } + + [Fact] + public void AmountDefinition_TranslatesToSqlServer_ForRowAndGroupedSums() + { + // ToQueryString only builds SQL; it never opens the connection. + using var context = new ApplicationDbContext(new DbContextOptionsBuilder() + .UseSqlServer("Server=translation-only;Database=none;Trusted_Connection=True;") + .Options); + + var rowSql = context.DispatchUpliftRequests + .Select(UpliftAmount.ForQuery) + .ToQueryString(); + var groupedSql = context.DispatchUpliftRequests + .GroupBy(u => new { u.DispatchId, u.Status }, UpliftAmount.ForQuery) + .Select(g => new { g.Key.DispatchId, g.Key.Status, Total = g.Sum() }) + .ToQueryString(); + + Assert.Contains("CASE", rowSql); + Assert.Contains("[createdby] IS NULL", rowSql); + Assert.Contains("SUM(CASE", groupedSql); + Assert.Contains("GROUP BY", groupedSql); + } + + private sealed class NoDocumentStorage : IVendorDocumentStoragePort + { + public Task SaveAsync(int vendorId, int dispatchId, string storedFileName, Stream content, CancellationToken cancellationToken) => + Task.CompletedTask; + + public Stream OpenRead(int vendorId, int dispatchId, string storedFileName) => + throw new NotSupportedException(); + + public void Delete(int vendorId, int dispatchId, string storedFileName) + { + } + } +} From d33ba34db91e6f5bb77978ad3a443ccd9ef54b01 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 02:35:06 -0300 Subject: [PATCH 3/4] fix(vendor-portal): vendors revise only uplift requests they raised A work-order request stores the requested increase, not a total. Letting the vendor revise one after changes were requested rewrote RequestedNTE as a total while it still read as a work-order request, corrupting its amount and the NTE it would be approved to. Revise now answers not-found for any request the vendor did not raise and leaves the row untouched. Co-Authored-By: Claude Opus 5.5 --- .../UpliftWorkflowTests.cs | 38 +++++++++++++++++++ .../Implementation/VendorPortalService.cs | 4 +- .../UpliftAmountPerCreationPathTests.cs | 15 ++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs b/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs index 4518910..87f07cf 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs @@ -419,6 +419,44 @@ public sealed class UpliftWorkflowTests context.WorkOrderAuditLogs.Should().Contain(a => a.Action == "uplift_revised"); } + [Fact] + public async Task Revise_WorkOrderRequest_IsRefusedAsNotFound_AndLeavesTheRowUnchanged() + { + using var context = NewContext(); + var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 600m); + context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id)); + // Raised from the work order by an internal user and sent back for changes: + // RequestedNTE holds the 90 increase, not a total. + var workOrderRequest = new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + CurrentNTE = 600m, + RequestedNTE = 90m, + Status = UpliftStatus.ChangesRequested, + RequiredTier = 1, + VendorReason = "Extra parts", + RequestedByVendorName = "Alex Dispatcher", + NotificationStatus = "Sent", + createdby = "dispatcher-1", + CreatedDate = new DateTime(2026, 3, 1), + }; + context.DispatchUpliftRequests.Add(workOrderRequest); + await context.SaveChangesAsync(); + var service = NewPortalService(context, new FakeEmailSender(deliver: true)); + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + + var act = () => service.ReviseUpliftAsync(session!, dispatch.Id, workOrderRequest.Id, 900m, "vendor total", 1, CancellationToken.None); + + await act.Should().ThrowAsync(); + var after = context.DispatchUpliftRequests.AsNoTracking().Single(u => u.Id == workOrderRequest.Id); + after.Status.Should().Be(UpliftStatus.ChangesRequested); + after.RequestedNTE.Should().Be(90m); + after.CurrentNTE.Should().Be(600m); + after.VendorReason.Should().Be("Extra parts"); + after.createdby.Should().Be("dispatcher-1"); + context.WorkOrderAuditLogs.Should().NotContain(a => a.Action == "uplift_revised"); + } + [Fact] public async Task Withdraw_Pending_TransitionsToWithdrawn_SetsDecidedAt() { diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index c595809..047abd0 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -736,7 +736,9 @@ namespace SeaHaven.Services.Implementation } var req = await _upliftData.GetByIdAndDispatchAsync(requestId, id, cancellationToken); - if (req == null) throw new KeyNotFoundException("Uplift request not found"); + // A vendor revises only requests it raised. Work-order requests (createdby set) + // store the increase, not a total, so a vendor revise would corrupt their amount. + if (req == null || req.createdby != null) throw new KeyNotFoundException("Uplift request not found"); if (UpliftStatus.ToCanonical(req.Status) != UpliftStatus.ChangesRequested) { diff --git a/SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs b/SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs index a378b60..11502ef 100644 --- a/SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs +++ b/SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs @@ -261,6 +261,21 @@ public sealed class UpliftAmountPerCreationPathTests .SumAutoApprovedAmountForWorkOrderAsync(WorkOrderId, CancellationToken.None)); } + [Fact] + public async Task WorkOrderUpliftList_ShowsEachRequestsIncrease() + { + await using var context = CreateContext(); + await SeedWorkOrderAsync(context); + var workOrderRequestId = await CreatePendingWorkOrderRequestAsync(context, 90m); + var vendorRequestId = await SeedVendorRequestAsync(context, SecondDispatchId, currentNte: 600m, requestedTotal: 900m); + + var uplifts = await NewWorkOrderUpliftService(context) + .ListAsync(WorkOrderId, Admin(), CancellationToken.None); + + Assert.Equal(90m, Assert.Single(uplifts!, u => u.Id == workOrderRequestId).Amount); + Assert.Equal(300m, Assert.Single(uplifts!, u => u.Id == vendorRequestId).Amount); + } + [Fact] public void AmountDefinition_TranslatesToSqlServer_ForRowAndGroupedSums() { From 85f2e709c9ba498bc9cf66cf23f87ee90bb370dd Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 02:36:17 -0300 Subject: [PATCH 4/4] test(uplifts): board summary and notification delta use the per-path amount Co-Authored-By: Claude Opus 5.5 --- .../UpliftAmountPerCreationPathTests.cs | 23 +++++++++ .../WorkOrderBoardServiceTests.cs | 51 +++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs b/SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs index 11502ef..43220e1 100644 --- a/SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs +++ b/SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs @@ -276,6 +276,29 @@ public sealed class UpliftAmountPerCreationPathTests Assert.Equal(300m, Assert.Single(uplifts!, u => u.Id == vendorRequestId).Amount); } + [Theory] + [InlineData(DispatcherId, 90, "$90.00")] + [InlineData(null, 900, "$300.00")] + public void NotificationDeltaRow_ShowsTheRequestedIncrease(string? createdBy, int requestedNte, string expectedDelta) + { + var dispatch = new Dispatch { Id = PrimaryDispatchId, DispatchNumber = "DIS-10", NTEAmount = 600m }; + var request = new DispatchUpliftRequest + { + DispatchId = PrimaryDispatchId, + CurrentNTE = 600m, + RequestedNTE = requestedNte, + RequiredTier = 1, + createdby = createdBy, + }; + + // Builds the message content only; nothing is sent. + var (_, body) = UpliftNotificationMessage.BuildInitial(dispatch, request, "Acme HVAC", "https://example.test"); + + Assert.Contains( + $"Delta{expectedDelta}", + body); + } + [Fact] public void AmountDefinition_TranslatesToSqlServer_ForRowAndGroupedSums() { diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs index 96acd1b..b579460 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs @@ -451,6 +451,57 @@ public class WorkOrderBoardPendingUpliftTests Assert.Equal(1500m, dto.UpliftSummary.Amount); } + [Fact] + public async Task GetBoardRows_VendorPortalUplift_SummaryAmountIsTheRequestedIncrease() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); + context.Dispatches.Add(new Dispatch + { + Id = 10, + VendorId = 1, + WorkOrderId = 1, + NTEAmount = 600m, + ScheduledDate = new DateTime(2026, 6, 24, 8, 0, 0) + }); + context.workOrders.Add(new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + SiteCode = "BK5" + }); + // A vendor-portal request (no identity user) stores the requested NTE total. + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + Id = 100, + DispatchId = 10, + CurrentNTE = 600m, + RequestedNTE = 900m, + Status = "Pending", + RequiredTier = 1, + NotificationStatus = "Pending" + }); + await context.SaveChangesAsync(); + + var result = await new WorkOrderBoardDataService(context).GetBoardRowsAsync(new WorkOrderBoardQuery( + weekStart, + weekStart.AddDays(4), + null, + false, + null, + false, + null, + null)); + + var dto = WorkOrderBoardService.MapRawRow(Assert.Single(result.ScheduledRows), DateTime.UtcNow); + Assert.Equal(300m, dto.UpliftSummary!.Amount); + } + [Fact] public async Task GetBoardRows_ApprovedUplift_DoesNotBlock() {