diff --git a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs index 78fe0ac..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_SumsGrantedAmountsAcrossAllPendingRequests() + public async Task List_PendingExposureTotal_SumsEachPendingRequestsIncreaseAcrossTheQueue() { using var context = NewContext(); var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC"); @@ -567,10 +568,10 @@ public sealed class UpliftQueueReadTests 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. + // 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-path rows (createdby set) store the requested increment: 90. + // 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)); @@ -584,6 +585,38 @@ public sealed class UpliftQueueReadTests result.PendingExposureTotal.Should().Be(740m); } + [Fact] + public async Task List_PendingExposureTotal_EqualsSumOfPendingRowAmounts_ForBothCreationPaths() + { + 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: 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 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(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None); + + result.Items.Should().HaveCount(2); + 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)); + } + // --- Approved-on-WO exposure totals --- [Fact] 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/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 3c11978..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, @@ -128,16 +130,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 @@ -153,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, @@ -193,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, @@ -402,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); @@ -438,17 +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(); } - // 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 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 @@ -456,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, cancellationToken) ?? 0m; + .Select(UpliftAmount.ForQuery) + .SumAsync(cancellationToken); } 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/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 134ec66..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; @@ -36,7 +37,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 { @@ -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/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 new file mode 100644 index 0000000..43220e1 --- /dev/null +++ b/SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs @@ -0,0 +1,336 @@ +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 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); + } + + [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() + { + // 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) + { + } + } +} 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(); 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() {