fix(work-orders): use dedicated overdue query flag

Stop treating WorkOrderType.Other as an overdue sentinel. Board and advanced search now accept overdue=true while types=Other filters real Other rows; combining both uses OR.
This commit is contained in:
Arthur Bassi 2026-07-21 10:36:21 -03:00
parent 188b4f0c58
commit f6c7c1ec7f
13 changed files with 134 additions and 12 deletions

View file

@ -112,6 +112,7 @@ namespace Api.SeaHavenIndustries.Controllers
[FromQuery] List<string>? dispatchers = null, [FromQuery] List<string>? dispatchers = null,
[FromQuery] bool myWorkOrders = false, [FromQuery] bool myWorkOrders = false,
[FromQuery] List<WorkOrderType>? types = null, [FromQuery] List<WorkOrderType>? types = null,
[FromQuery] bool overdue = false,
[FromQuery] string? search = null) [FromQuery] string? search = null)
{ {
try try
@ -124,6 +125,7 @@ namespace Api.SeaHavenIndustries.Controllers
Dispatchers = dispatchers, Dispatchers = dispatchers,
MyWorkOrders = myWorkOrders, MyWorkOrders = myWorkOrders,
Types = types, Types = types,
Overdue = overdue,
Search = search Search = search
}; };

View file

