mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
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.
This commit is contained in:
parent
f7db6b9f84
commit
028908bd5a
7 changed files with 201 additions and 10 deletions
|
|
@ -220,13 +220,26 @@ namespace SeaHaven.DataServices.Helpers
|
|||
&& w.LifecycleStatus != LifecycleStatus.Completed
|
||||
&& w.LifecycleStatus != LifecycleStatus.Canceled);
|
||||
|
||||
/// <summary>
|
||||
/// 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 <paramref name="includeDateless"/>
|
||||
/// is set, which callers send for searches with no date range selected.
|
||||
/// Completed and canceled dateless rows stay out, matching
|
||||
/// <see cref="ApplyUnscheduledOnlyFilter"/>.
|
||||
/// </summary>
|
||||
public static IQueryable<WorkOrder> ApplyDateRangeFilter(
|
||||
IQueryable<WorkOrder> 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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<WorkOrderBoardRawRow> Rows,
|
||||
|
|
|
|||
|
|
@ -87,6 +87,12 @@ namespace SeaHaven.Services.DTOs
|
|||
/// filter; every other facet still applies.
|
||||
/// </summary>
|
||||
public bool UnscheduledOnly { get; set; }
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public bool IncludeDateless { get; set; }
|
||||
public List<string>? Sites { get; set; }
|
||||
public List<string>? Regions { get; set; }
|
||||
public List<WorkOrderType>? Types { get; set; }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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<ApplicationDbContext>()
|
||||
.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<string> { "__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<string> { "__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());
|
||||
}
|
||||
}
|
||||
|
|
@ -319,6 +319,7 @@ public class WorkOrderAdvancedSearchServiceTests
|
|||
DateFrom = new DateOnly(2000, 1, 1),
|
||||
DateTo = new DateOnly(2099, 12, 31),
|
||||
Dispatchers = new List<string> { "__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<string> { "__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<string> { "__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<string> { "__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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue