mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
Merge pull request #178 from Sea-Haven-Industries/fix/ab/sh-398-pending-exposure
fix(uplifts): one per-path uplift amount for queue, approval and exposure
This commit is contained in:
commit
3b69b9fbcc
15 changed files with 548 additions and 52 deletions
|
|
@ -278,7 +278,8 @@ public sealed class UpliftQueueReadTests
|
||||||
item.RequestedByName.Should().Be("Gateway");
|
item.RequestedByName.Should().Be("Gateway");
|
||||||
item.RequestedAt.Should().Be(new DateTime(2026, 3, 2));
|
item.RequestedAt.Should().Be(new DateTime(2026, 3, 2));
|
||||||
item.VendorReason.Should().Be("Scope grew");
|
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");
|
item.Status.Should().Be("Pending");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -558,7 +559,7 @@ public sealed class UpliftQueueReadTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task List_PendingExposureTotal_SumsGrantedAmountsAcrossAllPendingRequests()
|
public async Task List_PendingExposureTotal_SumsEachPendingRequestsIncreaseAcrossTheQueue()
|
||||||
{
|
{
|
||||||
using var context = NewContext();
|
using var context = NewContext();
|
||||||
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
|
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
|
||||||
|
|
@ -567,10 +568,10 @@ public sealed class UpliftQueueReadTests
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
context.DispatchUpliftRequests.AddRange(
|
context.DispatchUpliftRequests.AddRange(
|
||||||
// Vendor-portal rows (createdby null) store the requested new NTE total,
|
// 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, 1), requested: 400m, currentNte: 100m),
|
||||||
Request(dispatch, "Pending", new DateTime(2026, 3, 2), requested: 600m, currentNte: 250m),
|
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"),
|
Request(dispatch, "Pending", new DateTime(2026, 3, 3), requested: 90m, currentNte: 600m, createdBy: "user-7"),
|
||||||
// Non-pending rows never add pending exposure.
|
// 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));
|
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);
|
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 ---
|
// --- Approved-on-WO exposure totals ---
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -419,6 +419,44 @@ public sealed class UpliftWorkflowTests
|
||||||
context.WorkOrderAuditLogs.Should().Contain(a => a.Action == "uplift_revised");
|
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<KeyNotFoundException>();
|
||||||
|
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]
|
[Fact]
|
||||||
public async Task Withdraw_Pending_TransitionsToWithdrawn_SetsDecidedAt()
|
public async Task Withdraw_Pending_TransitionsToWithdrawn_SetsDecidedAt()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,8 @@ namespace Data.SeaHavenIndustries
|
||||||
public string? DecidedByLastName { get; set; }
|
public string? DecidedByLastName { get; set; }
|
||||||
public decimal? CurrentNTE { get; set; }
|
public decimal? CurrentNTE { get; set; }
|
||||||
public decimal RequestedNTE { 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? VendorReason { get; set; }
|
||||||
public int RequiredTier { get; set; }
|
public int RequiredTier { get; set; }
|
||||||
public string? Status { get; set; }
|
public string? Status { get; set; }
|
||||||
|
|
@ -127,6 +129,8 @@ namespace Data.SeaHavenIndustries
|
||||||
public int DispatchId { get; set; }
|
public int DispatchId { get; set; }
|
||||||
public decimal? CurrentNTE { get; set; }
|
public decimal? CurrentNTE { get; set; }
|
||||||
public decimal RequestedNTE { 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? VendorReason { get; set; }
|
||||||
public string? Status { get; set; }
|
public string? Status { get; set; }
|
||||||
public int RequiredTier { get; set; }
|
public int RequiredTier { get; set; }
|
||||||
|
|
|
||||||
33
SeaHaven.DataServices/Helpers/UpliftAmount.cs
Normal file
33
SeaHaven.DataServices/Helpers/UpliftAmount.cs
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -255,6 +255,8 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
WorkOrderId = u.Dispatch!.WorkOrderId!.Value,
|
WorkOrderId = u.Dispatch!.WorkOrderId!.Value,
|
||||||
u.Status,
|
u.Status,
|
||||||
u.RequestedNTE,
|
u.RequestedNTE,
|
||||||
|
u.CurrentNTE,
|
||||||
|
u.createdby,
|
||||||
u.CreatedDate
|
u.CreatedDate
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -268,6 +270,8 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
WorkOrderId = link.WorkOrderId,
|
WorkOrderId = link.WorkOrderId,
|
||||||
u.Status,
|
u.Status,
|
||||||
u.RequestedNTE,
|
u.RequestedNTE,
|
||||||
|
u.CurrentNTE,
|
||||||
|
u.createdby,
|
||||||
u.CreatedDate
|
u.CreatedDate
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -276,7 +280,7 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
l.UpliftId,
|
l.UpliftId,
|
||||||
l.WorkOrderId,
|
l.WorkOrderId,
|
||||||
l.Status,
|
l.Status,
|
||||||
l.RequestedNTE,
|
UpliftAmount.Of(l.createdby, l.RequestedNTE, l.CurrentNTE),
|
||||||
l.CreatedDate)));
|
l.CreatedDate)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -316,7 +320,7 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
pendingCount,
|
pendingCount,
|
||||||
HasUplift: true,
|
HasUplift: true,
|
||||||
primary?.Status,
|
primary?.Status,
|
||||||
primary?.RequestedNTE);
|
primary?.Amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -337,7 +341,7 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
int UpliftId,
|
int UpliftId,
|
||||||
int WorkOrderId,
|
int WorkOrderId,
|
||||||
string Status,
|
string Status,
|
||||||
decimal RequestedNTE,
|
decimal Amount,
|
||||||
DateTime? CreatedDate);
|
DateTime? CreatedDate);
|
||||||
|
|
||||||
private sealed record BoardUpliftAgg(
|
private sealed record BoardUpliftAgg(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SeaHaven.DataServices.Dto;
|
using SeaHaven.DataServices.Dto;
|
||||||
|
using SeaHaven.DataServices.Helpers;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.DataServices.Models;
|
using SeaHaven.DataServices.Models;
|
||||||
|
|
||||||
|
|
@ -457,6 +458,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
u.Id,
|
u.Id,
|
||||||
u.CurrentNTE,
|
u.CurrentNTE,
|
||||||
u.RequestedNTE,
|
u.RequestedNTE,
|
||||||
|
u.createdby,
|
||||||
u.VendorReason,
|
u.VendorReason,
|
||||||
u.Status,
|
u.Status,
|
||||||
u.RequiredTier,
|
u.RequiredTier,
|
||||||
|
|
@ -474,7 +476,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
Id = u.Id,
|
Id = u.Id,
|
||||||
CurrentNTE = u.CurrentNTE,
|
CurrentNTE = u.CurrentNTE,
|
||||||
RequestedNTE = u.RequestedNTE,
|
RequestedNTE = u.RequestedNTE,
|
||||||
Delta = u.RequestedNTE - (u.CurrentNTE ?? 0m),
|
Delta = UpliftAmount.Of(u.createdby, u.RequestedNTE, u.CurrentNTE),
|
||||||
VendorReason = u.VendorReason,
|
VendorReason = u.VendorReason,
|
||||||
Status = u.Status,
|
Status = u.Status,
|
||||||
RequiredTier = u.RequiredTier,
|
RequiredTier = u.RequiredTier,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ using Data.SeaHavenIndustries;
|
||||||
using Data.SeaHavenIndustries.Enums;
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SeaHaven.DataServices.Exceptions;
|
using SeaHaven.DataServices.Exceptions;
|
||||||
|
using SeaHaven.DataServices.Helpers;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
namespace SeaHaven.DataServices.Implementation
|
namespace SeaHaven.DataServices.Implementation
|
||||||
|
|
@ -109,6 +110,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
DecidedByLastName = x.decUser != null ? x.decUser.LastName : null,
|
DecidedByLastName = x.decUser != null ? x.decUser.LastName : null,
|
||||||
CurrentNTE = x.u.CurrentNTE,
|
CurrentNTE = x.u.CurrentNTE,
|
||||||
RequestedNTE = x.u.RequestedNTE,
|
RequestedNTE = x.u.RequestedNTE,
|
||||||
|
Amount = UpliftAmount.Of(x.u.createdby, x.u.RequestedNTE, x.u.CurrentNTE),
|
||||||
VendorReason = x.u.VendorReason,
|
VendorReason = x.u.VendorReason,
|
||||||
RequiredTier = x.u.RequiredTier,
|
RequiredTier = x.u.RequiredTier,
|
||||||
Status = x.u.Status,
|
Status = x.u.Status,
|
||||||
|
|
@ -128,16 +130,6 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
return (total, items);
|
return (total, items);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<decimal> 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<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return await (from u in _context.DispatchUpliftRequests
|
return await (from u in _context.DispatchUpliftRequests
|
||||||
|
|
@ -153,6 +145,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
DispatchId = u.DispatchId,
|
DispatchId = u.DispatchId,
|
||||||
CurrentNTE = u.CurrentNTE,
|
CurrentNTE = u.CurrentNTE,
|
||||||
RequestedNTE = u.RequestedNTE,
|
RequestedNTE = u.RequestedNTE,
|
||||||
|
Amount = UpliftAmount.Of(u.createdby, u.RequestedNTE, u.CurrentNTE),
|
||||||
VendorReason = u.VendorReason,
|
VendorReason = u.VendorReason,
|
||||||
Status = u.Status,
|
Status = u.Status,
|
||||||
RequiredTier = u.RequiredTier,
|
RequiredTier = u.RequiredTier,
|
||||||
|
|
@ -193,6 +186,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
DispatchId = u.DispatchId,
|
DispatchId = u.DispatchId,
|
||||||
CurrentNTE = u.CurrentNTE,
|
CurrentNTE = u.CurrentNTE,
|
||||||
RequestedNTE = u.RequestedNTE,
|
RequestedNTE = u.RequestedNTE,
|
||||||
|
Amount = UpliftAmount.Of(u.createdby, u.RequestedNTE, u.CurrentNTE),
|
||||||
VendorReason = u.VendorReason,
|
VendorReason = u.VendorReason,
|
||||||
Status = u.Status,
|
Status = u.Status,
|
||||||
RequiredTier = u.RequiredTier,
|
RequiredTier = u.RequiredTier,
|
||||||
|
|
@ -402,25 +396,14 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
.Where(u => (u.IsDeleted == null || u.IsDeleted == false)
|
.Where(u => (u.IsDeleted == null || u.IsDeleted == false)
|
||||||
&& dispatchIds.Contains(u.DispatchId)
|
&& dispatchIds.Contains(u.DispatchId)
|
||||||
&& (u.Status == "Approved" || u.Status == "NoApprovalRequired"))
|
&& (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
|
.Select(g => new
|
||||||
{
|
{
|
||||||
DispatchId = g.Key,
|
g.Key.DispatchId,
|
||||||
// The two creation paths store different meanings in RequestedNTE.
|
g.Key.Status,
|
||||||
// Vendor-portal rows store the requested new NTE total, so the
|
Total = g.Sum()
|
||||||
// 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))
|
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
|
@ -438,17 +421,20 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
totals[workOrderId] = total;
|
totals[workOrderId] = total;
|
||||||
}
|
}
|
||||||
|
|
||||||
total.AutoApprovedTotal += sum.AutoApproved ?? 0m;
|
if (sum.Status == "NoApprovalRequired")
|
||||||
total.AdminApprovedTotal += sum.AdminApproved ?? 0m;
|
total.AutoApprovedTotal += sum.Total;
|
||||||
|
else
|
||||||
|
total.AdminApprovedTotal += sum.Total;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return totals.Values.ToList();
|
return totals.Values.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// SH-207: queue-wide pending exposure for the approvals header. Mirrors the
|
// Queue-wide pending exposure for the approvals header: the sum of the amount each
|
||||||
// approved-exposure convention (sum of RequestedNTE) over every non-deleted
|
// Pending queue row displays (UpliftAmount), over the same rows the Pending tab
|
||||||
// Pending request on a non-deleted dispatch, independent of page or filters.
|
// lists (non-deleted request on a non-deleted dispatch), independent of page or
|
||||||
|
// filters.
|
||||||
public async Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken)
|
public async Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return await _context.DispatchUpliftRequests
|
return await _context.DispatchUpliftRequests
|
||||||
|
|
@ -456,7 +442,8 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
&& u.Status == "Pending"
|
&& u.Status == "Pending"
|
||||||
&& u.Dispatch != null
|
&& u.Dispatch != null
|
||||||
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false))
|
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false))
|
||||||
.SumAsync(u => (decimal?)u.RequestedNTE, cancellationToken) ?? 0m;
|
.Select(UpliftAmount.ForQuery)
|
||||||
|
.SumAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(
|
public Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
{
|
{
|
||||||
Task<(int TotalCount, IReadOnlyList<UpliftListItemData> Items)> GetPagedAsync(
|
Task<(int TotalCount, IReadOnlyList<UpliftListItemData> Items)> GetPagedAsync(
|
||||||
string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken);
|
string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken);
|
||||||
Task<decimal> GetPendingExposureAsync(CancellationToken cancellationToken);
|
|
||||||
Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken);
|
Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken);
|
||||||
Task<IReadOnlyList<UpliftForWorkOrderData>> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
Task<IReadOnlyList<UpliftForWorkOrderData>> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
||||||
Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken);
|
Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
|
using SeaHaven.DataServices.Helpers;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
using SeaHaven.Services.Implementation;
|
using SeaHaven.Services.Implementation;
|
||||||
|
|
@ -42,7 +43,7 @@ namespace SeaHaven.Services.Helpers
|
||||||
{
|
{
|
||||||
Id = row.Id,
|
Id = row.Id,
|
||||||
Status = ToFrontendStatus(row.Status),
|
Status = ToFrontendStatus(row.Status),
|
||||||
Amount = row.RequestedNTE,
|
Amount = row.Amount,
|
||||||
Notes = row.VendorReason ?? "",
|
Notes = row.VendorReason ?? "",
|
||||||
RequestedAt = row.CreatedDate,
|
RequestedAt = row.CreatedDate,
|
||||||
RequestedById = row.CreatedByUserId,
|
RequestedById = row.CreatedByUserId,
|
||||||
|
|
@ -58,7 +59,7 @@ namespace SeaHaven.Services.Helpers
|
||||||
{
|
{
|
||||||
Id = request.Id,
|
Id = request.Id,
|
||||||
Status = ToFrontendStatus(request.Status),
|
Status = ToFrontendStatus(request.Status),
|
||||||
Amount = request.RequestedNTE,
|
Amount = UpliftAmount.Of(request),
|
||||||
Notes = request.VendorReason ?? "",
|
Notes = request.VendorReason ?? "",
|
||||||
RequestedAt = request.CreatedDate,
|
RequestedAt = request.CreatedDate,
|
||||||
RequestedById = request.createdby,
|
RequestedById = request.createdby,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
|
using SeaHaven.DataServices.Helpers;
|
||||||
|
|
||||||
namespace SeaHaven.Services.Implementation
|
namespace SeaHaven.Services.Implementation
|
||||||
{
|
{
|
||||||
|
|
@ -23,7 +24,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
<table style='border-collapse:collapse;font-family:Arial,sans-serif;'>
|
<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>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>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>
|
<tr><td style='padding:4px 10px;'><strong>Required Approval</strong></td><td style='padding:4px 10px;'>{tierText}</td></tr>
|
||||||
</table>
|
</table>
|
||||||
<h3>Vendor Reason</h3>
|
<h3>Vendor Reason</h3>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using SeaHaven.DataServices.Helpers;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.Services.Configuration;
|
using SeaHaven.Services.Configuration;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
@ -36,7 +37,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
public async Task<UpliftListResultDTO> ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken)
|
public async Task<UpliftListResultDTO> 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 (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
|
var mapped = items.Select(r => new UpliftListItemDTO
|
||||||
{
|
{
|
||||||
|
|
@ -56,7 +57,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
DecidedByName = ResolveRequestedByName(null, r.DecidedByFirstName, r.DecidedByLastName),
|
DecidedByName = ResolveRequestedByName(null, r.DecidedByFirstName, r.DecidedByLastName),
|
||||||
CurrentNTE = r.CurrentNTE,
|
CurrentNTE = r.CurrentNTE,
|
||||||
RequestedNTE = r.RequestedNTE,
|
RequestedNTE = r.RequestedNTE,
|
||||||
Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m),
|
Delta = r.Amount,
|
||||||
VendorReason = r.VendorReason,
|
VendorReason = r.VendorReason,
|
||||||
RequiredTier = r.RequiredTier,
|
RequiredTier = r.RequiredTier,
|
||||||
Status = UpliftStatus.ToCanonical(r.Status),
|
Status = UpliftStatus.ToCanonical(r.Status),
|
||||||
|
|
@ -115,7 +116,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
DispatchId = r.DispatchId,
|
DispatchId = r.DispatchId,
|
||||||
CurrentNTE = r.CurrentNTE,
|
CurrentNTE = r.CurrentNTE,
|
||||||
RequestedNTE = r.RequestedNTE,
|
RequestedNTE = r.RequestedNTE,
|
||||||
Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m),
|
Delta = r.Amount,
|
||||||
VendorReason = r.VendorReason,
|
VendorReason = r.VendorReason,
|
||||||
Status = UpliftStatus.ToCanonical(r.Status),
|
Status = UpliftStatus.ToCanonical(r.Status),
|
||||||
RequiredTier = r.RequiredTier,
|
RequiredTier = r.RequiredTier,
|
||||||
|
|
@ -157,7 +158,12 @@ namespace SeaHaven.Services.Implementation
|
||||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||||
var oldNTE = dispatch.NTEAmount ?? 0m;
|
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;
|
dispatch.LastModificationTime = now;
|
||||||
|
|
||||||
req.Status = UpliftStatus.Approved;
|
req.Status = UpliftStatus.Approved;
|
||||||
|
|
@ -172,7 +178,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
FieldName = $"Dispatch {dispatch.DispatchNumber} NTE",
|
FieldName = $"Dispatch {dispatch.DispatchNumber} NTE",
|
||||||
OldValue = $"${oldNTE:F2}",
|
OldValue = $"${oldNTE:F2}",
|
||||||
NewValue = $"${req.RequestedNTE:F2}",
|
NewValue = $"${dispatch.NTEAmount:F2}",
|
||||||
Action = "uplift_approved",
|
Action = "uplift_approved",
|
||||||
CreatedAt = now
|
CreatedAt = now
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -736,7 +736,9 @@ namespace SeaHaven.Services.Implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
var req = await _upliftData.GetByIdAndDispatchAsync(requestId, id, cancellationToken);
|
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)
|
if (UpliftStatus.ToCanonical(req.Status) != UpliftStatus.ChangesRequested)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
336
SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs
Normal file
336
SeaHavenIndustries.Tests/UpliftAmountPerCreationPathTests.cs
Normal file
|
|
@ -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<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 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(
|
||||||
|
$"<strong>Delta</strong></td><td style='padding:4px 10px;'>{expectedDelta}</td>",
|
||||||
|
body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -447,7 +447,6 @@ public class WorkOrderBoardCancelServiceTests
|
||||||
public Task<(int TotalCount, IReadOnlyList<UpliftListItemData> Items)> GetPagedAsync(
|
public Task<(int TotalCount, IReadOnlyList<UpliftListItemData> Items)> GetPagedAsync(
|
||||||
string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken) =>
|
string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken) =>
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
public Task<decimal> GetPendingExposureAsync(CancellationToken cancellationToken) => throw new NotSupportedException();
|
|
||||||
public Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
public Task<IReadOnlyList<UpliftForWorkOrderData>> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<IReadOnlyList<UpliftForWorkOrderData>> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
public Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
|
|
|
||||||
|
|
@ -451,6 +451,57 @@ public class WorkOrderBoardPendingUpliftTests
|
||||||
Assert.Equal(1500m, dto.UpliftSummary.Amount);
|
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]
|
[Fact]
|
||||||
public async Task GetBoardRows_ApprovedUplift_DoesNotBlock()
|
public async Task GetBoardRows_ApprovedUplift_DoesNotBlock()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue