diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index 190bb8f..3d43fe3 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,17 @@ 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 live 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.IsDeleted == null || u.Dispatch.IsDeleted == false) + && ( + u.Dispatch.WorkOrderId == w.Id + || u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id))) }) .ToListAsync(); @@ -122,6 +133,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/SeaHaven.Services/Implementation/WorkOrderDetailService.cs b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs index c8f47e7..0bb6eac 100644 --- a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs @@ -128,6 +128,7 @@ namespace SeaHaven.Services.Implementation PrimaryDispatchId = row.PrimaryDispatchId, RowVersion = row.RowVersion, DispatchRowVersion = row.DispatchRowVersion, + PendingUpliftCount = row.PendingUpliftCount, Description = extended?.Description, Trade = extended?.Trade, Problem = extended?.Problem, diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs index da348fd..92c4d36 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs @@ -158,6 +158,219 @@ 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); + } + + [Fact] + public async Task GetBoardRows_PendingUpliftOnSoftDeletedDispatch_DoesNotCount() + { + 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), + IsDeleted = true + }); + 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(0, result.ScheduledRows[0].PendingUpliftCount); + } +} + public class WorkOrderBoardServiceTests { private static ApplicationDbContext CreateContext() diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 8eeb94e..d6bd47e 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -170,6 +170,46 @@ public class WorkOrderDetailServiceTests Assert.Equal("WeekRolled", detail.Audit[0].Action); } + [Fact] + public async Task GetDetail_SurfacesPendingUpliftCountFromBoardRow() + { + var (context, service) = CreateSut(); + + 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", + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = DateTime.UtcNow.AddDays(1), + PrimaryDispatchId = 10, + SiteCode = "BK5", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + Id = 100, + DispatchId = 10, + RequestedNTE = 1500m, + Status = "Pending", + RequiredTier = 1, + NotificationStatus = "Pending" + }); + await context.SaveChangesAsync(); + + var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin()); + + Assert.NotNull(detail); + Assert.Equal(1, detail!.Info.PendingUpliftCount); + } + [Fact] public async Task GetAudit_ResolvesAssignToUserIdsToDisplayNames() {