SH-338: filter unscheduled work orders before pagination (#116)
Some checks are pending
Validate and deploy / Validate deployable source bundle (push) Waiting to run
Validate and deploy / Deploy shoc-backend-dev through Terraform (push) Blocked by required conditions
Validate and deploy / Deploy shoc-backend-staging to Elastic Beanstalk (push) Blocked by required conditions

* fix(work-orders): filter unscheduled search server-side

* fix(work-orders): preserve unscheduled status scope
This commit is contained in:
Alexandre Brandizzi 2026-09-15 16:46:40 -03:00 • committed by GitHub
parent 13ce4b7e88
commit 802b7a414e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 479 additions and 11 deletions

View file

@ -173,6 +173,19 @@ namespace SeaHaven.DataServices.Helpers
|| (w.Problem ?? "").ToLower().Contains(pm)));
}
/// <summary>
/// Restricts to unscheduled rows. A null ScheduledDate is the canonical
/// unscheduled representation; replaces the date-range filter when the
/// unscheduled-only search mode is active. Mirrors the undated branch of
/// <see cref="ApplyDateRangeFilter"/>: completed and canceled rows are
/// excluded so both modes agree on the protected unscheduled semantics.
/// </summary>
public static IQueryable<WorkOrder> ApplyUnscheduledOnlyFilter(IQueryable<WorkOrder> query)
=> query.Where(w =>
w.ScheduledDate == null
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled);
public static IQueryable<WorkOrder> ApplyDateRangeFilter(
IQueryable<WorkOrder> query,
DateOnly dateFrom,

View file

@ -19,7 +19,10 @@ namespace SeaHaven.DataServices.Implementation
var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (query.AccountId.HasValue)
baseQuery = WorkOrderBoardQueryFilters.ApplyAccountScope(baseQuery, query.AccountId.Value);
baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo);
if (query.UnscheduledOnly)
baseQuery = WorkOrderBoardQueryFilters.ApplyUnscheduledOnlyFilter(baseQuery);
else
baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo);
baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites);
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue);
baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter(
@ -61,14 +64,14 @@ namespace SeaHaven.DataServices.Implementation
return field switch
{
"wonumber" or "woNumber" => descending
? query.OrderByDescending(w => w.InternalWONumber)
: query.OrderBy(w => w.InternalWONumber),
? query.OrderByDescending(w => w.InternalWONumber).ThenBy(w => w.Id)
: query.OrderBy(w => w.InternalWONumber).ThenBy(w => w.Id),
"duedate" or "dueDate" => descending
? query.OrderByDescending(w => w.DueDate).ThenBy(w => w.InternalWONumber)
: query.OrderBy(w => w.DueDate).ThenBy(w => w.InternalWONumber),
? query.OrderByDescending(w => w.DueDate).ThenBy(w => w.InternalWONumber).ThenBy(w => w.Id)
: query.OrderBy(w => w.DueDate).ThenBy(w => w.InternalWONumber).ThenBy(w => w.Id),
_ => descending
? query.OrderByDescending(w => w.ScheduledDate).ThenByDescending(w => w.InternalWONumber)
: query.OrderBy(w => w.ScheduledDate).ThenBy(w => w.InternalWONumber)
? query.OrderByDescending(w => w.ScheduledDate).ThenByDescending(w => w.InternalWONumber).ThenBy(w => w.Id)
: query.OrderBy(w => w.ScheduledDate).ThenBy(w => w.InternalWONumber).ThenBy(w => w.Id)
};
}
}

View file

@ -7,6 +7,7 @@ namespace SeaHaven.DataServices.Interfaces
string? Search,
DateOnly DateFrom,
DateOnly DateTo,
bool UnscheduledOnly,
IReadOnlyList<string>? Sites,
IReadOnlyList<WorkOrderType>? Types,
bool Overdue,

View file

@ -66,6 +66,12 @@ namespace SeaHaven.Services.DTOs
public WorkOrderAdvancedSearchDatePreset? DatePreset { get; set; }
public DateOnly? DateFrom { get; set; }
public DateOnly? DateTo { get; set; }
/// <summary>
/// When true, only unscheduled rows (ScheduledDate is null) are returned.
/// Takes precedence over DatePreset/DateFrom/DateTo for the scheduled-date
/// filter; every other facet still applies.
/// </summary>
public bool UnscheduledOnly { get; set; }
public List<string>? Sites { get; set; }
public List<WorkOrderType>? Types { get; set; }
/// <summary>When true, include past-due (isPastDue) rows. Combined with Types via OR.</summary>

View file

