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.
This commit is contained in:
Alexandre Brandizzi 2026-09-25 03:34:24 -03:00
parent c49a98db18
commit d6c5357fab
3 changed files with 165 additions and 3 deletions

View file

@ -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<IEnumerable<int>> Ids(LifecycleStatus status)
=> (await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = From,
DateTo = To,
Statuses = new List<LifecycleStatus> { 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();

View file

@ -163,6 +163,44 @@ namespace SeaHaven.DataServices.Helpers
|| !WorkOrderBoardRegions.KnownStorageValues.Contains(w.Locations.State))));
}
/// <summary>
/// Lower-cased legacy status text and the lifecycle status it stands for:
/// the Phase0 backfill rules, kept equal to LifecycleStatusMapper.FromLegacyStatus.
/// </summary>
public static readonly IReadOnlyDictionary<string, LifecycleStatus> LegacyStatusValues =
new Dictionary<string, LifecycleStatus>(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,
};
/// <summary>
/// Keeps rows in <paramref name="statuses"/>. 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
/// <see cref="LegacyStatusValues"/>, anything else, blank included, is Incomplete.
/// </summary>
public static IQueryable<WorkOrder> ApplyStatusFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<LifecycleStatus>? 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<WorkOrder> ApplyDocStatusFilter(

View file

@ -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")]