@ -38,22 +38,25 @@ namespace SeaHaven.DataServices.Helpers
} }
/// <summary> /// <summary>
/// FE sends <see cref="WorkOrderType.Other"/> (99) as the overdue/isPastDue sentinel — not a real type filter. /// Filters by real <paramref name="types"/> (including <see cref="WorkOrderType.Other"/>)
/// and/or past-due rows when <paramref name="overdue"/> is true.
/// Types and overdue are combined with OR when both are present.
/// </summary> /// </summary>
public static IQueryable<WorkOrder> ApplyTypeAndOverdueFilter( public static IQueryable<WorkOrder> ApplyTypeAndOverdueFilter(
IQueryable<WorkOrder> query, IQueryable<WorkOrder> query,
IReadOnlyList<WorkOrderType>? types, IReadOnlyList<WorkOrderType>? types,
bool overdue = false,
DateTime? utcNow = null) DateTime? utcNow = null)
{ {
if (types == null || types.Count == 0) var hasTypes = types != null && types.Count > 0;
if (!hasTypes && !overdue)
return query; return query;
var today = (utcNow ?? DateTime.UtcNow).Date; var today = (utcNow ?? DateTime.UtcNow).Date;
var includeOverdue = types.Contains(WorkOrderType.Other); var typeFilters = hasTypes ? types!.ToList() : new List<WorkOrderType>();
var typeFilters = types.Where(t => t != WorkOrderType.Other).ToList();
// Keep past-due predicate inline — EF cannot translate local method calls. // Keep past-due predicate inline — EF cannot translate local method calls.
if (includeOverdue && typeFilters.Count == 0) if (overdue && !hasTypes)
{ {
return query.Where(w => return query.Where(w =>
w.ScheduledDate != null w.ScheduledDate != null
@ -62,7 +65,7 @@ namespace SeaHaven.DataServices.Helpers
&& w.LifecycleStatus != LifecycleStatus.Canceled); && w.LifecycleStatus != LifecycleStatus.Canceled);
} }
if (includeOverdue) if (overdue)
{ {
return query.Where(w => return query.Where(w =>
(w.ScheduledDate != null (w.ScheduledDate != null
@ -77,8 +80,9 @@ namespace SeaHaven.DataServices.Helpers
public static IQueryable<WorkOrder> ApplyTypeFilter( public static IQueryable<WorkOrder> ApplyTypeFilter(
IQueryable<WorkOrder> query, IQueryable<WorkOrder> query,
IReadOnlyList<WorkOrderType>? types) IReadOnlyList<WorkOrderType>? types,
=> ApplyTypeAndOverdueFilter(query, types); bool overdue = false)
=> ApplyTypeAndOverdueFilter(query, types, overdue);
public static IQueryable<WorkOrder> ApplySiteFilter( public static IQueryable<WorkOrder> ApplySiteFilter(
IQueryable<WorkOrder> query, IQueryable<WorkOrder> query,

View file

@ -19,7 +19,7 @@ namespace SeaHaven.DataServices.Implementation
var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo); baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo);
baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites); baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites);
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types); baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue);
baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter( baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter(
baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId); baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId);
baseQuery = WorkOrderBoardQueryFilters.ApplyStatusFilter(baseQuery, query.Statuses); baseQuery = WorkOrderBoardQueryFilters.ApplyStatusFilter(baseQuery, query.Statuses);

View file

@ -23,7 +23,7 @@ namespace SeaHaven.DataServices.Implementation
var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter( baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter(
baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId); baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId);
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types); baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue);
var scheduledBase = baseQuery.Where(w => var scheduledBase = baseQuery.Where(w =>
(w.ScheduledDate != null (w.ScheduledDate != null

View file

@ -9,6 +9,7 @@ namespace SeaHaven.DataServices.Interfaces
DateOnly DateTo, DateOnly DateTo,
IReadOnlyList<string>? Sites, IReadOnlyList<string>? Sites,
IReadOnlyList<WorkOrderType>? Types, IReadOnlyList<WorkOrderType>? Types,
bool Overdue,
IReadOnlyList<string>? Dispatchers, IReadOnlyList<string>? Dispatchers,
IReadOnlyList<LifecycleStatus>? Statuses, IReadOnlyList<LifecycleStatus>? Statuses,
IReadOnlyList<string>? PmTypes, IReadOnlyList<string>? PmTypes,

View file

@ -8,6 +8,7 @@ namespace SeaHaven.DataServices.Interfaces
IReadOnlyList<string>? Dispatchers, IReadOnlyList<string>? Dispatchers,
bool MyWorkOrders, bool MyWorkOrders,
IReadOnlyList<WorkOrderType>? Types, IReadOnlyList<WorkOrderType>? Types,
bool Overdue,
string? Search, string? Search,
string? CurrentUserId); string? CurrentUserId);

View file

@ -10,6 +10,8 @@ namespace SeaHaven.Services.DTOs
public List<string>? Dispatchers { get; set; } public List<string>? Dispatchers { get; set; }
public bool MyWorkOrders { get; set; } public bool MyWorkOrders { get; set; }
public List<WorkOrderType>? Types { get; set; } public List<WorkOrderType>? Types { get; set; }
/// <summary>When true, include past-due (isPastDue) rows. Combined with Types via OR.</summary>
public bool Overdue { get; set; }
public string? Search { get; set; } public string? Search { get; set; }
} }

View file

@ -51,6 +51,8 @@ namespace SeaHaven.Services.DTOs
public DateOnly? DateTo { get; set; } public DateOnly? DateTo { get; set; }
public List<string>? Sites { get; set; } public List<string>? Sites { get; set; }
public List<WorkOrderType>? Types { get; set; } public List<WorkOrderType>? Types { get; set; }
/// <summary>When true, include past-due (isPastDue) rows. Combined with Types via OR.</summary>
public bool Overdue { get; set; }
public List<string>? Dispatchers { get; set; } public List<string>? Dispatchers { get; set; }
public List<LifecycleStatus>? Statuses { get; set; } public List<LifecycleStatus>? Statuses { get; set; }
public List<string>? PmTypes { get; set; } public List<string>? PmTypes { get; set; }

View file

@ -38,6 +38,7 @@ namespace SeaHaven.Services.Implementation
dateRange.To, dateRange.To,
query.Sites, query.Sites,
query.Types, query.Types,
query.Overdue,
query.Dispatchers, query.Dispatchers,
query.Statuses, query.Statuses,
query.PmTypes, query.PmTypes,

View file

@ -30,6 +30,7 @@ namespace SeaHaven.Services.Implementation
query.Dispatchers, query.Dispatchers,
query.MyWorkOrders, query.MyWorkOrders,
query.Types, query.Types,
query.Overdue,
query.Search, query.Search,
currentUserId); currentUserId);

View file

