From 4876fa27175dee7254b08ae164265999977bb34e Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:02:28 -0300 Subject: [PATCH 1/4] feat(dashboard): count open unassigned work orders for the period (SH-392) GET /dashboard/stats now returns unassigned: open (not Completed or Canceled) work orders with no dispatcher in the selected period, within the caller's server-derived account scope. It uses the same filters as the Unassigned board search, so the Dashboard number equals the list it drills into. A dispatcher-scoped Dashboard has no unassigned work and returns 0. --- .../DashboardControllerTests.cs | 4 +- .../DashboardUnassignedTests.cs | 176 ++++++++++++++++++ .../Controllers/DashboardController.cs | 1 + .../Implementation/DashboardDataService.cs | 28 +++ .../Interfaces/IDashboardDataService.cs | 12 ++ SeaHaven.Services/DTOs/DashboardDTOs.cs | 2 + .../Implementation/DashboardService.cs | 7 + 7 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs diff --git a/Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs b/Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs index 4ef3b58..ddd6082 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs @@ -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); } } diff --git a/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs b/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs new file mode 100644 index 0000000..dbdbc7e --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs @@ -0,0 +1,176 @@ +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; + +/// +/// 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). +/// +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 OpenStatuses = Enum.GetValues() + .Where(s => s != LifecycleStatus.Completed && s != LifecycleStatus.Canceled) + .ToList(); + + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .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); + 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 { "__unassigned__" }, + Statuses = OpenStatuses, + PageSize = 200 + }, user, "admin-1"); + + stats.Unassigned.Should().Be(list.TotalCount); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index b53c4bf..e40c9fd 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -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, diff --git a/SeaHaven.DataServices/Implementation/DashboardDataService.cs b/SeaHaven.DataServices/Implementation/DashboardDataService.cs index a6c03f2..7f9767f 100644 --- a/SeaHaven.DataServices/Implementation/DashboardDataService.cs +++ b/SeaHaven.DataServices/Implementation/DashboardDataService.cs @@ -39,6 +39,34 @@ namespace SeaHaven.DataServices.Implementation }; } + private static readonly IReadOnlyList OpenLifecycleStatuses = Enum + .GetValues() + .Where(s => s != LifecycleStatus.Completed && s != LifecycleStatus.Canceled) + .ToList(); + + public async Task 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); + if (dateFrom is DateOnly from && dateTo is DateOnly to) + query = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(query, from, to); + + return await query.CountAsync(cancellationToken); + } + public async Task> GetDashboardWorkOrdersAsync( int? accountId, string? dispatcherId, diff --git a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs index 51ef7b5..d55bdea 100644 --- a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs @@ -14,6 +14,18 @@ namespace SeaHaven.DataServices.Interfaces DateOnly today, CancellationToken cancellationToken); + /// + /// 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. + /// + Task CountOpenUnassignedAsync( + int? accountId, + string? dispatcherId, + DateOnly? dateFrom, + DateOnly? dateTo, + CancellationToken cancellationToken); + Task> GetDashboardWorkOrdersAsync( int? accountId, string? dispatcherId, diff --git a/SeaHaven.Services/DTOs/DashboardDTOs.cs b/SeaHaven.Services/DTOs/DashboardDTOs.cs index 9cf76c0..146c491 100644 --- a/SeaHaven.Services/DTOs/DashboardDTOs.cs +++ b/SeaHaven.Services/DTOs/DashboardDTOs.cs @@ -106,6 +106,8 @@ namespace SeaHaven.Services.DTOs public int ScheduledTomorrow { get; set; } public int PendingUplifts { get; set; } public int AvetaPending { get; set; } + /// Open work orders with no dispatcher in the selected period (SH-392). + public int Unassigned { get; set; } public DashboardBreakdownDTO Breakdown { get; set; } = new(); public int DueCount { get; set; } public int CompletedDueCount { get; set; } diff --git a/SeaHaven.Services/Implementation/DashboardService.cs b/SeaHaven.Services/Implementation/DashboardService.cs index 02642dc..e528b97 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -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, From 7297e302129f8dbec0c29a74b9db91f6cca65c1d Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:45:06 -0300 Subject: [PATCH 2/4] fix(dashboard): count All time over the same window the board lists (SH-392) The Unassigned drill-down opens the board with no range, which searches 2000-01-01..2099-12-31 plus undated work. The All time count had no bounds, so a work order dated outside that window was counted but not listed. The count now uses the same window. --- .../DashboardUnassignedTests.cs | 3 +++ .../Helpers/WorkOrderBoardQueryFilters.cs | 8 ++++++++ .../Implementation/DashboardDataService.cs | 11 +++++++++-- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs b/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs index dbdbc7e..b3cc03e 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs @@ -152,6 +152,9 @@ public class DashboardUnassignedTests { 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( diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs index b665f2c..d4810ca 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -5,6 +5,14 @@ namespace SeaHaven.DataServices.Helpers { public static class WorkOrderBoardQueryFilters { + /// + /// 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. + /// + public static readonly DateOnly AllWeeksFrom = new(2000, 1, 1); + public static readonly DateOnly AllWeeksTo = new(2099, 12, 31); + public static IQueryable ApplyBaseScope(IQueryable query) => query.Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null)); diff --git a/SeaHaven.DataServices/Implementation/DashboardDataService.cs b/SeaHaven.DataServices/Implementation/DashboardDataService.cs index 7f9767f..02362a2 100644 --- a/SeaHaven.DataServices/Implementation/DashboardDataService.cs +++ b/SeaHaven.DataServices/Implementation/DashboardDataService.cs @@ -61,8 +61,15 @@ namespace SeaHaven.DataServices.Implementation query = WorkOrderBoardQueryFilters.ApplyDispatcherFilter( query, new[] { "__unassigned__" }, myWorkOrders: false, currentUserId: null); query = WorkOrderBoardQueryFilters.ApplyStatusFilter(query, OpenLifecycleStatuses); - if (dateFrom is DateOnly from && dateTo is DateOnly to) - query = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(query, from, to); + // 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); } From d6c5357fab5a24185f996d845a683f025508930d Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 03:34:24 -0300 Subject: [PATCH 3/4] fix(dashboard): count unassigned legacy rows with no lifecycle status (SH-392) The legacy create paths (WorkOrderDTOs, SyncService, the Blazor WorkorderService) still write Status without LifecycleStatus. The board status filter only matched LifecycleStatus. So an open unassigned row of that kind was left out of the Unassigned tile and its drill-down list, even though the Open tile in the same response counted it. ApplyStatusFilter now reads a row with no LifecycleStatus by its legacy status (LegacyStatus ?? Status), using the Phase0 backfill rules: known text maps as LifecycleStatusMapper does, and anything else, blank included, counts as Incomplete. The tile and /board/search share the predicate, so the count still matches the list it opens. --- .../DashboardUnassignedTests.cs | 70 ++++++++++++++++++- .../Helpers/WorkOrderBoardQueryFilters.cs | 55 ++++++++++++++- .../LifecycleStatusMapperTests.cs | 43 ++++++++++++ 3 files changed, 165 insertions(+), 3 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs b/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs index b3cc03e..81649e6 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs @@ -54,9 +54,11 @@ public class DashboardUnassignedTests int id, DateTime? scheduled, string? assignTo = null, - LifecycleStatus status = LifecycleStatus.Scheduled, + LifecycleStatus? status = LifecycleStatus.Scheduled, DateOnly? targetWeek = null, - int accountId = 1) + int accountId = 1, + string? legacyStatus = null, + string? statusText = null) => new() { Id = id, @@ -67,9 +69,25 @@ public class DashboardUnassignedTests ScheduleWeekOnly = targetWeek.HasValue, TargetWeek = targetWeek, LifecycleStatus = status, + LegacyStatus = legacyStatus, + Status = statusText, istemplate = false }; + // Rows written by legacy create paths, which set Status but no LifecycleStatus. + private static void SeedLegacy(ApplicationDbContext ctx) + { + var inRange = new DateTime(2026, 9, 22); + ctx.workOrders.AddRange( + Wo(13, inRange, status: null, statusText: "Open"), // counted + Wo(14, inRange, status: null, statusText: "On Hold"), // counted: Pending + Wo(15, inRange, status: null), // counted: no status at all + Wo(16, inRange, status: null, legacyStatus: "Done", statusText: "Open"), // closed: LegacyStatus wins + Wo(17, inRange, status: null, statusText: " Cancelled "), // canceled + Wo(18, inRange, assignTo: "disp-1", status: null, statusText: "Open")); // assigned + ctx.SaveChanges(); + } + private static void Seed(ApplicationDbContext ctx) { ctx.workOrders.AddRange( @@ -111,6 +129,53 @@ public class DashboardUnassignedTests stats.Unassigned.Should().Be(5); } + [Fact] + public async Task GetStatsAsync_CountsOpenLegacyRowsWithNoLifecycleStatus() + { + using var ctx = NewContext(); + Seed(ctx); + SeedLegacy(ctx); + + var inRange = await NewDashboard(ctx).GetStatsAsync( + AccountUser(1), new DashboardStatsQueryDTO { DateFrom = From, DateTo = To }, CancellationToken.None); + var allTime = await NewDashboard(ctx).GetStatsAsync( + AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None); + + // 1, 2, 3 plus legacy 13, 14 and 15. + inRange.Unassigned.Should().Be(6); + allTime.Unassigned.Should().Be(8); + } + + [Fact] + public async Task BoardSearch_StatusFilter_MatchesLegacyRowsByTheirLegacyStatus() + { + using var ctx = NewContext(); + var inRange = new DateTime(2026, 9, 22); + ctx.workOrders.AddRange( + Wo(1, inRange), + Wo(2, inRange, status: null, statusText: "scheduled"), + Wo(3, inRange, status: null, statusText: "Open"), + Wo(4, inRange, status: null, statusText: "Legacy Mystery"), + Wo(5, inRange, status: null)); + ctx.SaveChanges(); + var user = AccountUser(1); + + async Task> Ids(LifecycleStatus status) + => (await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = From, + DateTo = To, + Statuses = new List { status }, + PageSize = 200 + }, user, "admin-1")).Items.Select(row => row.Id); + + (await Ids(LifecycleStatus.Scheduled)).Should().BeEquivalentTo(new[] { 1, 2 }); + // Unknown or missing legacy text reads as Incomplete, as the Phase0 backfill set it. + (await Ids(LifecycleStatus.Incomplete)).Should().BeEquivalentTo(new[] { 3, 4, 5 }); + (await Ids(LifecycleStatus.Completed)).Should().BeEmpty(); + } + [Fact] public async Task GetStatsAsync_Unassigned_NeverCountsAnotherTenantsWorkOrders() { @@ -152,6 +217,7 @@ public class DashboardUnassignedTests { using var ctx = NewContext(); Seed(ctx); + SeedLegacy(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(); diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs index d4810ca..583aaa2 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -163,6 +163,44 @@ namespace SeaHaven.DataServices.Helpers || !WorkOrderBoardRegions.KnownStorageValues.Contains(w.Locations.State)))); } + /// + /// Lower-cased legacy status text and the lifecycle status it stands for: + /// the Phase0 backfill rules, kept equal to LifecycleStatusMapper.FromLegacyStatus. + /// + public static readonly IReadOnlyDictionary LegacyStatusValues = + new Dictionary(StringComparer.Ordinal) + { + ["open"] = LifecycleStatus.Incomplete, + ["incomplete"] = LifecycleStatus.Incomplete, + ["pending"] = LifecycleStatus.Pending, + ["onhold"] = LifecycleStatus.Pending, + ["on hold"] = LifecycleStatus.Pending, + ["scheduled"] = LifecycleStatus.Scheduled, + ["en route"] = LifecycleStatus.EnRoute, + ["enroute"] = LifecycleStatus.EnRoute, + ["on site"] = LifecycleStatus.OnSite, + ["onsite"] = LifecycleStatus.OnSite, + ["inprogress"] = LifecycleStatus.InProgress, + ["in progress"] = LifecycleStatus.InProgress, + ["completed"] = LifecycleStatus.Completed, + ["complete"] = LifecycleStatus.Completed, + ["done"] = LifecycleStatus.Completed, + ["closed"] = LifecycleStatus.Completed, + ["rescheduled"] = LifecycleStatus.Rescheduled, + ["cancelled"] = LifecycleStatus.Canceled, + ["canceled"] = LifecycleStatus.Canceled, + ["pending quote"] = LifecycleStatus.PendingQuote, + ["pendingquote"] = LifecycleStatus.PendingQuote, + ["pendingapproval"] = LifecycleStatus.PendingQuote, + ["pending approval"] = LifecycleStatus.PendingQuote, + }; + + /// + /// Keeps rows in . Rows with no LifecycleStatus + /// (legacy create paths still write only Status) match by their legacy + /// status, read as the Phase0 backfill does: known text maps through + /// , anything else, blank included, is Incomplete. + /// public static IQueryable ApplyStatusFilter( IQueryable query, IReadOnlyList? statuses) @@ -170,7 +208,22 @@ namespace SeaHaven.DataServices.Helpers if (statuses == null || statuses.Count == 0) return query; - return query.Where(w => w.LifecycleStatus != null && statuses.Contains(w.LifecycleStatus.Value)); + var legacyValues = LegacyStatusValues + .Where(entry => statuses.Contains(entry.Value)) + .Select(entry => entry.Key) + .ToList(); + var knownLegacyValues = LegacyStatusValues.Keys.ToList(); + var includeUnknownLegacy = statuses.Contains(LifecycleStatus.Incomplete); + + // Null check before Trim(): EF translates it, the in-memory provider runs it. + return query.Where(w => + (w.LifecycleStatus != null && statuses.Contains(w.LifecycleStatus.Value)) + || (w.LifecycleStatus == null + && (((w.LegacyStatus ?? w.Status) != null + && legacyValues.Contains((w.LegacyStatus ?? w.Status)!.Trim().ToLower())) + || (includeUnknownLegacy + && ((w.LegacyStatus ?? w.Status) == null + || !knownLegacyValues.Contains((w.LegacyStatus ?? w.Status)!.Trim().ToLower())))))); } public static IQueryable ApplyDocStatusFilter( diff --git a/SeaHavenIndustries.Tests/LifecycleStatusMapperTests.cs b/SeaHavenIndustries.Tests/LifecycleStatusMapperTests.cs index 0a06141..5e0149f 100644 --- a/SeaHavenIndustries.Tests/LifecycleStatusMapperTests.cs +++ b/SeaHavenIndustries.Tests/LifecycleStatusMapperTests.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Helpers; using SeaHaven.Services.Constants; using SeaHaven.Services.Helpers; @@ -30,6 +31,48 @@ public class LifecycleStatusMapperTests Assert.Null(result); } + // Every value FromLegacyStatus recognizes: the board's status filter reads + // rows with no LifecycleStatus through its own copy of this table. + [Theory] + [InlineData("open")] + [InlineData("incomplete")] + [InlineData("pending")] + [InlineData("onhold")] + [InlineData("on hold")] + [InlineData("scheduled")] + [InlineData("en route")] + [InlineData("enroute")] + [InlineData("on site")] + [InlineData("onsite")] + [InlineData("inprogress")] + [InlineData("in progress")] + [InlineData("completed")] + [InlineData("complete")] + [InlineData("done")] + [InlineData("closed")] + [InlineData("rescheduled")] + [InlineData("cancelled")] + [InlineData("canceled")] + [InlineData("pending quote")] + [InlineData("pendingquote")] + [InlineData("pendingapproval")] + [InlineData("pending approval")] + public void BoardStatusFilterLegacyTable_AgreesWithFromLegacyStatus(string legacy) + { + Assert.Equal( + LifecycleStatusMapper.FromLegacyStatus(legacy), + WorkOrderBoardQueryFilters.LegacyStatusValues[legacy]); + } + + [Fact] + public void BoardStatusFilterLegacyTable_HasNoValueTheMapperRejects() + { + Assert.Equal(23, WorkOrderBoardQueryFilters.LegacyStatusValues.Count); + Assert.All( + WorkOrderBoardQueryFilters.LegacyStatusValues, + entry => Assert.Equal(entry.Value, LifecycleStatusMapper.FromLegacyStatus(entry.Key))); + } + [Theory] [InlineData(LifecycleStatus.EnRoute, "En Route")] [InlineData(LifecycleStatus.PendingQuote, "Pending Quote")] From 702069f09c470623742d6e8c639f0b724aff853c Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 03:38:31 -0300 Subject: [PATCH 4/4] test(dashboard): pin drill-down rows for legacy open work orders The parity test now also asserts which rows the drill-down lists: legacy open rows (status in Status or in LegacyStatus, or none) are listed and legacy closed, cancelled or assigned rows are not. Drops ticket keys from comments. --- .../DashboardUnassignedTests.cs | 14 +++++++++----- .../Implementation/DashboardDataService.cs | 2 +- .../Interfaces/IDashboardDataService.cs | 2 +- SeaHaven.Services/DTOs/DashboardDTOs.cs | 2 +- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs b/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs index 81649e6..996b53c 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs @@ -12,7 +12,7 @@ using Xunit; namespace Api.SeaHavenIndustries.Tests; /// -/// SH-392: the Dashboard "Unassigned" count is open work orders with no +/// 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). /// @@ -84,7 +84,8 @@ public class DashboardUnassignedTests Wo(15, inRange, status: null), // counted: no status at all Wo(16, inRange, status: null, legacyStatus: "Done", statusText: "Open"), // closed: LegacyStatus wins Wo(17, inRange, status: null, statusText: " Cancelled "), // canceled - Wo(18, inRange, assignTo: "disp-1", status: null, statusText: "Open")); // assigned + Wo(18, inRange, assignTo: "disp-1", status: null, statusText: "Open"), // assigned + Wo(19, inRange, status: null, legacyStatus: "Open", statusText: "Done")); // counted: LegacyStatus wins ctx.SaveChanges(); } @@ -141,9 +142,9 @@ public class DashboardUnassignedTests var allTime = await NewDashboard(ctx).GetStatsAsync( AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None); - // 1, 2, 3 plus legacy 13, 14 and 15. - inRange.Unassigned.Should().Be(6); - allTime.Unassigned.Should().Be(8); + // 1, 2, 3 plus legacy 13, 14, 15 and 19. + inRange.Unassigned.Should().Be(7); + allTime.Unassigned.Should().Be(9); } [Fact] @@ -241,5 +242,8 @@ public class DashboardUnassignedTests }, user, "admin-1"); stats.Unassigned.Should().Be(list.TotalCount); + var listed = list.Items.Select(row => row.Id).ToList(); + listed.Should().Contain(new[] { 13, 14, 15, 19 }); + listed.Should().NotContain(new[] { 16, 17, 18 }); } } diff --git a/SeaHaven.DataServices/Implementation/DashboardDataService.cs b/SeaHaven.DataServices/Implementation/DashboardDataService.cs index 02362a2..33957fc 100644 --- a/SeaHaven.DataServices/Implementation/DashboardDataService.cs +++ b/SeaHaven.DataServices/Implementation/DashboardDataService.cs @@ -62,7 +62,7 @@ namespace SeaHaven.DataServices.Implementation 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). + // undated work, so the tile and its drill-down agree. query = dateFrom is DateOnly from && dateTo is DateOnly to ? WorkOrderBoardQueryFilters.ApplyDateRangeFilter(query, from, to) : WorkOrderBoardQueryFilters.ApplyDateRangeFilter( diff --git a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs index d55bdea..ee1932d 100644 --- a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs @@ -16,7 +16,7 @@ namespace SeaHaven.DataServices.Interfaces /// /// Open work orders with no dispatcher in the period, counted with the - /// same filters as GET /board/search for Dispatcher=Unassigned (SH-392), + /// same filters as GET /board/search for Dispatcher=Unassigned, /// so the Dashboard number matches the list it drills into. /// Task CountOpenUnassignedAsync( diff --git a/SeaHaven.Services/DTOs/DashboardDTOs.cs b/SeaHaven.Services/DTOs/DashboardDTOs.cs index 146c491..7e4438f 100644 --- a/SeaHaven.Services/DTOs/DashboardDTOs.cs +++ b/SeaHaven.Services/DTOs/DashboardDTOs.cs @@ -106,7 +106,7 @@ namespace SeaHaven.Services.DTOs public int ScheduledTomorrow { get; set; } public int PendingUplifts { get; set; } public int AvetaPending { get; set; } - /// Open work orders with no dispatcher in the selected period (SH-392). + /// Open work orders with no dispatcher in the selected period. public int Unassigned { get; set; } public DashboardBreakdownDTO Breakdown { get; set; } = new(); public int DueCount { get; set; }