fix(uplifts): correct queue exposure projections (SH-210)

This commit is contained in:
Alexandre Brandizzi 2026-09-16 22:19:43 -03:00
parent d366f319e9
commit 316d1e6a63
2 changed files with 74 additions and 4 deletions

View file

@ -86,6 +86,7 @@ public sealed class UpliftQueueReadTests
var vendor = new Vendor { CompanyName = vendorName, IsActive = true };
var workOrder = new WorkOrder
{
InternalWONumber = number,
WorkerOrderNumber = number,
SiteCode = site,
Service = service,
@ -280,6 +281,35 @@ public sealed class UpliftQueueReadTests
item.Status.Should().Be("Pending");
}
[Fact]
public async Task List_WorkOrderNumber_RendersInternalShNumber_NotTheCrmExternalId()
{
using var context = NewContext();
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
// Synced work orders carry the CRM external id in WorkerOrderNumber; the SH
// display number the board renders is stamped on InternalWONumber.
var workOrder = new WorkOrder
{
InternalWONumber = "10000000001",
WorkerOrderNumber = "CRM-EXT-77",
SiteCode = "SITE-EAST",
Service = "HVAC",
WorkerOrderTitle = "Repair"
};
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
context.DispatchUpliftRequests.Add(Request(dispatch, "Pending", new DateTime(2026, 3, 2)));
await context.SaveChangesAsync();
var service = NewService(context);
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
var item = result.Items.Should().ContainSingle().Subject;
item.WorkOrderNumber.Should().Be("10000000001");
item.WorkOrderNumber.Should().NotBe("CRM-EXT-77");
}
[Fact]
public async Task List_RequesterLabelFallsBackToCreatingUserName()
{
@ -353,7 +383,9 @@ public sealed class UpliftQueueReadTests
var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A");
var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B");
context.DispatchUpliftRequests.AddRange(
Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 300m),
// Vendor-portal row (createdby null): stores the requested new NTE total,
// so the granted amount is 400 - 100 = 300.
Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 400m, currentNte: 100m),
new DispatchUpliftRequest
{
DispatchId = dispatchA.Id,
@ -390,7 +422,7 @@ public sealed class UpliftQueueReadTests
RequiredTier = 1,
RequestedNTE = 999m
},
Request(dispatchB, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 75m));
Request(dispatchB, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 75m, currentNte: 0m));
await context.SaveChangesAsync();
var service = NewService(context);
@ -440,6 +472,32 @@ public sealed class UpliftQueueReadTests
total.AutoApprovedTotal.Should().Be(0m);
}
[Fact]
public async Task Exposure_AdminApprovedSumsGrantedAmountsPerCreationPath()
{
using var context = NewContext();
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A");
context.DispatchUpliftRequests.AddRange(
// Vendor-portal rows (createdby null) store the requested new NTE total:
// 1000 -> 1500 grants 500, then 1500 -> 1800 grants 300.
Request(dispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 1500m, currentNte: 1000m),
Request(dispatch, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), requested: 1800m, currentNte: 1500m),
// Work-order-path rows (createdby set) store the granted increment: 200.
Request(dispatch, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 200m, currentNte: 1800m, createdBy: "user-7"));
await context.SaveChangesAsync();
var data = new UpliftDataService(context);
var exposure = await data.GetApprovedExposureForWorkOrdersAsync(
new[] { workOrder.Id }, CancellationToken.None);
// Granted exposure is 500 + 300 + 200; summing raw RequestedNTE would
// double-count whole NTE totals and report 3500.
var total = exposure.Should().ContainSingle(e => e.WorkOrderId == workOrder.Id).Subject;
total.AdminApprovedTotal.Should().Be(1000m);
total.AutoApprovedTotal.Should().Be(0m);
}
// --- Read authorization (tier roles) ---
[Fact]

View file

@ -61,7 +61,10 @@ namespace SeaHaven.DataServices.Implementation
PONumber = x.d.PONumber,
WorkOrderId = x.d.WorkOrderId,
VendorCompanyName = x.v != null ? x.v.CompanyName : x.u.RequestedByVendorName,
WorkOrderNumber = x.wo != null ? x.wo.WorkerOrderNumber : null,
// The queue renders the internal display number (InternalWONumber, what the
// board shows); WorkerOrderNumber holds the CRM external id on synced
// work orders and must not leak into the queue display.
WorkOrderNumber = x.wo != null ? x.wo.InternalWONumber : null,
WorkOrderSiteCode = x.wo != null ? x.wo.SiteCode : null,
WorkOrderService = x.wo != null ? x.wo.Service : null,
RequestedByVendorName = x.u.RequestedByVendorName,
@ -339,8 +342,17 @@ namespace SeaHaven.DataServices.Implementation
DispatchId = g.Key,
AutoApproved = g.Where(x => x.Status == "NoApprovalRequired")
.Sum(x => (decimal?)x.RequestedNTE),
// 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.
AdminApproved = g.Where(x => x.Status == "Approved")
.Sum(x => (decimal?)x.RequestedNTE)
.Sum(x => (decimal?)(x.createdby == null
? x.RequestedNTE - (x.CurrentNTE ?? 0m)
: x.RequestedNTE))
})
.ToListAsync(cancellationToken);