mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 16:19:07 +00:00
Merge 7297e30212 into f7db6b9f84
This commit is contained in:
commit
49aa9fe204
14 changed files with 459 additions and 11 deletions
|
|
@ -40,7 +40,8 @@ public class DashboardControllerTests
|
|||
{
|
||||
ScheduledTomorrow = 3,
|
||||
PendingUplifts = 5,
|
||||
AvetaPending = 7
|
||||
AvetaPending = 7,
|
||||
Unassigned = 11
|
||||
});
|
||||
|
||||
var result = await NewController(service).GetStats(
|
||||
|
|
@ -50,5 +51,6 @@ public class DashboardControllerTests
|
|||
((int)Prop(body, "scheduledTomorrow")).Should().Be(3);
|
||||
((int)Prop(body, "pendingUplifts")).Should().Be(5);
|
||||
((int)Prop(body, "avetaPending")).Should().Be(7);
|
||||
((int)Prop(body, "unassigned")).Should().Be(11);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
179
Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs
Normal file
179
Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
using System.Security.Claims;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// SH-392: the Dashboard "Unassigned" count is open work orders with no
|
||||
/// dispatcher in the selected period, and it equals the Work Orders list the
|
||||
/// tile drills into (GET /board/search, Dispatcher = Unassigned).
|
||||
/// </summary>
|
||||
public class DashboardUnassignedTests
|
||||
{
|
||||
private static readonly DateOnly From = new(2026, 9, 21);
|
||||
private static readonly DateOnly To = new(2026, 9, 25);
|
||||
|
||||
private static readonly List<LifecycleStatus> OpenStatuses = Enum.GetValues<LifecycleStatus>()
|
||||
.Where(s => s != LifecycleStatus.Completed && s != LifecycleStatus.Canceled)
|
||||
.ToList();
|
||||
|
||||
private static ApplicationDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static WorkOrderAccountResolver Resolver(ApplicationDbContext ctx)
|
||||
=> new(new AccountDataService(ctx), new LocationDataService(ctx));
|
||||
|
||||
private static DashboardService NewDashboard(ApplicationDbContext ctx)
|
||||
=> new(new DashboardDataService(ctx), Resolver(ctx));
|
||||
|
||||
private static WorkOrderAdvancedSearchService NewSearch(ApplicationDbContext ctx)
|
||||
=> new(new WorkOrderAdvancedSearchDataService(ctx), Resolver(ctx));
|
||||
|
||||
private static ClaimsPrincipal AccountUser(int accountId, string role = "Admin", string userId = "admin-1")
|
||||
=> new(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()),
|
||||
new Claim(ClaimTypes.NameIdentifier, userId),
|
||||
new Claim(ClaimTypes.Role, role)
|
||||
}, "test"));
|
||||
|
||||
private static WorkOrder Wo(
|
||||
int id,
|
||||
DateTime? scheduled,
|
||||
string? assignTo = null,
|
||||
LifecycleStatus status = LifecycleStatus.Scheduled,
|
||||
DateOnly? targetWeek = null,
|
||||
int accountId = 1)
|
||||
=> new()
|
||||
{
|
||||
Id = id,
|
||||
AccountId = accountId,
|
||||
InternalWONumber = $"2000000{id:0000}",
|
||||
AssignTo = assignTo,
|
||||
ScheduledDate = scheduled,
|
||||
ScheduleWeekOnly = targetWeek.HasValue,
|
||||
TargetWeek = targetWeek,
|
||||
LifecycleStatus = status,
|
||||
istemplate = false
|
||||
};
|
||||
|
||||
private static void Seed(ApplicationDbContext ctx)
|
||||
{
|
||||
ctx.workOrders.AddRange(
|
||||
Wo(1, new DateTime(2026, 9, 22)), // counted
|
||||
Wo(2, new DateTime(2026, 9, 23), assignTo: ""), // counted: blank assignee
|
||||
Wo(3, null, targetWeek: new DateOnly(2026, 9, 21)), // counted: week-only in range
|
||||
Wo(4, new DateTime(2026, 9, 24), assignTo: "disp-1"), // assigned
|
||||
Wo(5, new DateTime(2026, 9, 24), status: LifecycleStatus.Completed),
|
||||
Wo(6, new DateTime(2026, 9, 24), status: LifecycleStatus.Canceled),
|
||||
Wo(7, new DateTime(2026, 8, 4)), // out of range
|
||||
Wo(8, null, status: LifecycleStatus.Incomplete), // undated
|
||||
Wo(9, new DateTime(2026, 9, 22), accountId: 2), // other tenant
|
||||
Wo(10, null, status: LifecycleStatus.Incomplete, accountId: 2)); // other tenant, undated
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatsAsync_Range_CountsOpenUnassignedScheduledInPeriod()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
Seed(ctx);
|
||||
|
||||
var stats = await NewDashboard(ctx).GetStatsAsync(
|
||||
AccountUser(1), new DashboardStatsQueryDTO { DateFrom = From, DateTo = To }, CancellationToken.None);
|
||||
|
||||
stats.Unassigned.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatsAsync_AllTime_CountsEveryOpenUnassignedIncludingUndated()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
Seed(ctx);
|
||||
|
||||
var stats = await NewDashboard(ctx).GetStatsAsync(
|
||||
AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None);
|
||||
|
||||
// 1, 2, 3, 7 (out of any week but open) and 8 (undated).
|
||||
stats.Unassigned.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatsAsync_Unassigned_NeverCountsAnotherTenantsWorkOrders()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
Seed(ctx);
|
||||
|
||||
var accountTwo = await NewDashboard(ctx).GetStatsAsync(
|
||||
AccountUser(2), new DashboardStatsQueryDTO(), CancellationToken.None);
|
||||
var accountTwoInRange = await NewDashboard(ctx).GetStatsAsync(
|
||||
AccountUser(2), new DashboardStatsQueryDTO { DateFrom = From, DateTo = To }, CancellationToken.None);
|
||||
|
||||
accountTwo.Unassigned.Should().Be(2);
|
||||
accountTwoInRange.Unassigned.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatsAsync_DispatcherScope_HasNoUnassignedWork()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
Seed(ctx);
|
||||
|
||||
var dispatcher = await NewDashboard(ctx).GetStatsAsync(
|
||||
AccountUser(1, role: "Dispatcher", userId: "disp-1"),
|
||||
new DashboardStatsQueryDTO(),
|
||||
CancellationToken.None);
|
||||
var adminPickingDispatcher = await NewDashboard(ctx).GetStatsAsync(
|
||||
AccountUser(1),
|
||||
new DashboardStatsQueryDTO { DispatcherId = "disp-1" },
|
||||
CancellationToken.None);
|
||||
|
||||
dispatcher.Unassigned.Should().Be(0);
|
||||
adminPickingDispatcher.Unassigned.Should().Be(0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task GetStatsAsync_UnassignedMatchesTheDrillDownList(bool withRange)
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
Seed(ctx);
|
||||
// Dated outside the board's all-weeks window: neither side may count them.
|
||||
ctx.workOrders.AddRange(Wo(11, new DateTime(1999, 12, 31)), Wo(12, new DateTime(2100, 1, 1)));
|
||||
ctx.SaveChanges();
|
||||
var user = AccountUser(1);
|
||||
|
||||
var stats = await NewDashboard(ctx).GetStatsAsync(
|
||||
user,
|
||||
withRange ? new DashboardStatsQueryDTO { DateFrom = From, DateTo = To } : new DashboardStatsQueryDTO(),
|
||||
CancellationToken.None);
|
||||
|
||||
// What the Work Orders board sends for the tile's drill-down link.
|
||||
var list = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto
|
||||
{
|
||||
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
|
||||
DateFrom = withRange ? From : new DateOnly(2000, 1, 1),
|
||||
DateTo = withRange ? To : new DateOnly(2099, 12, 31),
|
||||
IncludeDateless = !withRange,
|
||||
Dispatchers = new List<string> { "__unassigned__" },
|
||||
Statuses = OpenStatuses,
|
||||
PageSize = 200
|
||||
}, user, "admin-1");
|
||||
|
||||
stats.Unassigned.Should().Be(list.TotalCount);
|
||||
}
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
scheduledTomorrow = stats.ScheduledTomorrow,
|
||||
pendingUplifts = stats.PendingUplifts,
|
||||
avetaPending = stats.AvetaPending,
|
||||
unassigned = stats.Unassigned,
|
||||
breakdown = stats.Breakdown,
|
||||
dueCount = stats.DueCount,
|
||||
completedDueCount = stats.CompletedDueCount,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,14 @@ namespace SeaHaven.DataServices.Helpers
|
|||
{
|
||||
public static class WorkOrderBoardQueryFilters
|
||||
{
|
||||
/// <summary>
|
||||
/// The window the Work Orders board searches when no date range is selected
|
||||
/// (client ADVANCED_SEARCH_ALL_WEEKS_FROM/TO), used with includeDateless.
|
||||
/// Counts that drill into that list use it too, so both see the same rows.
|
||||
/// </summary>
|
||||
public static readonly DateOnly AllWeeksFrom = new(2000, 1, 1);
|
||||
public static readonly DateOnly AllWeeksTo = new(2099, 12, 31);
|
||||
|
||||
public static IQueryable<WorkOrder> ApplyBaseScope(IQueryable<WorkOrder> query)
|
||||
=> query.Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
|
||||
|
||||
|
|
@ -220,13 +228,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 +255,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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,41 @@ namespace SeaHaven.DataServices.Implementation
|
|||
};
|
||||
}
|
||||
|
||||
private static readonly IReadOnlyList<LifecycleStatus> OpenLifecycleStatuses = Enum
|
||||
.GetValues<LifecycleStatus>()
|
||||
.Where(s => s != LifecycleStatus.Completed && s != LifecycleStatus.Canceled)
|
||||
.ToList();
|
||||
|
||||
public async Task<int> CountOpenUnassignedAsync(
|
||||
int? accountId,
|
||||
string? dispatcherId,
|
||||
DateOnly? dateFrom,
|
||||
DateOnly? dateTo,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// A dispatcher-scoped Dashboard has no unassigned work by definition.
|
||||
if (dispatcherId != null)
|
||||
return 0;
|
||||
|
||||
var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||||
if (accountId is int scopedAccountId)
|
||||
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, scopedAccountId);
|
||||
query = WorkOrderBoardQueryFilters.ApplyDispatcherFilter(
|
||||
query, new[] { "__unassigned__" }, myWorkOrders: false, currentUserId: null);
|
||||
query = WorkOrderBoardQueryFilters.ApplyStatusFilter(query, OpenLifecycleStatuses);
|
||||
// All time counts what the board lists with no range: every week plus
|
||||
// undated work, so the tile and its drill-down agree (SH-392).
|
||||
query = dateFrom is DateOnly from && dateTo is DateOnly to
|
||||
? WorkOrderBoardQueryFilters.ApplyDateRangeFilter(query, from, to)
|
||||
: WorkOrderBoardQueryFilters.ApplyDateRangeFilter(
|
||||
query,
|
||||
WorkOrderBoardQueryFilters.AllWeeksFrom,
|
||||
WorkOrderBoardQueryFilters.AllWeeksTo,
|
||||
includeDateless: true);
|
||||
|
||||
return await query.CountAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DashboardWorkOrder>> GetDashboardWorkOrdersAsync(
|
||||
int? accountId,
|
||||
string? dispatcherId,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,18 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
DateOnly today,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Open work orders with no dispatcher in the period, counted with the
|
||||
/// same filters as GET /board/search for Dispatcher=Unassigned (SH-392),
|
||||
/// so the Dashboard number matches the list it drills into.
|
||||
/// </summary>
|
||||
Task<int> CountOpenUnassignedAsync(
|
||||
int? accountId,
|
||||
string? dispatcherId,
|
||||
DateOnly? dateFrom,
|
||||
DateOnly? dateTo,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<DashboardWorkOrder>> GetDashboardWorkOrdersAsync(
|
||||
int? accountId,
|
||||
string? dispatcherId,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -106,6 +106,8 @@ namespace SeaHaven.Services.DTOs
|
|||
public int ScheduledTomorrow { get; set; }
|
||||
public int PendingUplifts { get; set; }
|
||||
public int AvetaPending { get; set; }
|
||||
/// <summary>Open work orders with no dispatcher in the selected period (SH-392).</summary>
|
||||
public int Unassigned { get; set; }
|
||||
public DashboardBreakdownDTO Breakdown { get; set; } = new();
|
||||
public int DueCount { get; set; }
|
||||
public int CompletedDueCount { get; set; }
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -43,6 +43,12 @@ namespace SeaHaven.Services.Implementation
|
|||
query.DateTo,
|
||||
cancellationToken);
|
||||
var metrics = DashboardMetrics.Calculate(workOrders, DashboardBusinessTime.Today());
|
||||
var unassigned = await _dataService.CountOpenUnassignedAsync(
|
||||
accountId,
|
||||
dispatcherId,
|
||||
query.DateFrom,
|
||||
query.DateTo,
|
||||
cancellationToken);
|
||||
|
||||
var useMetrics = query.DateFrom.HasValue && query.DateTo.HasValue || dispatcherId is not null;
|
||||
|
||||
|
|
@ -55,6 +61,7 @@ namespace SeaHaven.Services.Implementation
|
|||
ScheduledTomorrow = kpis.ScheduledTomorrow,
|
||||
PendingUplifts = kpis.PendingUplifts,
|
||||
AvetaPending = kpis.AvetaPending,
|
||||
Unassigned = unassigned,
|
||||
Breakdown = metrics.Breakdown,
|
||||
DueCount = metrics.DueCount,
|
||||
CompletedDueCount = metrics.CompletedDueCount,
|
||||
|
|
|
|||
|
|
@ -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,173 @@
|
|||
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 WorkOrder NotWeekOnly(WorkOrder wo)
|
||||
{
|
||||
wo.ScheduleWeekOnly = false;
|
||||
return wo;
|
||||
}
|
||||
|
||||
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)),
|
||||
// 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.
|
||||
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, 4 }, result.Items.Select(i => i.Id).OrderBy(id => 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