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 <noreply@anthropic.com>
This commit is contained in:
Alexandre Brandizzi 2026-09-25 02:32:58 -03:00
parent f40ad7c1ef
commit eb2b442775
10 changed files with 394 additions and 45 deletions

View file

@ -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));
}

View file

@ -100,6 +100,8 @@ namespace Data.SeaHavenIndustries
public string? DecidedByLastName { get; set; }
public decimal? CurrentNTE { get; set; }
public decimal RequestedNTE { get; set; }
/// <summary>The requested NTE increase; see UpliftAmount in SeaHaven.DataServices.</summary>
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; }
/// <summary>The requested NTE increase; see UpliftAmount in SeaHaven.DataServices.</summary>
public decimal Amount { get; set; }
public string? VendorReason { get; set; }
public string? Status { get; set; }
public int RequiredTier { get; set; }

View file

@ -0,0 +1,33 @@
using System.Linq.Expressions;
using Data.SeaHavenIndustries;
namespace SeaHaven.DataServices.Helpers
{
/// <summary>
/// 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.
/// </summary>
public static class UpliftAmount
{
/// <summary>Translatable form for EF queries and aggregates.</summary>
public static readonly Expression<Func<DispatchUpliftRequest, decimal>> ForQuery =
u => u.createdby == null
? u.RequestedNTE - (u.CurrentNTE ?? 0m)
: u.RequestedNTE;
private static readonly Func<DispatchUpliftRequest, decimal> 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,
});
}
}

View file

@ -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(

View file

@ -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,

View file

@ -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<decimal> 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<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(

View file

@ -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,

View file

@ -1,4 +1,5 @@
using Data.SeaHavenIndustries;
using SeaHaven.DataServices.Helpers;
namespace SeaHaven.Services.Implementation
{
@ -23,7 +24,7 @@ namespace SeaHaven.Services.Implementation
<table style='border-collapse:collapse;font-family:Arial,sans-serif;'>
<tr><td style='padding:4px 10px;'><strong>Current NTE</strong></td><td style='padding:4px 10px;'>${req.CurrentNTE ?? 0m:F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Requested NTE</strong></td><td style='padding:4px 10px;'>${req.RequestedNTE:F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Delta</strong></td><td style='padding:4px 10px;'>${(req.RequestedNTE - (req.CurrentNTE ?? 0m)):F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Delta</strong></td><td style='padding:4px 10px;'>${UpliftAmount.Of(req):F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Required Approval</strong></td><td style='padding:4px 10px;'>{tierText}</td></tr>
</table>
<h3>Vendor Reason</h3>

View file

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

View file

@ -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<ApplicationDbContext>()
.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<string> { 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<int> 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<int> 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<WorkOrderUpliftDto?> 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<ApplicationDbContext>()
.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)
{
}
}
}