From 028908bd5a1d53628230ddf9016baae400aa442e Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 20:51:44 -0300 Subject: [PATCH 1/3] fix(board-search): make the Advanced Filters date range narrow results (SH-391) An explicit date range let every undated, non-terminal work order through, so Unassigned plus a range still returned all ~1,780 unassigned rows. The range now matches Schedule On, or the Target Week for week-only rows (overlap), the same date the weekly board groups by. Undated rows are added only when the caller sends includeDateless, which the client uses for searches with no range selected and for the Unassigned queue. --- .../Helpers/WorkOrderBoardQueryFilters.cs | 20 ++- .../WorkOrderAdvancedSearchDataService.cs | 3 +- .../WorkOrderAdvancedSearchModels.cs | 3 +- .../DTOs/WorkOrderBoardRequestDTOs.cs | 6 + .../WorkOrderAdvancedSearchService.cs | 3 +- .../WorkOrderAdvancedSearchDateRangeTests.cs | 162 ++++++++++++++++++ .../WorkOrderBoardSearchTests.cs | 14 +- 7 files changed, 201 insertions(+), 10 deletions(-) create mode 100644 SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs index 71c4d94..b665f2c 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -220,13 +220,26 @@ namespace SeaHaven.DataServices.Helpers && w.LifecycleStatus != LifecycleStatus.Completed && w.LifecycleStatus != LifecycleStatus.Canceled); + /// + /// Narrows to rows whose Schedule On date falls in the range, or, for + /// week-only rows, whose Target Week overlaps it: the same date the + /// weekly board groups by (SH-391). Rows with no date never match a + /// range; they are added only when + /// is set, which callers send for searches with no date range selected. + /// Completed and canceled dateless rows stay out, matching + /// . + /// public static IQueryable ApplyDateRangeFilter( IQueryable query, DateOnly dateFrom, - DateOnly dateTo) + DateOnly dateTo, + bool includeDateless = false) { var fromDate = dateFrom.ToDateTime(TimeOnly.MinValue).Date; var toDate = dateTo.ToDateTime(TimeOnly.MinValue).Date; + // A target week (stored as its Monday) overlaps the range when it + // starts by the range end and its Sunday is on or after the range start. + var earliestWeekStart = dateFrom.DayNumber >= 6 ? dateFrom.AddDays(-6) : dateFrom; return query.Where(w => (w.ScheduledDate != null @@ -234,9 +247,10 @@ namespace SeaHaven.DataServices.Helpers && w.ScheduledDate.Value.Date <= toDate) || (w.ScheduleWeekOnly == true && w.TargetWeek != null - && w.TargetWeek >= dateFrom + && w.TargetWeek >= earliestWeekStart && w.TargetWeek <= dateTo) - || (w.ScheduledDate == null + || (includeDateless + && w.ScheduledDate == null && w.LifecycleStatus != LifecycleStatus.Completed && w.LifecycleStatus != LifecycleStatus.Canceled)); } diff --git a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs index eeb28c5..74b5fb2 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs @@ -22,7 +22,8 @@ namespace SeaHaven.DataServices.Implementation if (query.UnscheduledOnly) baseQuery = WorkOrderBoardQueryFilters.ApplyUnscheduledOnlyFilter(baseQuery); else - baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo); + baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter( + baseQuery, query.DateFrom, query.DateTo, query.IncludeDateless); baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites); baseQuery = WorkOrderBoardQueryFilters.ApplyRegionFilter(baseQuery, query.Regions); baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue); diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs index 2c0a627..4ee175a 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs @@ -32,7 +32,8 @@ namespace SeaHaven.DataServices.Interfaces int PageSize, string SortBy, string SortDir, - int? AccountId = null); + int? AccountId = null, + bool IncludeDateless = false); public record WorkOrderAdvancedSearchResult( IReadOnlyList Rows, diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index 8f2c9df..e7c3dc6 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -87,6 +87,12 @@ namespace SeaHaven.Services.DTOs /// filter; every other facet still applies. /// public bool UnscheduledOnly { get; set; } + /// + /// When true, rows with no date (no Schedule On) are returned alongside + /// the in-range rows. Sent for searches with no date range selected; an + /// explicit range leaves it false so the range strictly narrows (SH-391). + /// + public bool IncludeDateless { get; set; } public List? Sites { get; set; } public List? Regions { get; set; } public List? Types { get; set; } diff --git a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs index fa97dc5..c428da7 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs @@ -72,7 +72,8 @@ namespace SeaHaven.Services.Implementation pageSize, sortBy, query.SortDir ?? "asc", - accountId); + accountId, + query.IncludeDateless); var result = await _searchDataService.SearchAsync(dataQuery); var utcNow = DateTime.UtcNow; diff --git a/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs b/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs new file mode 100644 index 0000000..4bacc93 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs @@ -0,0 +1,162 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; + +namespace SeaHavenIndustries.Tests; + +/// +/// SH-391: an Advanced Filters date range narrows GET /board/search to rows +/// whose Schedule On (or week-only Target Week) is in the range. Rows with no +/// date come back only when the caller asks for them (no range selected). +/// +public class WorkOrderAdvancedSearchDateRangeTests +{ + private static readonly DateOnly RangeFrom = new(2026, 9, 21); + private static readonly DateOnly RangeTo = new(2026, 9, 25); + + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static WorkOrderAdvancedSearchService CreateService(ApplicationDbContext context) + => new(new WorkOrderAdvancedSearchDataService(context), WorkOrderAccountTestHelpers.Resolver(context)); + + private static WorkOrder Wo( + int id, + DateTime? scheduled, + string? assignTo = null, + LifecycleStatus status = LifecycleStatus.Scheduled, + DateOnly? targetWeek = null, + int? accountId = null) + => new() + { + Id = id, + InternalWONumber = $"1000000000{id}", + AssignTo = assignTo, + ScheduledDate = scheduled, + ScheduleWeekOnly = targetWeek.HasValue, + TargetWeek = targetWeek, + LifecycleStatus = status, + AccountId = accountId, + istemplate = false + }; + + private static WorkOrderAdvancedSearchQueryDto UnassignedInRange(bool includeDateless = false) => new() + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = RangeFrom, + DateTo = RangeTo, + Dispatchers = new List { "__unassigned__" }, + IncludeDateless = includeDateless, + PageSize = 50 + }; + + [Fact] + public async Task SearchAsync_UnassignedWithRange_ReturnsOnlyUnassignedRowsScheduledInRange() + { + await using var context = CreateContext(); + context.workOrders.AddRange( + Wo(1, new DateTime(2026, 9, 23)), + Wo(2, new DateTime(2026, 8, 4)), + Wo(3, null, status: LifecycleStatus.Incomplete), + Wo(4, new DateTime(2026, 9, 24), assignTo: "user-a"), + Wo(5, new DateTime(2026, 10, 2))); + await context.SaveChangesAsync(); + + var result = await CreateService(context).SearchAsync( + UnassignedInRange(), WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(1, result.TotalCount); + Assert.Equal(new[] { 1 }, result.Items.Select(i => i.Id).ToArray()); + } + + [Fact] + public async Task SearchAsync_RangeWithoutDispatcherFilter_ExcludesUndatedRows() + { + await using var context = CreateContext(); + context.workOrders.AddRange( + Wo(1, new DateTime(2026, 9, 22), assignTo: "user-a"), + Wo(2, null, assignTo: "user-a", status: LifecycleStatus.Incomplete), + Wo(3, new DateTime(2026, 9, 14), assignTo: "user-a", status: LifecycleStatus.Incomplete)); + await context.SaveChangesAsync(); + + var result = await CreateService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = RangeFrom, + DateTo = RangeTo + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(new[] { 1 }, result.Items.Select(i => i.Id).ToArray()); + } + + [Fact] + public async Task SearchAsync_WeekOnlyRow_MatchesRangeThatOverlapsItsTargetWeek() + { + await using var context = CreateContext(); + context.workOrders.AddRange( + Wo(1, null, targetWeek: new DateOnly(2026, 9, 21)), + Wo(2, null, targetWeek: new DateOnly(2026, 9, 14)), + Wo(3, null, targetWeek: new DateOnly(2026, 9, 28))); + await context.SaveChangesAsync(); + + // Wed–Fri of the week of 9/21: the week-only row for that week overlaps it. + var result = await CreateService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = new DateOnly(2026, 9, 23), + DateTo = RangeTo, + Dispatchers = new List { "__unassigned__" } + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(new[] { 1 }, result.Items.Select(i => i.Id).ToArray()); + } + + [Fact] + public async Task SearchAsync_IncludeDateless_AddsOpenUndatedRowsToTheRange() + { + await using var context = CreateContext(); + context.workOrders.AddRange( + Wo(1, new DateTime(2026, 9, 23)), + Wo(2, new DateTime(2026, 8, 4)), + Wo(3, null, status: LifecycleStatus.Incomplete), + Wo(4, null, status: LifecycleStatus.Completed), + Wo(5, null, status: LifecycleStatus.Canceled)); + await context.SaveChangesAsync(); + + var result = await CreateService(context).SearchAsync( + UnassignedInRange(includeDateless: true), WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(new[] { 1, 3 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray()); + } + + [Fact] + public async Task SearchAsync_RangeForAccountUser_NeverReturnsAnotherAccountsRows() + { + await using var context = CreateContext(); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A"); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B"); + context.workOrders.AddRange( + Wo(1, new DateTime(2026, 9, 23), accountId: 1), + Wo(2, new DateTime(2026, 9, 23), accountId: 2), + Wo(3, null, status: LifecycleStatus.Incomplete, accountId: 2), + Wo(4, null, targetWeek: new DateOnly(2026, 9, 21), accountId: 2)); + await context.SaveChangesAsync(); + + var user = WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher"); + var strict = await CreateService(context).SearchAsync(UnassignedInRange(), user, "disp-1"); + var withDateless = await CreateService(context).SearchAsync( + UnassignedInRange(includeDateless: true), user, "disp-1"); + + Assert.Equal(new[] { 1 }, strict.Items.Select(i => i.Id).ToArray()); + Assert.Equal(new[] { 1 }, withDateless.Items.Select(i => i.Id).ToArray()); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs index cd47e05..6a1f2e3 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs @@ -319,6 +319,7 @@ public class WorkOrderAdvancedSearchServiceTests DateFrom = new DateOnly(2000, 1, 1), DateTo = new DateOnly(2099, 12, 31), Dispatchers = new List { "__unassigned__" }, + IncludeDateless = true, Page = 0, PageSize = pageSize }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); @@ -380,6 +381,7 @@ public class WorkOrderAdvancedSearchServiceTests DateFrom = new DateOnly(2000, 1, 1), DateTo = new DateOnly(2099, 12, 31), Dispatchers = new List { "__unassigned__" }, + IncludeDateless = true, Page = 0, PageSize = 50 }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); @@ -436,6 +438,7 @@ public class WorkOrderAdvancedSearchServiceTests DateFrom = new DateOnly(2000, 1, 1), DateTo = new DateOnly(2099, 12, 31), Dispatchers = new List { "__unassigned__" }, + IncludeDateless = true, Page = 0, PageSize = 2 }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); @@ -446,6 +449,7 @@ public class WorkOrderAdvancedSearchServiceTests DateFrom = new DateOnly(2000, 1, 1), DateTo = new DateOnly(2099, 12, 31), Dispatchers = new List { "__unassigned__" }, + IncludeDateless = true, Page = 1, PageSize = 2 }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); @@ -773,7 +777,7 @@ public class WorkOrderAdvancedSearchServiceTests } [Fact] - public async Task SearchAsync_IncludesUnscheduledRows() + public async Task SearchAsync_IncludeDateless_IncludesUnscheduledRows() { await using var context = CreateContext(); var weekStart = new DateOnly(2026, 6, 22); @@ -803,7 +807,8 @@ public class WorkOrderAdvancedSearchServiceTests { DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, DateFrom = weekStart, - DateTo = weekStart.AddDays(4) + DateTo = weekStart.AddDays(4), + IncludeDateless = true }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); Assert.Equal(2, result.TotalCount); @@ -1193,9 +1198,10 @@ public class WorkOrderAdvancedSearchServiceTests Assert.Equal( withFlagAbsent.Items.Select(i => i.Id).ToList(), withFlagFalse.Items.Select(i => i.Id).ToList()); - Assert.Equal(2, withFlagFalse.TotalCount); + // SH-391: an explicit range narrows strictly, so the undated row 3 is out. + Assert.Equal(1, withFlagFalse.TotalCount); Assert.Contains(withFlagFalse.Items, i => i.Id == 1); - Assert.Contains(withFlagFalse.Items, i => i.Id == 3); + Assert.DoesNotContain(withFlagFalse.Items, i => i.Id == 3); Assert.DoesNotContain(withFlagFalse.Items, i => i.Id == 2); } From 9d5798437d09f867879466bdf8d4b0d4d02c41be Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:11:20 -0300 Subject: [PATCH 2/3] test(workorders): pin the week-only date range rule shared with the board page (SH-391) --- .../WorkOrderAdvancedSearchDateRangeTests.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs b/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs index 4bacc93..634c12f 100644 --- a/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs @@ -49,6 +49,12 @@ public class WorkOrderAdvancedSearchDateRangeTests istemplate = false }; + private static WorkOrder NotWeekOnly(WorkOrder wo) + { + wo.ScheduleWeekOnly = false; + return wo; + } + private static WorkOrderAdvancedSearchQueryDto UnassignedInRange(bool includeDateless = false) => new() { DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, @@ -105,7 +111,12 @@ public class WorkOrderAdvancedSearchDateRangeTests context.workOrders.AddRange( Wo(1, null, targetWeek: new DateOnly(2026, 9, 21)), Wo(2, null, targetWeek: new DateOnly(2026, 9, 14)), - Wo(3, null, targetWeek: new DateOnly(2026, 9, 28))); + Wo(3, null, targetWeek: new DateOnly(2026, 9, 28)), + // Week-only with a leftover date outside the range: the week decides, + // as on the board page (SH-391). + Wo(4, new DateTime(2026, 10, 2), targetWeek: new DateOnly(2026, 9, 21)), + // Not week-only: its date decides, so a target week does not bring it in. + NotWeekOnly(Wo(5, new DateTime(2026, 10, 2), targetWeek: new DateOnly(2026, 9, 21)))); await context.SaveChangesAsync(); // Wed–Fri of the week of 9/21: the week-only row for that week overlaps it. @@ -117,7 +128,7 @@ public class WorkOrderAdvancedSearchDateRangeTests Dispatchers = new List { "__unassigned__" } }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); - Assert.Equal(new[] { 1 }, result.Items.Select(i => i.Id).ToArray()); + Assert.Equal(new[] { 1, 4 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray()); } [Fact] From 67cd9c589b4b38ba8bf03dd6d635dc65b202d338 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 01:41:49 -0300 Subject: [PATCH 3/3] fix(board-search): keep undated rows for clients that omit includeDateless (SH-391) The strict date range made undated open work orders disappear for every client that predates the includeDateless flag. The frontend on main sends the all-weeks window (2000-01-01..2099-12-31) for "no range", the 1970-01-01..2099-12-31 window for the pinned Unassigned queue, and no date input at all for the WO# duplicate lookup. None of those send the flag, so deploying this backend before the frontend would have dropped undated WOs from all three flows. includeDateless is now optional. An explicit value still wins. When it is omitted, a request with no date input or with an all-weeks Custom window keeps its open undated rows, as before SH-391. Any other range stays strict. The backend and frontend can therefore deploy in either order. --- .../DTOs/WorkOrderBoardRequestDTOs.cs | 6 +- .../WorkOrderSearchDateRangeResolver.cs | 30 ++++++ .../WorkOrderAdvancedSearchService.cs | 6 +- .../WorkOrderAdvancedSearchDateRangeTests.cs | 93 ++++++++++++++++++- 4 files changed, 131 insertions(+), 4 deletions(-) diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index e7c3dc6..38b4c24 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -90,9 +90,11 @@ namespace SeaHaven.Services.DTOs /// /// When true, rows with no date (no Schedule On) are returned alongside /// the in-range rows. Sent for searches with no date range selected; an - /// explicit range leaves it false so the range strictly narrows (SH-391). + /// explicit range leaves it unset or false so the range strictly narrows + /// (SH-391). When it is omitted, clients that predate the flag keep their + /// undated rows: see . /// - public bool IncludeDateless { get; set; } + public bool? IncludeDateless { get; set; } public List? Sites { get; set; } public List? Regions { get; set; } public List? Types { get; set; } diff --git a/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs b/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs index 48a8f48..7cf378e 100644 --- a/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs +++ b/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs @@ -15,6 +15,36 @@ namespace SeaHaven.Services.Helpers public static class WorkOrderSearchDateRangeResolver { + // Widest windows the frontend sends for "every week": the no-range search + // (ADVANCED_SEARCH_ALL_WEEKS_FROM/TO, 2000-01-01..2099-12-31) and the pinned + // Unassigned queue (UNASSIGNED_QUEUE_DATE_FROM/TO, 1970-01-01..2099-12-31). + private static readonly DateOnly AllWeeksFrom = new(2000, 1, 1); + private static readonly DateOnly AllWeeksTo = new(2099, 12, 31); + + /// + /// Whether a board search adds open undated rows to its date range (SH-391). + /// An explicit wins. When it is omitted, + /// as by clients that predate the flag, a search with no date input or with + /// an all-weeks Custom window still returns undated rows, as before SH-391: + /// those requests mean "no range selected". Any other range is strict. + /// + public static bool ResolveIncludeDateless( + bool? includeDateless, + WorkOrderAdvancedSearchDatePreset? preset, + DateOnly? dateFrom, + DateOnly? dateTo) + { + if (includeDateless.HasValue) + return includeDateless.Value; + + if (!preset.HasValue && !dateFrom.HasValue && !dateTo.HasValue) + return true; + + return preset == WorkOrderAdvancedSearchDatePreset.Custom + && dateFrom <= AllWeeksFrom + && dateTo >= AllWeeksTo; + } + public static WorkOrderSearchDateRange Resolve( WorkOrderAdvancedSearchDatePreset? preset, DateOnly? dateFrom, diff --git a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs index c428da7..39f63dc 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs @@ -73,7 +73,11 @@ namespace SeaHaven.Services.Implementation sortBy, query.SortDir ?? "asc", accountId, - query.IncludeDateless); + WorkOrderSearchDateRangeResolver.ResolveIncludeDateless( + query.IncludeDateless, + query.DatePreset, + query.DateFrom, + query.DateTo)); var result = await _searchDataService.SearchAsync(dataQuery); var utcNow = DateTime.UtcNow; diff --git a/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs b/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs index 634c12f..f6d61b2 100644 --- a/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; @@ -55,7 +56,7 @@ public class WorkOrderAdvancedSearchDateRangeTests return wo; } - private static WorkOrderAdvancedSearchQueryDto UnassignedInRange(bool includeDateless = false) => new() + private static WorkOrderAdvancedSearchQueryDto UnassignedInRange(bool? includeDateless = null) => new() { DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, DateFrom = RangeFrom, @@ -149,6 +150,96 @@ public class WorkOrderAdvancedSearchDateRangeTests Assert.Equal(new[] { 1, 3 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray()); } + // Payloads the frontend sent before includeDateless existed. They must keep + // their undated rows so this backend can deploy ahead of the client. + [Theory] + [InlineData(2000)] // no range selected: ADVANCED_SEARCH_ALL_WEEKS_FROM + [InlineData(1970)] // pinned Unassigned queue: UNASSIGNED_QUEUE_DATE_FROM + public async Task SearchAsync_AllWeeksWindowWithoutFlag_KeepsOpenUndatedRows(int fromYear) + { + await using var context = CreateContext(); + context.workOrders.AddRange( + Wo(1, new DateTime(2026, 8, 4)), + Wo(2, null, status: LifecycleStatus.Incomplete), + Wo(3, null, status: LifecycleStatus.Completed)); + await context.SaveChangesAsync(); + + var result = await CreateService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = new DateOnly(fromYear, 1, 1), + DateTo = new DateOnly(2099, 12, 31), + Dispatchers = new List { "__unassigned__" }, + PageSize = 50 + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(new[] { 1, 2 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray()); + } + + [Fact] + public async Task SearchAsync_NoDateInputWithoutFlag_KeepsOpenUndatedRows() + { + // The WO# duplicate lookup sends only the search text and paging. + await using var context = CreateContext(); + var thisWeek = WorkOrderSearchDateRangeResolver.GetIsoWeekStart(DateOnly.FromDateTime(DateTime.UtcNow)); + context.workOrders.AddRange( + Wo(1, thisWeek.ToDateTime(TimeOnly.MinValue)), + Wo(2, null, status: LifecycleStatus.Incomplete), + Wo(3, thisWeek.AddDays(-7).ToDateTime(TimeOnly.MinValue))); + await context.SaveChangesAsync(); + + var result = await CreateService(context).SearchAsync( + new WorkOrderAdvancedSearchQueryDto { PageSize = 100 }, + WorkOrderAccountTestHelpers.OrgWideAdmin(), + null); + + Assert.Equal(new[] { 1, 2 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray()); + } + + [Fact] + public async Task SearchAsync_AllWeeksWindowWithFlagFalse_ExcludesUndatedRows() + { + await using var context = CreateContext(); + context.workOrders.AddRange( + Wo(1, new DateTime(2026, 8, 4)), + Wo(2, null, status: LifecycleStatus.Incomplete)); + await context.SaveChangesAsync(); + + var result = await CreateService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = new DateOnly(2000, 1, 1), + DateTo = new DateOnly(2099, 12, 31), + IncludeDateless = false, + PageSize = 50 + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(new[] { 1 }, result.Items.Select(i => i.Id).ToArray()); + } + + [Theory] + [InlineData(null, null, null, null, true)] + [InlineData(null, WorkOrderAdvancedSearchDatePreset.Custom, "2000-01-01", "2099-12-31", true)] + [InlineData(null, WorkOrderAdvancedSearchDatePreset.Custom, "1970-01-01", "2099-12-31", true)] + [InlineData(null, WorkOrderAdvancedSearchDatePreset.Custom, "2000-01-02", "2099-12-31", false)] + [InlineData(null, WorkOrderAdvancedSearchDatePreset.Custom, "2026-09-21", "2026-09-25", false)] + [InlineData(null, WorkOrderAdvancedSearchDatePreset.ThisWeek, null, null, false)] + [InlineData(false, null, null, null, false)] + [InlineData(true, WorkOrderAdvancedSearchDatePreset.ThisWeek, null, null, true)] + public void ResolveIncludeDateless_InfersOnlyForNoRangeRequests( + bool? flag, + WorkOrderAdvancedSearchDatePreset? preset, + string? from, + string? to, + bool expected) + { + Assert.Equal(expected, WorkOrderSearchDateRangeResolver.ResolveIncludeDateless( + flag, + preset, + from is null ? null : DateOnly.Parse(from, CultureInfo.InvariantCulture), + to is null ? null : DateOnly.Parse(to, CultureInfo.InvariantCulture))); + } + [Fact] public async Task SearchAsync_RangeForAccountUser_NeverReturnsAnotherAccountsRows() {