From aaeeb90f1ab14ebaa3587833343cdf11808e86d7 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 12 Aug 2026 09:43:10 -0300 Subject: [PATCH] feat(work-orders): expose pendingUpliftCount on board rows (SH-188) Aggregate Pending uplift requests across all dispatches tied to a work order so the board/search/detail contract can drive the Completed gate. --- .../Helpers/WorkOrderBoardProjection.cs | 13 +- .../WorkOrderAdvancedSearchDataService.cs | 2 +- .../WorkOrderBoardDataService.cs | 4 +- .../Interfaces/WorkOrderBoardModels.cs | 1 + SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs | 5 + .../Implementation/WorkOrderBoardService.cs | 3 +- .../WorkOrderBoardServiceTests.cs | 163 ++++++++++++++++++ 7 files changed, 187 insertions(+), 4 deletions(-) diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index 190bb8f..9ccfbd8 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -7,6 +7,7 @@ namespace SeaHaven.DataServices.Helpers public static class WorkOrderBoardProjection { public static async Task> ProjectRowsAsync( + ApplicationDbContext context, IQueryable query, bool isUnscheduled) { @@ -70,7 +71,16 @@ namespace SeaHaven.DataServices.Helpers w.FlagColor, w.PrimaryDispatchId, w.RowVersion, - DispatchRowVersion = w.PrimaryDispatch != null ? w.PrimaryDispatch.RowVersion : null + DispatchRowVersion = w.PrimaryDispatch != null ? w.PrimaryDispatch.RowVersion : null, + // SH-188: aggregate Pending uplifts on every dispatch tied to this WO + // (primary WorkOrderId and multi-WO DispatchWorkOrders links). + PendingUpliftCount = context.DispatchUpliftRequests.Count(u => + u.Status == "Pending" + && (u.IsDeleted == null || u.IsDeleted == false) + && u.Dispatch != null + && ( + u.Dispatch.WorkOrderId == w.Id + || u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id))) }) .ToListAsync(); @@ -122,6 +132,7 @@ namespace SeaHaven.DataServices.Helpers w.PrimaryDispatchId, w.RowVersion, w.DispatchRowVersion, + w.PendingUpliftCount, isUnscheduled); }).ToList(); } diff --git a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs index 7935751..de1f4d2 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs @@ -40,7 +40,7 @@ namespace SeaHaven.DataServices.Implementation .Skip(query.Page * query.PageSize) .Take(query.PageSize); - var rows = await WorkOrderBoardProjection.ProjectRowsAsync(paged, isUnscheduled: false); + var rows = await WorkOrderBoardProjection.ProjectRowsAsync(_context, paged, isUnscheduled: false); return new WorkOrderAdvancedSearchResult(rows, totalCount); } diff --git a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs index 00d4772..21d2d5a 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs @@ -47,9 +47,11 @@ namespace SeaHaven.DataServices.Implementation } var scheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync( + _context, scheduledBase.OrderBy(w => w.ScheduledDate).ThenBy(w => w.InternalWONumber), isUnscheduled: false); var unscheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync( + _context, unscheduledBase.OrderBy(w => w.InternalWONumber), isUnscheduled: true); @@ -79,7 +81,7 @@ namespace SeaHaven.DataServices.Implementation if (accountId.HasValue) query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value); - var rows = await WorkOrderBoardProjection.ProjectRowsAsync(query, isUnscheduled: false); + var rows = await WorkOrderBoardProjection.ProjectRowsAsync(_context, query, isUnscheduled: false); return rows.FirstOrDefault(); } diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs index 88ea6e8..ca27ca0 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -52,6 +52,7 @@ namespace SeaHaven.DataServices.Interfaces int? PrimaryDispatchId, byte[]? RowVersion, byte[]? DispatchRowVersion, + int PendingUpliftCount, bool IsUnscheduled); public record WorkOrderBoardQueryResult( diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs index 21e4fff..de3fe67 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -62,6 +62,11 @@ namespace SeaHaven.Services.DTOs public int? PrimaryDispatchId { get; set; } public byte[]? RowVersion { get; set; } public byte[]? DispatchRowVersion { get; set; } + /// + /// Count of uplift requests with status Pending across all dispatches tied to this work order (SH-188). + /// Authoritative completion-gate signal for the board/search/detail row contract. + /// + public int PendingUpliftCount { get; set; } } public class WorkOrderBoardPatchRequestDto diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index ea1443c..04bf63c 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -126,7 +126,8 @@ namespace SeaHaven.Services.Implementation FlagColor = row.FlagColor, PrimaryDispatchId = row.PrimaryDispatchId, RowVersion = row.RowVersion, - DispatchRowVersion = row.DispatchRowVersion + DispatchRowVersion = row.DispatchRowVersion, + PendingUpliftCount = row.PendingUpliftCount }; } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs index da348fd..bda4319 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs @@ -158,6 +158,169 @@ public class WorkOrderBoardDataServiceTests } } +public class WorkOrderBoardPendingUpliftTests +{ + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + [Fact] + public async Task GetBoardRows_PendingUpliftOnPrimaryDispatch_SurfacesCount() + { + 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, + 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" + }); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + Id = 100, + DispatchId = 10, + RequestedNTE = 1500m, + Status = "Pending", + RequiredTier = 1, + NotificationStatus = "Pending" + }); + await context.SaveChangesAsync(); + + var service = new WorkOrderBoardDataService(context); + var result = await service.GetBoardRowsAsync(new WorkOrderBoardQuery( + weekStart, + weekStart.AddDays(4), + null, + false, + null, + false, + null, + null)); + + Assert.Single(result.ScheduledRows); + Assert.Equal(1, result.ScheduledRows[0].PendingUpliftCount); + + var dto = WorkOrderBoardService.MapRawRow(result.ScheduledRows[0], DateTime.UtcNow); + Assert.Equal(1, dto.PendingUpliftCount); + } + + [Fact] + public async Task GetBoardRows_ApprovedUplift_DoesNotBlock() + { + 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 + }); + context.workOrders.Add(new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10 + }); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + Id = 100, + DispatchId = 10, + RequestedNTE = 1500m, + Status = "Approved", + RequiredTier = 1, + NotificationStatus = "Sent" + }); + await context.SaveChangesAsync(); + + var service = new WorkOrderBoardDataService(context); + var result = await service.GetBoardRowsAsync(new WorkOrderBoardQuery( + weekStart, + weekStart.AddDays(4), + null, + false, + null, + false, + null, + null)); + + Assert.Single(result.ScheduledRows); + Assert.Equal(0, result.ScheduledRows[0].PendingUpliftCount); + } + + [Fact] + public async Task GetBoardRows_PendingUpliftViaDispatchWorkOrderLink_SurfacesCount() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); + // Secondary link only via DispatchWorkOrders (no WorkOrderId / PrimaryDispatch). + context.Dispatches.Add(new Dispatch + { + Id = 20, + VendorId = 1, + WorkOrderId = null + }); + context.DispatchWorkOrders.Add(new DispatchWorkOrder + { + DispatchId = 20, + WorkOrderId = 2 + }); + context.workOrders.Add(new WorkOrder + { + Id = 2, + InternalWONumber = "10000000002", + ScheduledDate = new DateTime(2026, 6, 24), + LifecycleStatus = LifecycleStatus.Scheduled + }); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + Id = 200, + DispatchId = 20, + RequestedNTE = 900m, + Status = "Pending", + RequiredTier = 1, + NotificationStatus = "Pending" + }); + await context.SaveChangesAsync(); + + var service = new WorkOrderBoardDataService(context); + var result = await service.GetBoardRowsAsync(new WorkOrderBoardQuery( + weekStart, + weekStart.AddDays(4), + null, + false, + null, + false, + null, + null)); + + Assert.Single(result.ScheduledRows); + Assert.Equal(2, result.ScheduledRows[0].Id); + Assert.Equal(1, result.ScheduledRows[0].PendingUpliftCount); + } +} + public class WorkOrderBoardServiceTests { private static ApplicationDbContext CreateContext()