diff --git a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs index ad539bd..39d8d91 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs @@ -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] diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 6c52434..5cb7013 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -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);