@ -134,6 +134,7 @@ public class WorkOrderBoardSearchCountsTests
null, null,
false, false,
null, null,
false,
"BK5", "BK5",
null)); null));
@ -288,7 +289,7 @@ public class WorkOrderAdvancedSearchServiceTests
} }
[Fact] [Fact]
public async Task SearchAsync_OverdueTypeSentinel_FiltersPastDueOnly() public async Task SearchAsync_TypesOther_ReturnsOtherRowsNotPastDueSentinel()
{ {
await using var context = CreateContext(); await using var context = CreateContext();
var today = DateTime.UtcNow.Date; var today = DateTime.UtcNow.Date;
@ -324,11 +325,114 @@ public class WorkOrderAdvancedSearchServiceTests
Types = new List<WorkOrderType> { WorkOrderType.Other } Types = new List<WorkOrderType> { WorkOrderType.Other }
}, null); }, null);
Assert.Equal(1, result.TotalCount);
Assert.Equal(2, result.Items.First().Id);
Assert.Equal(WorkOrderType.Other, result.Items.First().WorkOrderType);
Assert.False(result.Items.First().IsPastDue);
}
[Fact]
public async Task SearchAsync_OverdueTrue_ReturnsPastDueRowsOnly()
{
await using var context = CreateContext();
var today = DateTime.UtcNow.Date;
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
WorkOrderType = WorkOrderType.PM,
ScheduledDate = today.AddDays(-3),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
},
new WorkOrder
{
Id = 2,
WorkOrderType = WorkOrderType.Other,
ScheduledDate = today.AddDays(3),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000002"
},
new WorkOrder
{
Id = 3,
WorkOrderType = WorkOrderType.Reactive,
ScheduledDate = today.AddDays(1),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000003"
});
await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)),
DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
Overdue = true
}, null);
Assert.Equal(1, result.TotalCount); Assert.Equal(1, result.TotalCount);
Assert.Equal(1, result.Items.First().Id); Assert.Equal(1, result.Items.First().Id);
Assert.True(result.Items.First().IsPastDue); Assert.True(result.Items.First().IsPastDue);
} }
[Fact]
public async Task SearchAsync_OtherPlusOverdue_ReturnsUnion()
{
await using var context = CreateContext();
var today = DateTime.UtcNow.Date;
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
WorkOrderType = WorkOrderType.PM,
ScheduledDate = today.AddDays(-3),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
},
new WorkOrder
{
Id = 2,
WorkOrderType = WorkOrderType.Other,
ScheduledDate = today.AddDays(3),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000002"
},
new WorkOrder
{
Id = 3,
WorkOrderType = WorkOrderType.Reactive,
ScheduledDate = today.AddDays(1),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000003"
});
await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)),
DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
Types = new List<WorkOrderType> { WorkOrderType.Other },
Overdue = true
}, null);
Assert.Equal(2, result.TotalCount);
Assert.Contains(result.Items, i => i.Id == 1 && i.IsPastDue);
Assert.Contains(result.Items, i => i.Id == 2 && i.WorkOrderType == WorkOrderType.Other);
Assert.DoesNotContain(result.Items, i => i.Id == 3);
}
[Fact] [Fact]
public async Task SearchAsync_IncludesUnscheduledRows() public async Task SearchAsync_IncludesUnscheduledRows()
{ {

View file

@ -67,6 +67,7 @@ public class WorkOrderBoardDataServiceTests
null, null,
false, false,
null, null,
false,
null, null,
null)); null));
@ -109,6 +110,7 @@ public class WorkOrderBoardDataServiceTests
new[] { "user-a" }, new[] { "user-a" },
false, false,
null, null,
false,
null, null,
null)); null));
@ -147,6 +149,7 @@ public class WorkOrderBoardDataServiceTests
null, null,
true, true,
null, null,
false,
null, null,
"me")); "me"));

View file

@ -47,7 +47,8 @@ Advanced search cross-week paginado.
| `datePreset` | enum | `thisWeek`, `lastWeek`, `thisMonth`, `last3Months`, `nextWeek`, `nextMonth`, `custom` | | `datePreset` | enum | `thisWeek`, `lastWeek`, `thisMonth`, `last3Months`, `nextWeek`, `nextMonth`, `custom` |
| `dateFrom` / `dateTo` | date | Obrigatórios se `datePreset=custom` | | `dateFrom` / `dateTo` | date | Obrigatórios se `datePreset=custom` |
| `sites` | string[] | `SiteCode` | | `sites` | string[] | `SiteCode` |
| `types` | WorkOrderType[] | Tipo WO | | `types` | WorkOrderType[] | Tipo WO (inclui `Other` como tipo real) |
| `overdue` | bool | Past-due (`isPastDue`); default `false`; OR com `types` |
| `dispatchers` | string[] | GUIDs + `__unassigned__` | | `dispatchers` | string[] | GUIDs + `__unassigned__` |
| `statuses` | LifecycleStatus[] | Status operacional | | `statuses` | LifecycleStatus[] | Status operacional |
| `pmTypes` | string[] | Match em `Trade`/`Problem` (contains, case-insensitive) | | `pmTypes` | string[] | Match em `Trade`/`Problem` (contains, case-insensitive) |