@ -32,10 +32,14 @@ namespace SeaHaven.Services.Implementation
var page = query.Page < 0 ? 0 : query.Page;
var pageSize = query.PageSize < 1 ? DefaultPageSize : Math.Min(query.PageSize, MaxPageSize);
var dateRange = WorkOrderSearchDateRangeResolver.Resolve(
query.DatePreset,
query.DateFrom,
query.DateTo);
// Unscheduled-only mode ignores the scheduled-date range entirely;
// the inert wide range keeps the data contract satisfied.
var dateRange = query.UnscheduledOnly
? new WorkOrderSearchDateRange(DateOnly.MinValue, DateOnly.MaxValue)
: WorkOrderSearchDateRangeResolver.Resolve(
query.DatePreset,
query.DateFrom,
query.DateTo);
var sortBy = WorkOrderBoardSortFields.NormalizeOrThrow(query.SortBy);
@ -43,6 +47,7 @@ namespace SeaHaven.Services.Implementation
WorkOrderBoardSearchFilter.NormalizeSearch(query.Search),
dateRange.From,
dateRange.To,
query.UnscheduledOnly,
query.Sites,
query.Types,
query.Overdue,

View file

@ -1048,4 +1048,444 @@ public class WorkOrderAdvancedSearchServiceTests
Assert.Equal(1, result.Items.Single().Id);
Assert.True(result.Items.Single().AvetaRequired);
}
[Fact]
public async Task SearchAsync_UnscheduledOnly_ReturnsOnlyUndatedRows()
{
await using var context = CreateContext();
var weekStart = new DateOnly(2026, 6, 22);
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001",
istemplate = false
},
new WorkOrder
{
Id = 2,
ScheduledDate = null,
LifecycleStatus = LifecycleStatus.Incomplete,
InternalWONumber = "10000000002",
istemplate = false
});
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 = weekStart,
DateTo = weekStart.AddDays(4),
UnscheduledOnly = true
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(1, result.TotalCount);
Assert.Single(result.Items);
Assert.Equal(2, result.Items.Single().Id);
Assert.Null(result.Items.Single().ScheduledDate);
}
[Fact]
public async Task SearchAsync_UnscheduledOnlyFalse_MatchesDefaultRangeSearch()
{
await using var context = CreateContext();
var weekStart = new DateOnly(2026, 6, 22);
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001",
istemplate = false
},
new WorkOrder
{
Id = 2,
ScheduledDate = new DateTime(2026, 8, 4),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000002",
istemplate = false
},
new WorkOrder
{
Id = 3,
ScheduledDate = null,
LifecycleStatus = LifecycleStatus.Incomplete,
InternalWONumber = "10000000003",
istemplate = false
});
await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(
dataService,
WorkOrderAccountTestHelpers.Resolver(context));
var withFlagFalse = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = weekStart,
DateTo = weekStart.AddDays(4),
UnscheduledOnly = false
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
var withFlagAbsent = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = weekStart,
DateTo = weekStart.AddDays(4)
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(withFlagAbsent.TotalCount, withFlagFalse.TotalCount);
Assert.Equal(
withFlagAbsent.Items.Select(i => i.Id).ToList(),
withFlagFalse.Items.Select(i => i.Id).ToList());
Assert.Equal(2, 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 == 2);
}
[Fact]
public async Task SearchAsync_UnscheduledOnly_IgnoresScheduledDateRange()
{
await using var context = CreateContext();
var weekStart = new DateOnly(2026, 6, 22);
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001",
istemplate = false
},
new WorkOrder
{
Id = 2,
ScheduledDate = null,
LifecycleStatus = LifecycleStatus.Incomplete,
InternalWONumber = "10000000002",
istemplate = false
});
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 = weekStart,
DateTo = weekStart.AddDays(4),
UnscheduledOnly = true
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(1, result.TotalCount);
Assert.Single(result.Items);
Assert.Equal(2, result.Items.Single().Id);
}
[Fact]
public async Task SearchAsync_UnscheduledOnly_CustomPresetWithoutDates_DoesNotThrow()
{
await using var context = CreateContext();
context.workOrders.Add(new WorkOrder
{
Id = 1,
ScheduledDate = null,
LifecycleStatus = LifecycleStatus.Incomplete,
InternalWONumber = "10000000001",
istemplate = false
});
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,
UnscheduledOnly = true
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(1, result.TotalCount);
Assert.Equal(1, result.Items.Single().Id);
}
[Fact]
public async Task SearchAsync_UnscheduledOnly_ComposesWithAccountScopeAndSiteFacet()
{
await using var context = CreateContext();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B");
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
AccountId = 1,
SiteCode = "BK5",
ScheduledDate = null,
LifecycleStatus = LifecycleStatus.Incomplete,
InternalWONumber = "10000000001",
istemplate = false
},
new WorkOrder
{
Id = 2,
AccountId = 2,
SiteCode = "BK5",
ScheduledDate = null,
LifecycleStatus = LifecycleStatus.Incomplete,
InternalWONumber = "10000000002",
istemplate = false
},
new WorkOrder
{
Id = 3,
AccountId = 1,
SiteCode = "BK6",
ScheduledDate = null,
LifecycleStatus = LifecycleStatus.Incomplete,
InternalWONumber = "10000000003",
istemplate = false
},
new WorkOrder
{
Id = 4,
AccountId = 1,
SiteCode = "BK5",
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000004",
istemplate = false
});
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(2026, 6, 22),
DateTo = new DateOnly(2026, 6, 26),
UnscheduledOnly = true,
Sites = new List<string> { "BK5" }
}, WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher"), "disp-1");
Assert.Equal(1, result.TotalCount);
Assert.Single(result.Items);
Assert.Equal(1, result.Items.Single().Id);
}
[Fact]
public async Task SearchAsync_UnscheduledOnly_NoUndatedRows_ReturnsEmptyPage()
{
await using var context = CreateContext();
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001",
istemplate = false
},
new WorkOrder
{
Id = 2,
ScheduledDate = new DateTime(2026, 6, 24),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000002",
istemplate = false
});
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),
UnscheduledOnly = true,
Page = 0,
PageSize = 25
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(0, result.TotalCount);
Assert.Empty(result.Items);
Assert.Equal(0, result.Page);
Assert.Equal(25, result.PageSize);
}
[Fact]
public async Task SearchAsync_UnscheduledOnly_PaginatesStableOrderWithoutDuplicates()
{
await using var context = CreateContext();
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
ScheduledDate = null,
LifecycleStatus = LifecycleStatus.Incomplete,
InternalWONumber = "10000000003",
istemplate = false
},
new WorkOrder
{
Id = 2,
ScheduledDate = null,
LifecycleStatus = LifecycleStatus.Incomplete,
InternalWONumber = "10000000001",
istemplate = false
},
new WorkOrder
{
Id = 3,
ScheduledDate = null,
LifecycleStatus = LifecycleStatus.Incomplete,
InternalWONumber = "10000000002",
istemplate = false
},
new WorkOrder
{
Id = 4,
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000000",
istemplate = false
});
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(2026, 6, 22),
DateTo = new DateOnly(2026, 6, 26),
UnscheduledOnly = true,
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(2026, 6, 22),
DateTo = new DateOnly(2026, 6, 26),
UnscheduledOnly = true,
Page = 1,
PageSize = 2
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(3, page0.TotalCount);
Assert.Equal(2, page0.Items.Count());
Assert.Equal(new[] { 2, 3 }, page0.Items.Select(i => i.Id).ToArray());
Assert.Equal(3, page1.TotalCount);
Assert.Single(page1.Items);
Assert.Equal(1, page1.Items.Single().Id);
Assert.Empty(page0.Items.Select(i => i.Id).Intersect(page1.Items.Select(i => i.Id)));
Assert.Equal(new[] { 1, 2, 3 }, page0.Items.Concat(page1.Items).Select(i => i.Id).OrderBy(id => id).ToArray());
}
[Fact]
public async Task SearchAsync_UnscheduledOnly_ExcludesCompletedAndCanceledUndatedRows()
{
await using var context = CreateContext();
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
ScheduledDate = null,
LifecycleStatus = LifecycleStatus.Incomplete,
InternalWONumber = "10000000001",
istemplate = false
},
new WorkOrder
{
Id = 2,
ScheduledDate = null,
LifecycleStatus = LifecycleStatus.Completed,
InternalWONumber = "10000000002",
istemplate = false
},
new WorkOrder
{
Id = 3,
ScheduledDate = null,
LifecycleStatus = LifecycleStatus.Canceled,
InternalWONumber = "10000000003",
istemplate = false
},
new WorkOrder
{
Id = 4,
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Completed,
InternalWONumber = "10000000004",
istemplate = false
});
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(2026, 6, 22),
DateTo = new DateOnly(2026, 6, 26),
UnscheduledOnly = true
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(1, result.TotalCount);
Assert.Single(result.Items);
Assert.Equal(1, result.Items.Single().Id);
Assert.Null(result.Items.Single().ScheduledDate);
Assert.DoesNotContain(result.Items, i => i.Id == 2);
Assert.DoesNotContain(result.Items, i => i.Id == 3);
Assert.DoesNotContain(result.Items, i => i.Id == 4);
}
}