diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs index 33cec3f..c45b1c6 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs @@ -54,7 +54,8 @@ namespace Api.SeaHavenIndustries.Controllers [FromQuery] bool myWorkOrders = false, [FromQuery] List? types = null, [FromQuery] bool overdue = false, - [FromQuery] string? search = null) + [FromQuery] string? search = null, + CancellationToken cancellationToken = default) { var resolvedWeekEnd = weekEnd ?? weekStart.AddDays(4); var weekValidationError = WorkOrderOperationalWeek.ValidateWeekWindow(weekStart, resolvedWeekEnd); @@ -75,7 +76,7 @@ namespace Api.SeaHavenIndustries.Controllers Search = search }; - var result = await _workOrderBoardService.GetBoardAsync(query, User, userId); + var result = await _workOrderBoardService.GetBoardAsync(query, User, userId, cancellationToken); return Ok(result); } catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index 015c2ad..aaf1778 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -10,7 +10,8 @@ namespace SeaHaven.DataServices.Helpers public static async Task> ProjectRowsAsync( ApplicationDbContext context, IQueryable query, - bool isUnscheduled) + bool isUnscheduled, + CancellationToken cancellationToken = default) { var rows = await query .Select(w => new @@ -81,48 +82,14 @@ namespace SeaHaven.DataServices.Helpers w.FlagColor, w.PrimaryDispatchId, w.RowVersion, - 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))), - HasUplift = context.DispatchUpliftRequests.Any(u => - (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))), - PrimaryUpliftStatus = context.DispatchUpliftRequests - .Where(u => - (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))) - .OrderByDescending(u => u.CreatedDate) - .Select(u => u.Status) - .FirstOrDefault(), - PrimaryUpliftAmount = context.DispatchUpliftRequests - .Where(u => - (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))) - .OrderByDescending(u => u.CreatedDate) - .Select(u => (decimal?)u.RequestedNTE) - .FirstOrDefault() + DispatchRowVersion = w.PrimaryDispatch != null ? w.PrimaryDispatch.RowVersion : null }) - .ToListAsync(); + .ToListAsync(cancellationToken); + + if (rows.Count == 0) + return Array.Empty(); + + var upliftByWorkOrder = await LoadUpliftAggregatesAsync(context, query, cancellationToken); return rows.Select(w => { @@ -136,6 +103,7 @@ namespace SeaHaven.DataServices.Helpers w.VendorId, w.VendorName, w.PrimaryDispatchStatus); + upliftByWorkOrder.TryGetValue(w.Id, out var uplift); return new WorkOrderBoardRawRow( w.Id, @@ -178,10 +146,10 @@ namespace SeaHaven.DataServices.Helpers w.PrimaryDispatchId, w.RowVersion, w.DispatchRowVersion, - w.PendingUpliftCount, - w.HasUplift, - w.PrimaryUpliftStatus, - w.PrimaryUpliftAmount, + uplift?.PendingCount ?? 0, + uplift?.HasUplift ?? false, + uplift?.PrimaryStatus, + uplift?.PrimaryAmount, w.PrimaryDispatchStatus, w.AvetaRequired, w.HasAvetaDocument, @@ -190,5 +158,125 @@ namespace SeaHaven.DataServices.Helpers isUnscheduled); }).ToList(); } + + /// + /// SH-188: one round-trip for live uplifts on WOs in + /// (primary WorkOrderId and multi-WO DispatchWorkOrders links). Uses the same + /// IQueryable as a SQL subquery so large boards do not hit the 2100-parameter cap. + /// + private static async Task> LoadUpliftAggregatesAsync( + ApplicationDbContext context, + IQueryable workOrders, + CancellationToken cancellationToken) + { + var workOrderIds = workOrders.Select(w => w.Id); + + var live = context.DispatchUpliftRequests + .AsNoTracking() + .Where(u => + (u.IsDeleted == null || u.IsDeleted == false) + && u.Dispatch != null + && (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)); + + var fromPrimary = live + .Where(u => + u.Dispatch!.WorkOrderId != null + && workOrderIds.Contains(u.Dispatch.WorkOrderId.Value)) + .Select(u => new + { + UpliftId = u.Id, + WorkOrderId = u.Dispatch!.WorkOrderId!.Value, + u.Status, + u.RequestedNTE, + u.CreatedDate + }); + + var fromLinks = live + .SelectMany( + u => u.Dispatch!.DispatchWorkOrders! + .Where(link => workOrderIds.Contains(link.WorkOrderId)), + (u, link) => new + { + UpliftId = u.Id, + WorkOrderId = link.WorkOrderId, + u.Status, + u.RequestedNTE, + u.CreatedDate + }); + + var links = await fromPrimary.Concat(fromLinks).ToListAsync(cancellationToken); + return AggregateUpliftLinks(links.Select(l => new UpliftLinkRow( + l.UpliftId, + l.WorkOrderId, + l.Status, + l.RequestedNTE, + l.CreatedDate))); + } + + private static Dictionary AggregateUpliftLinks(IEnumerable links) + { + var seen = new HashSet<(int UpliftId, int WorkOrderId)>(); + var byWorkOrder = new Dictionary>(); + + foreach (var link in links) + { + if (!seen.Add((link.UpliftId, link.WorkOrderId))) + continue; + + if (!byWorkOrder.TryGetValue(link.WorkOrderId, out var group)) + { + group = new List(); + byWorkOrder[link.WorkOrderId] = group; + } + + group.Add(link); + } + + var result = new Dictionary(byWorkOrder.Count); + foreach (var (workOrderId, group) in byWorkOrder) + { + var pendingCount = 0; + UpliftLinkRow? primary = null; + foreach (var item in group) + { + if (item.Status == "Pending") + pendingCount++; + if (primary == null || IsNewer(item.CreatedDate, primary.CreatedDate)) + primary = item; + } + + result[workOrderId] = new BoardUpliftAgg( + pendingCount, + HasUplift: true, + primary?.Status, + primary?.RequestedNTE); + } + + return result; + } + + private static bool IsNewer(DateTime? candidate, DateTime? current) + { + if (candidate == current) + return false; + if (candidate is null) + return false; + if (current is null) + return true; + return candidate.Value > current.Value; + } + + private sealed record UpliftLinkRow( + int UpliftId, + int WorkOrderId, + string Status, + decimal RequestedNTE, + DateTime? CreatedDate); + + private sealed record BoardUpliftAgg( + int PendingCount, + bool HasUplift, + string? PrimaryStatus, + decimal? PrimaryAmount); } } diff --git a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs index 21d2d5a..ba5af55 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs @@ -1,5 +1,4 @@ using Data.SeaHavenIndustries; -using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; @@ -15,10 +14,12 @@ namespace SeaHaven.DataServices.Implementation _context = context; } - public async Task GetBoardRowsAsync(WorkOrderBoardQuery query) + public async Task GetBoardRowsAsync( + WorkOrderBoardQuery query, + CancellationToken cancellationToken = default) { var weekStartDate = query.WeekStart.ToDateTime(TimeOnly.MinValue); - var weekEndDate = query.WeekEnd.ToDateTime(TimeOnly.MinValue); + var weekEndExclusive = query.WeekEnd.AddDays(1).ToDateTime(TimeOnly.MinValue); var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); if (query.AccountId.HasValue) @@ -29,33 +30,27 @@ namespace SeaHaven.DataServices.Implementation var scheduledBase = baseQuery.Where(w => (w.ScheduledDate != null - && w.ScheduledDate.Value.Date >= weekStartDate.Date - && w.ScheduledDate.Value.Date <= weekEndDate.Date) + && w.ScheduledDate >= weekStartDate + && w.ScheduledDate < weekEndExclusive) || (w.ScheduleWeekOnly == true && w.TargetWeek == query.WeekStart)); - var unscheduledBase = baseQuery.Where(w => - w.ScheduledDate == null - && !LifecycleStatusSets.Terminal.Contains(w.LifecycleStatus)); - - var scheduledTotal = await scheduledBase.CountAsync(); + var scheduledTotal = await scheduledBase.CountAsync(cancellationToken); var normalizedSearch = WorkOrderBoardSearchFilter.NormalizeSearch(query.Search); if (normalizedSearch != null) - { scheduledBase = WorkOrderBoardSearchFilter.Apply(scheduledBase, normalizedSearch); - unscheduledBase = WorkOrderBoardSearchFilter.Apply(unscheduledBase, normalizedSearch); - } 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); + isUnscheduled: false, + cancellationToken); - return new WorkOrderBoardQueryResult(scheduledRows, unscheduledRows, scheduledTotal); + // Weekly board is scheduled-in-week only. Undated rows belong on GET /board/search. + return new WorkOrderBoardQueryResult( + scheduledRows, + Array.Empty(), + scheduledTotal); } public async Task> GetDispatcherLookupsAsync() @@ -74,14 +69,18 @@ namespace SeaHaven.DataServices.Implementation .ToListAsync(); } - public async Task GetBoardRowByIdAsync(int workOrderId, int? accountId = null) + public async Task GetBoardRowByIdAsync( + int workOrderId, + int? accountId = null, + CancellationToken cancellationToken = default) { var query = WorkOrderBoardQueryFilters.ApplyBaseScope( _context.workOrders.AsNoTracking().Where(w => w.Id == workOrderId)); if (accountId.HasValue) query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value); - var rows = await WorkOrderBoardProjection.ProjectRowsAsync(_context, query, isUnscheduled: false); + var rows = await WorkOrderBoardProjection.ProjectRowsAsync( + _context, query, isUnscheduled: false, cancellationToken); return rows.FirstOrDefault(); } diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderBoardDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderBoardDataService.cs index ee1d2ef..28d5773 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderBoardDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderBoardDataService.cs @@ -4,9 +4,14 @@ namespace SeaHaven.DataServices.Interfaces { public interface IWorkOrderBoardDataService { - Task GetBoardRowsAsync(WorkOrderBoardQuery query); + Task GetBoardRowsAsync( + WorkOrderBoardQuery query, + CancellationToken cancellationToken = default); Task> GetDispatcherLookupsAsync(); - Task GetBoardRowByIdAsync(int workOrderId, int? accountId = null); + Task GetBoardRowByIdAsync( + int workOrderId, + int? accountId = null, + CancellationToken cancellationToken = default); Task InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId); } } diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs index 5e960d3..c4704ea 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -15,6 +15,11 @@ namespace SeaHaven.Services.DTOs public string? Search { get; set; } } + /// + /// Scheduled-in-week counts for GET /board. is the week + /// match before the bar search filter; is after it. + /// Undated work orders are not included. + /// public class WorkOrderBoardCountsDto { public int Returned { get; set; } @@ -115,6 +120,10 @@ namespace SeaHaven.Services.DTOs public DateOnly WeekStart { get; set; } public DateOnly WeekEnd { get; set; } public WorkOrderBoardCountsDto Counts { get; set; } = new(); + /// + /// Always empty on the weekly board. Undated rows are served by GET /board/search. + /// Kept so existing clients can keep reading the field. + /// public List Unscheduled { get; set; } = new(); public List Scheduled { get; set; } = new(); } diff --git a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs index 6185f99..071acf0 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs @@ -10,7 +10,7 @@ namespace SeaHaven.Services.Implementation public class WorkOrderAdvancedSearchService : IWorkOrderAdvancedSearchService { private const int DefaultPageSize = 100; - private const int MaxPageSize = 100; + private const int MaxPageSize = 200; private readonly IWorkOrderAdvancedSearchDataService _searchDataService; private readonly IWorkOrderAccountResolver _accountResolver; diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index 335e71c..972e2f1 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -24,7 +24,8 @@ namespace SeaHaven.Services.Implementation public async Task GetBoardAsync( WorkOrderBoardQueryDto query, ClaimsPrincipal user, - string? currentUserId) + string? currentUserId, + CancellationToken cancellationToken = default) { var accountId = _accountResolver.ResolveAccountFilter(user); var weekStart = query.WeekStart; @@ -45,7 +46,7 @@ namespace SeaHaven.Services.Implementation currentUserId, accountId); - var result = await _boardDataService.GetBoardRowsAsync(dataQuery); + var result = await _boardDataService.GetBoardRowsAsync(dataQuery, cancellationToken); var utcNow = DateTime.UtcNow; return new WorkOrderBoardResponseDto diff --git a/SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs b/SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs index 9aa53d9..7b5094e 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs @@ -8,7 +8,8 @@ namespace SeaHaven.Services.Interfaces Task GetBoardAsync( WorkOrderBoardQueryDto query, ClaimsPrincipal user, - string? currentUserId); + string? currentUserId, + CancellationToken cancellationToken = default); Task> GetDispatcherLookupsAsync(); Task GetBoardRowAsync(int workOrderId, ClaimsPrincipal user); } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs index 7f98f9a..9c8dd75 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs @@ -141,8 +141,7 @@ public class WorkOrderBoardSearchCountsTests Assert.Equal(2, result.ScheduledTotalBeforeSearch); Assert.Single(result.ScheduledRows); Assert.Equal("BK5", result.ScheduledRows[0].SiteCode); - Assert.Single(result.UnscheduledRows); - Assert.Equal("BK5", result.UnscheduledRows[0].SiteCode); + Assert.Empty(result.UnscheduledRows); } } @@ -242,7 +241,180 @@ public class WorkOrderAdvancedSearchServiceTests PageSize = 500 }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); - Assert.Equal(100, result.PageSize); + Assert.Equal(200, result.PageSize); + } + + [Fact] + public async Task SearchAsync_AcceptsPageSize200() + { + await using var context = CreateContext(); + var dataService = new WorkOrderAdvancedSearchDataService(context); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context)); + + var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek, + PageSize = 200 + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(200, result.PageSize); + } + + [Theory] + [InlineData(25)] + [InlineData(50)] + [InlineData(200)] + public async Task SearchAsync_UnassignedDispatcher_AcceptsBoardPagerPageSizes(int pageSize) + { + await using var context = CreateContext(); + var dataService = new WorkOrderAdvancedSearchDataService(context); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService( + dataService, + WorkOrderAccountTestHelpers.Resolver(context)); + + var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = new DateOnly(2000, 1, 1), + DateTo = new DateOnly(2099, 12, 31), + Dispatchers = new List { "__unassigned__" }, + Page = 0, + PageSize = pageSize + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(pageSize, result.PageSize); + Assert.Equal(0, result.Page); + Assert.Equal(0, result.PageBase); + } + + [Fact] + public async Task SearchAsync_UnassignedDispatcher_WideCustomRange_IncludesDatedOutsideWeekAndUndated() + { + await using var context = CreateContext(); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + AssignTo = null, + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 2, + InternalWONumber = "10000000002", + AssignTo = null, + ScheduledDate = new DateTime(2026, 8, 4), + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 3, + InternalWONumber = "10000000003", + AssignTo = "", + ScheduledDate = null, + LifecycleStatus = LifecycleStatus.Incomplete + }, + new WorkOrder + { + Id = 4, + InternalWONumber = "10000000004", + AssignTo = "user-a", + ScheduledDate = new DateTime(2026, 6, 24), + LifecycleStatus = LifecycleStatus.Scheduled + }); + + await context.SaveChangesAsync(); + + var dataService = new WorkOrderAdvancedSearchDataService(context); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService( + dataService, + WorkOrderAccountTestHelpers.Resolver(context)); + + var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = new DateOnly(2000, 1, 1), + DateTo = new DateOnly(2099, 12, 31), + Dispatchers = new List { "__unassigned__" }, + Page = 0, + PageSize = 50 + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(3, result.TotalCount); + Assert.Equal(50, result.PageSize); + Assert.Contains(result.Items, i => i.Id == 1 && i.DispatcherId == null); + Assert.Contains(result.Items, i => i.Id == 2 && i.DispatcherId == null); + Assert.Contains(result.Items, i => i.Id == 3); + Assert.DoesNotContain(result.Items, i => i.Id == 4); + } + + [Fact] + public async Task SearchAsync_UnassignedDispatcher_PaginatesWideCustomRange() + { + await using var context = CreateContext(); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + AssignTo = null, + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 2, + InternalWONumber = "10000000002", + AssignTo = null, + ScheduledDate = new DateTime(2026, 8, 4), + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 3, + InternalWONumber = "10000000003", + AssignTo = null, + ScheduledDate = null, + LifecycleStatus = LifecycleStatus.Incomplete + }); + + await context.SaveChangesAsync(); + + var dataService = new WorkOrderAdvancedSearchDataService(context); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService( + dataService, + WorkOrderAccountTestHelpers.Resolver(context)); + + var page0 = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = new DateOnly(2000, 1, 1), + DateTo = new DateOnly(2099, 12, 31), + Dispatchers = new List { "__unassigned__" }, + Page = 0, + PageSize = 2 + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + var page1 = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = new DateOnly(2000, 1, 1), + DateTo = new DateOnly(2099, 12, 31), + Dispatchers = new List { "__unassigned__" }, + Page = 1, + PageSize = 2 + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(3, page0.TotalCount); + Assert.Equal(2, page0.PageSize); + Assert.Equal(2, page0.Items.Count()); + Assert.Equal(3, page1.TotalCount); + Assert.Single(page1.Items); + Assert.Empty(page0.Items.Select(i => i.Id).Intersect(page1.Items.Select(i => i.Id))); } [Fact] diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs index a73d582..123cc03 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs @@ -19,7 +19,7 @@ public class WorkOrderBoardDataServiceTests } [Fact] - public async Task GetBoardRows_ReturnsScheduledInWeekAndUnscheduled() + public async Task GetBoardRows_ReturnsScheduledInWeekAndOmitsUnscheduledDump() { await using var context = CreateContext(); var weekStart = new DateOnly(2026, 6, 22); @@ -74,11 +74,56 @@ public class WorkOrderBoardDataServiceTests Assert.Single(result.ScheduledRows); Assert.Equal(1, result.ScheduledRows[0].Id); Assert.Equal("Acme HVAC", result.ScheduledRows[0].VendorName); - Assert.Single(result.UnscheduledRows); - Assert.Equal(2, result.UnscheduledRows[0].Id); + Assert.Empty(result.UnscheduledRows); Assert.Equal(1, result.ScheduledTotalBeforeSearch); } + [Fact] + public async Task GetBoardRows_DifferentWeeksReturnDifferentScheduledSets() + { + await using var context = CreateContext(); + var weekA = new DateOnly(2026, 6, 22); + var weekB = new DateOnly(2026, 6, 29); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 2, + InternalWONumber = "10000000002", + ScheduledDate = new DateTime(2026, 6, 30), + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 3, + InternalWONumber = "10000000003", + ScheduledDate = null, + LifecycleStatus = LifecycleStatus.Incomplete + }); + + await context.SaveChangesAsync(); + + var service = new WorkOrderBoardDataService(context); + var resultA = await service.GetBoardRowsAsync(new WorkOrderBoardQuery( + weekA, weekA.AddDays(4), null, false, null, false, null, null)); + var resultB = await service.GetBoardRowsAsync(new WorkOrderBoardQuery( + weekB, weekB.AddDays(4), null, false, null, false, null, null)); + + Assert.Equal(1, resultA.ScheduledRows[0].Id); + Assert.Equal(2, resultB.ScheduledRows[0].Id); + Assert.Empty(resultA.UnscheduledRows); + Assert.Empty(resultB.UnscheduledRows); + Assert.Equal(1, resultA.ScheduledTotalBeforeSearch); + Assert.Equal(1, resultB.ScheduledTotalBeforeSearch); + } + [Fact] public async Task GetBoardRows_FiltersByDispatcher() { @@ -118,6 +163,121 @@ public class WorkOrderBoardDataServiceTests Assert.Equal("user-a", result.ScheduledRows[0].AssignTo); } + [Fact] + public async Task GetBoardRows_DatedUnassigned_AppearsInScheduledNotUnscheduled() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + AssignTo = null, + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 2, + InternalWONumber = "10000000002", + AssignTo = "user-a", + ScheduledDate = new DateTime(2026, 6, 24), + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 3, + InternalWONumber = "10000000003", + AssignTo = null, + ScheduledDate = null, + LifecycleStatus = LifecycleStatus.Incomplete + }); + + 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.Equal(2, result.ScheduledRows.Count); + Assert.Contains(result.ScheduledRows, r => r.Id == 1 && r.AssignTo == null); + Assert.Contains(result.ScheduledRows, r => r.Id == 2 && r.AssignTo == "user-a"); + Assert.DoesNotContain(result.ScheduledRows, r => r.Id == 3); + Assert.Empty(result.UnscheduledRows); + Assert.Equal(2, result.ScheduledTotalBeforeSearch); + } + + [Fact] + public async Task GetBoardRows_FiltersByUnassignedDispatcher_ReturnsOnlyUnassignedInWeek() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + AssignTo = null, + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 2, + InternalWONumber = "10000000002", + AssignTo = "user-a", + ScheduledDate = new DateTime(2026, 6, 24), + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 3, + InternalWONumber = "10000000003", + AssignTo = "", + ScheduledDate = new DateTime(2026, 6, 25), + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 4, + InternalWONumber = "10000000004", + AssignTo = null, + ScheduledDate = null, + LifecycleStatus = LifecycleStatus.Incomplete + }); + + await context.SaveChangesAsync(); + + var service = new WorkOrderBoardDataService(context); + var result = await service.GetBoardRowsAsync(new WorkOrderBoardQuery( + weekStart, + weekStart.AddDays(4), + new[] { "__unassigned__" }, + false, + null, + false, + null, + null)); + + Assert.Equal(2, result.ScheduledRows.Count); + Assert.Contains(result.ScheduledRows, r => r.Id == 1); + Assert.Contains(result.ScheduledRows, r => r.Id == 3); + Assert.DoesNotContain(result.ScheduledRows, r => r.Id == 2); + Assert.DoesNotContain(result.ScheduledRows, r => r.Id == 4); + Assert.Empty(result.UnscheduledRows); + Assert.Equal(2, result.ScheduledTotalBeforeSearch); + } + [Fact] public async Task GetBoardRows_MyWorkOrders_FiltersCurrentUser() { @@ -374,6 +534,122 @@ public class WorkOrderBoardPendingUpliftTests Assert.Single(result.ScheduledRows); Assert.Equal(0, result.ScheduledRows[0].PendingUpliftCount); } + + [Fact] + public async Task GetBoardRows_PendingUpliftOnPrimaryAndDispatchWorkOrderLink_DoesNotDoubleCount() + { + 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.DispatchWorkOrders.Add(new DispatchWorkOrder + { + DispatchId = 10, + 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 = "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); + Assert.True(result.ScheduledRows[0].HasUplift); + } + + [Fact] + public async Task GetBoardRows_ScheduledDateWithTimeOnWeekEnd_IsIncluded() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + var weekEnd = weekStart.AddDays(4); + + context.workOrders.Add(new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + ScheduledDate = weekEnd.ToDateTime(new TimeOnly(23, 59, 59)), + LifecycleStatus = LifecycleStatus.Scheduled + }); + await context.SaveChangesAsync(); + + var service = new WorkOrderBoardDataService(context); + var result = await service.GetBoardRowsAsync(new WorkOrderBoardQuery( + weekStart, + weekEnd, + null, + false, + null, + false, + null, + null)); + + Assert.Single(result.ScheduledRows); + Assert.Equal(1, result.ScheduledRows[0].Id); + } + + [Fact] + public async Task GetBoardRows_CanceledToken_ThrowsOperationCanceled() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + context.workOrders.Add(new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled + }); + await context.SaveChangesAsync(); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var service = new WorkOrderBoardDataService(context); + await Assert.ThrowsAnyAsync(() => + service.GetBoardRowsAsync( + new WorkOrderBoardQuery( + weekStart, + weekStart.AddDays(4), + null, + false, + null, + false, + null, + null), + cts.Token)); + } } public class WorkOrderBoardServiceTests @@ -565,4 +841,32 @@ public class WorkOrderBoardServiceTests WeekEnd = new DateOnly(2026, 6, 15) }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null)); } + + [Fact] + public async Task GetBoardAsync_CanceledToken_ThrowsOperationCanceled() + { + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled + }); + await context.SaveChangesAsync(); + + var boardService = new WorkOrderBoardService( + new WorkOrderBoardDataService(context), + WorkOrderAccountTestHelpers.Resolver(context)); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => + boardService.GetBoardAsync( + new WorkOrderBoardQueryDto { WeekStart = new DateOnly(2026, 6, 22) }, + WorkOrderAccountTestHelpers.OrgWideAdmin(), + null, + cts.Token)); + } }