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