From c2e29abb5d70ac11a6877a86f021ba834dfaf722 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 30 Sep 2026 16:45:05 -0300 Subject: [PATCH 1/5] fix(workorders): use live registry names in service filters --- .../Helpers/WorkOrderBoardProjection.cs | 9 +- .../Helpers/WorkOrderBoardQueryFilters.cs | 10 +- ...derServiceRegistryFilterRelationalTests.cs | 159 ++++++++++++++++++ 3 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 SeaHavenIndustries.Tests/WorkOrderServiceRegistryFilterRelationalTests.cs diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index f12d774..cd4122f 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -36,6 +36,13 @@ namespace SeaHaven.DataServices.Helpers w.WorkOrderType, w.ServiceId, w.ServiceNameSnapshot, + ServiceDisplayName = w.ServiceId.HasValue + ? (w.ServiceDefinition != null + && w.ServiceDefinition.IsActive + && w.ServiceDefinition.IsDeleted != true + ? w.ServiceDefinition.Name + : w.ServiceNameSnapshot ?? w.Trade ?? w.Problem) + : w.Trade ?? w.Problem, w.SiteCode, LocationName = w.Locations != null ? w.Locations.Name : null, SiteNotes = w.Locations != null ? w.Locations.Notes : null, @@ -180,7 +187,7 @@ namespace SeaHaven.DataServices.Helpers technicianAssigned == false ? null : frozenCompany?.ContactName ?? w.TechName, technicianAssigned == false ? null : frozenCompany?.Phone ?? techPhone, w.DispatchApptDate, - w.Trade ?? w.Problem, + w.ServiceDisplayName, w.Problem, w.ServiceNotes, w.ExtraServices, diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs index 6557db2..e7b3cc1 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -264,8 +264,14 @@ namespace SeaHaven.DataServices.Helpers return query.Where(w => normalized.Any(pm => - (w.Trade ?? "").ToLower().Contains(pm) - || (w.Problem ?? "").ToLower().Contains(pm))); + w.ServiceId.HasValue + ? (w.ServiceDefinition != null + && w.ServiceDefinition.IsActive + && w.ServiceDefinition.IsDeleted != true + ? (w.ServiceDefinition.Name ?? "").ToLower().Contains(pm) + : (w.ServiceNameSnapshot ?? w.Trade ?? w.Problem ?? "").ToLower().Contains(pm)) + : (w.Trade ?? "").ToLower().Contains(pm) + || (w.Problem ?? "").ToLower().Contains(pm))); } /// diff --git a/SeaHavenIndustries.Tests/WorkOrderServiceRegistryFilterRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderServiceRegistryFilterRelationalTests.cs new file mode 100644 index 0000000..b9a6e0a --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderServiceRegistryFilterRelationalTests.cs @@ -0,0 +1,159 @@ +using Data.SeaHavenIndustries; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Helpers; +using SeaHaven.Services.Implementation; + +namespace SeaHavenIndustries.Tests; + +public sealed class WorkOrderServiceRegistryFilterRelationalTests +{ + [Fact] + public async Task ServiceDisplayAndFilter_UseCurrentActiveNameAndKeepHistoricalAndLegacyLabels() + { + await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new SqliteServiceRegistryTestDbContext(options); + await context.Database.EnsureCreatedAsync(); + + context.Services.AddRange( + new Service + { + Id = 10, + Name = "Current Active Name", + NormalizedName = "CURRENT ACTIVE NAME", + Trade = "Electrical", + IconKey = "electrical", + IsActive = true + }, + new Service + { + Id = 20, + Name = "Current Inactive Name", + NormalizedName = "CURRENT INACTIVE NAME", + Trade = "Electrical", + IconKey = "electrical", + IsActive = false + }); + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + ServiceId = 10, + ServiceNameSnapshot = "Original Active Name", + Trade = "Original Active Name" + }, + new WorkOrder + { + Id = 2, + ServiceId = 20, + ServiceNameSnapshot = "Historical Inactive Name", + Trade = "Historical Inactive Name" + }, + new WorkOrder + { + Id = 3, + Trade = "Legacy Cooling", + Problem = "Legacy Leak" + }); + await context.SaveChangesAsync(); + + var activeRows = await WorkOrderBoardProjection.ProjectRowsAsync( + context, + context.workOrders.AsNoTracking().Where(w => w.Id == 1), + isUnscheduled: true); + var activeDisplay = WorkOrderBoardService.MapRawRow(Assert.Single(activeRows), DateTime.UtcNow); + + var inactiveRows = await WorkOrderBoardProjection.ProjectRowsAsync( + context, + context.workOrders.AsNoTracking().Where(w => w.Id == 2), + isUnscheduled: true); + var inactiveDisplay = WorkOrderBoardService.MapRawRow(Assert.Single(inactiveRows), DateTime.UtcNow); + + var legacyRows = await WorkOrderBoardProjection.ProjectRowsAsync( + context, + context.workOrders.AsNoTracking().Where(w => w.Id == 3), + isUnscheduled: true); + var legacyDisplay = WorkOrderBoardService.MapRawRow(Assert.Single(legacyRows), DateTime.UtcNow); + + var currentActiveNameMatches = await WorkOrderBoardQueryFilters + .ApplyPmTypeFilter(context.workOrders.AsNoTracking(), new[] { "Current Active Name" }) + .Select(w => w.Id) + .ToListAsync(); + + var historicalInactiveNameMatches = await WorkOrderBoardQueryFilters + .ApplyPmTypeFilter(context.workOrders.AsNoTracking(), new[] { "Historical Inactive Name" }) + .Select(w => w.Id) + .ToListAsync(); + + var inactiveRegistryNameMatches = await WorkOrderBoardQueryFilters + .ApplyPmTypeFilter(context.workOrders.AsNoTracking(), new[] { "Current Inactive Name" }) + .Select(w => w.Id) + .ToListAsync(); + + var legacyTradeMatches = await WorkOrderBoardQueryFilters + .ApplyPmTypeFilter(context.workOrders.AsNoTracking(), new[] { "Legacy Cooling" }) + .Select(w => w.Id) + .ToListAsync(); + + var legacyProblemMatches = await WorkOrderBoardQueryFilters + .ApplyPmTypeFilter(context.workOrders.AsNoTracking(), new[] { "Legacy Leak" }) + .Select(w => w.Id) + .ToListAsync(); + + var actual = string.Join("\n", new[] + { + $"active-display={activeDisplay.Pm}", + $"inactive-display={inactiveDisplay.Pm}", + $"legacy-display={legacyDisplay.Pm}", + $"active-filter={string.Join(',', currentActiveNameMatches)}", + $"inactive-snapshot-filter={string.Join(',', historicalInactiveNameMatches)}", + $"inactive-current-name-filter={string.Join(',', inactiveRegistryNameMatches)}", + $"legacy-trade-filter={string.Join(',', legacyTradeMatches)}", + $"legacy-problem-filter={string.Join(',', legacyProblemMatches)}" + }); + var expected = string.Join("\n", new[] + { + "active-display=Current Active Name", + "inactive-display=Historical Inactive Name", + "legacy-display=Legacy Cooling", + "active-filter=1", + "inactive-snapshot-filter=2", + "inactive-current-name-filter=", + "legacy-trade-filter=3", + "legacy-problem-filter=3" + }); + Assert.True(expected == actual, $"Expected:\n{expected}\nActual:\n{actual}"); + } + + private sealed class SqliteServiceRegistryTestDbContext : ApplicationDbContext + { + public SqliteServiceRegistryTestDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + // SQL Server filtered indexes and rowversion semantics are not supported by SQLite. + foreach (var index in builder.Model.GetEntityTypes().SelectMany(entity => entity.GetIndexes())) + { + if (index.GetFilter() != null) + index.SetFilter(null); + } + + foreach (var entityType in new[] { typeof(WorkOrder), typeof(Dispatch) }) + { + var property = builder.Entity(entityType).Property("RowVersion").Metadata; + property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never; + property.IsConcurrencyToken = false; + } + } + } +} From aede3148d11c77856504de85619ac1c1b24ec727 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 30 Sep 2026 16:47:12 -0300 Subject: [PATCH 2/5] fix(workorders): filter inactive services by current name --- .../Helpers/WorkOrderBoardQueryFilters.cs | 4 +++- ...rkOrderServiceRegistryFilterRelationalTests.cs | 15 +++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs index e7b3cc1..0b63ac5 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -269,7 +269,9 @@ namespace SeaHaven.DataServices.Helpers && w.ServiceDefinition.IsActive && w.ServiceDefinition.IsDeleted != true ? (w.ServiceDefinition.Name ?? "").ToLower().Contains(pm) - : (w.ServiceNameSnapshot ?? w.Trade ?? w.Problem ?? "").ToLower().Contains(pm)) + : ((w.ServiceDefinition != null + && (w.ServiceDefinition.Name ?? "").ToLower().Contains(pm)) + || (w.ServiceNameSnapshot ?? w.Trade ?? w.Problem ?? "").ToLower().Contains(pm))) : (w.Trade ?? "").ToLower().Contains(pm) || (w.Problem ?? "").ToLower().Contains(pm))); } diff --git a/SeaHavenIndustries.Tests/WorkOrderServiceRegistryFilterRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderServiceRegistryFilterRelationalTests.cs index b9a6e0a..60a1016 100644 --- a/SeaHavenIndustries.Tests/WorkOrderServiceRegistryFilterRelationalTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderServiceRegistryFilterRelationalTests.cs @@ -33,11 +33,11 @@ public sealed class WorkOrderServiceRegistryFilterRelationalTests new Service { Id = 20, - Name = "Current Inactive Name", - NormalizedName = "CURRENT INACTIVE NAME", + Name = "Original Inactive Name", + NormalizedName = "ORIGINAL INACTIVE NAME", Trade = "Electrical", IconKey = "electrical", - IsActive = false + IsActive = true }); context.workOrders.AddRange( new WorkOrder @@ -62,6 +62,13 @@ public sealed class WorkOrderServiceRegistryFilterRelationalTests }); await context.SaveChangesAsync(); + var renamedService = await context.Services.SingleAsync(service => service.Id == 20); + renamedService.Name = "Current Inactive Name"; + renamedService.NormalizedName = "CURRENT INACTIVE NAME"; + await context.SaveChangesAsync(); + renamedService.IsActive = false; + await context.SaveChangesAsync(); + var activeRows = await WorkOrderBoardProjection.ProjectRowsAsync( context, context.workOrders.AsNoTracking().Where(w => w.Id == 1), @@ -123,7 +130,7 @@ public sealed class WorkOrderServiceRegistryFilterRelationalTests "legacy-display=Legacy Cooling", "active-filter=1", "inactive-snapshot-filter=2", - "inactive-current-name-filter=", + "inactive-current-name-filter=2", "legacy-trade-filter=3", "legacy-problem-filter=3" }); From 0894b20e5942b74a8275bd018e4bf11932f855ba Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 30 Sep 2026 17:04:46 -0300 Subject: [PATCH 3/5] fix(workorders): preserve completed service snapshots --- .../Helpers/WorkOrderBoardProjection.cs | 16 +++-- .../Helpers/WorkOrderBoardQueryFilters.cs | 5 ++ ...derServiceRegistryFilterRelationalTests.cs | 59 ++++++++++++++++++- 3 files changed, 73 insertions(+), 7 deletions(-) diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index cd4122f..9446621 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -8,6 +8,8 @@ namespace SeaHaven.DataServices.Helpers { public static class WorkOrderBoardProjection { + private static readonly string[] CompletedLegacyStatuses = { "completed", "complete", "done", "closed" }; + private static readonly JsonSerializerOptions SnapshotJsonOptions = new(JsonSerializerDefaults.Web); @@ -37,11 +39,15 @@ namespace SeaHaven.DataServices.Helpers w.ServiceId, w.ServiceNameSnapshot, ServiceDisplayName = w.ServiceId.HasValue - ? (w.ServiceDefinition != null - && w.ServiceDefinition.IsActive - && w.ServiceDefinition.IsDeleted != true - ? w.ServiceDefinition.Name - : w.ServiceNameSnapshot ?? w.Trade ?? w.Problem) + ? (w.LifecycleStatus == LifecycleStatus.Completed + || (w.LifecycleStatus == null + && CompletedLegacyStatuses.Contains(((w.LegacyStatus ?? w.Status) ?? "").Trim().ToLower())) + ? w.ServiceNameSnapshot ?? w.Trade ?? w.Problem + : (w.ServiceDefinition != null + && w.ServiceDefinition.IsActive + && w.ServiceDefinition.IsDeleted != true + ? w.ServiceDefinition.Name + : w.ServiceNameSnapshot ?? w.Trade ?? w.Problem)) : w.Trade ?? w.Problem, w.SiteCode, LocationName = w.Locations != null ? w.Locations.Name : null, diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs index 0b63ac5..27f88f1 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -5,6 +5,8 @@ namespace SeaHaven.DataServices.Helpers { public static class WorkOrderBoardQueryFilters { + private static readonly string[] CompletedLegacyStatuses = { "completed", "complete", "done", "closed" }; + /// /// The window the Work Orders board searches when no date range is selected /// (client ADVANCED_SEARCH_ALL_WEEKS_FROM/TO), used with includeDateless. @@ -268,6 +270,9 @@ namespace SeaHaven.DataServices.Helpers ? (w.ServiceDefinition != null && w.ServiceDefinition.IsActive && w.ServiceDefinition.IsDeleted != true + && !(w.LifecycleStatus == LifecycleStatus.Completed + || (w.LifecycleStatus == null + && CompletedLegacyStatuses.Contains(((w.LegacyStatus ?? w.Status) ?? "").Trim().ToLower()))) ? (w.ServiceDefinition.Name ?? "").ToLower().Contains(pm) : ((w.ServiceDefinition != null && (w.ServiceDefinition.Name ?? "").ToLower().Contains(pm)) diff --git a/SeaHavenIndustries.Tests/WorkOrderServiceRegistryFilterRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderServiceRegistryFilterRelationalTests.cs index 60a1016..1f81bcc 100644 --- a/SeaHavenIndustries.Tests/WorkOrderServiceRegistryFilterRelationalTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderServiceRegistryFilterRelationalTests.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Helpers; @@ -9,7 +10,7 @@ namespace SeaHavenIndustries.Tests; public sealed class WorkOrderServiceRegistryFilterRelationalTests { [Fact] - public async Task ServiceDisplayAndFilter_UseCurrentActiveNameAndKeepHistoricalAndLegacyLabels() + public async Task ServiceDisplayAndFilter_RespectCompletedSnapshotsAndKeepCurrentAliases() { await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); await connection.OpenAsync(); @@ -59,6 +60,22 @@ public sealed class WorkOrderServiceRegistryFilterRelationalTests Id = 3, Trade = "Legacy Cooling", Problem = "Legacy Leak" + }, + new WorkOrder + { + Id = 4, + ServiceId = 10, + ServiceNameSnapshot = "Completed Archive Alpha", + Trade = "Completed Archive Alpha", + LifecycleStatus = LifecycleStatus.Completed + }, + new WorkOrder + { + Id = 5, + ServiceId = 10, + ServiceNameSnapshot = "Completed Archive Beta", + Trade = "Completed Archive Beta", + Status = "Done" }); await context.SaveChangesAsync(); @@ -87,6 +104,18 @@ public sealed class WorkOrderServiceRegistryFilterRelationalTests isUnscheduled: true); var legacyDisplay = WorkOrderBoardService.MapRawRow(Assert.Single(legacyRows), DateTime.UtcNow); + var completedRows = await WorkOrderBoardProjection.ProjectRowsAsync( + context, + context.workOrders.AsNoTracking().Where(w => w.Id == 4), + isUnscheduled: true); + var completedDisplay = WorkOrderBoardService.MapRawRow(Assert.Single(completedRows), DateTime.UtcNow); + + var legacyCompletedRows = await WorkOrderBoardProjection.ProjectRowsAsync( + context, + context.workOrders.AsNoTracking().Where(w => w.Id == 5), + isUnscheduled: true); + var legacyCompletedDisplay = WorkOrderBoardService.MapRawRow(Assert.Single(legacyCompletedRows), DateTime.UtcNow); + var currentActiveNameMatches = await WorkOrderBoardQueryFilters .ApplyPmTypeFilter(context.workOrders.AsNoTracking(), new[] { "Current Active Name" }) .Select(w => w.Id) @@ -102,6 +131,22 @@ public sealed class WorkOrderServiceRegistryFilterRelationalTests .Select(w => w.Id) .ToListAsync(); + var completedSnapshotMatches = await WorkOrderBoardQueryFilters + .ApplyPmTypeFilter(context.workOrders.AsNoTracking(), new[] { "Completed Archive Alpha" }) + .Select(w => w.Id) + .ToListAsync(); + + var legacyCompletedSnapshotMatches = await WorkOrderBoardQueryFilters + .ApplyPmTypeFilter(context.workOrders.AsNoTracking(), new[] { "Completed Archive Beta" }) + .Select(w => w.Id) + .ToListAsync(); + + var currentNameAliasMatches = await WorkOrderBoardQueryFilters + .ApplyPmTypeFilter(context.workOrders.AsNoTracking(), new[] { "Current Active Name" }) + .Select(w => w.Id) + .OrderBy(id => id) + .ToListAsync(); + var legacyTradeMatches = await WorkOrderBoardQueryFilters .ApplyPmTypeFilter(context.workOrders.AsNoTracking(), new[] { "Legacy Cooling" }) .Select(w => w.Id) @@ -117,9 +162,14 @@ public sealed class WorkOrderServiceRegistryFilterRelationalTests $"active-display={activeDisplay.Pm}", $"inactive-display={inactiveDisplay.Pm}", $"legacy-display={legacyDisplay.Pm}", + $"completed-display={completedDisplay.Pm}", + $"legacy-completed-display={legacyCompletedDisplay.Pm}", $"active-filter={string.Join(',', currentActiveNameMatches)}", $"inactive-snapshot-filter={string.Join(',', historicalInactiveNameMatches)}", $"inactive-current-name-filter={string.Join(',', inactiveRegistryNameMatches)}", + $"completed-snapshot-filter={string.Join(',', completedSnapshotMatches)}", + $"legacy-completed-snapshot-filter={string.Join(',', legacyCompletedSnapshotMatches)}", + $"current-name-alias-filter={string.Join(',', currentNameAliasMatches)}", $"legacy-trade-filter={string.Join(',', legacyTradeMatches)}", $"legacy-problem-filter={string.Join(',', legacyProblemMatches)}" }); @@ -128,9 +178,14 @@ public sealed class WorkOrderServiceRegistryFilterRelationalTests "active-display=Current Active Name", "inactive-display=Historical Inactive Name", "legacy-display=Legacy Cooling", - "active-filter=1", + "completed-display=Completed Archive Alpha", + "legacy-completed-display=Completed Archive Beta", + "active-filter=1,4,5", "inactive-snapshot-filter=2", "inactive-current-name-filter=2", + "completed-snapshot-filter=4", + "legacy-completed-snapshot-filter=5", + "current-name-alias-filter=1,4,5", "legacy-trade-filter=3", "legacy-problem-filter=3" }); From 0cd0d3b0667ac6398d9bb49b54fcfee069cda239 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 30 Sep 2026 17:08:17 -0300 Subject: [PATCH 4/5] fix(workorders): match registry service filters exactly --- .../Helpers/WorkOrderBoardQueryFilters.cs | 28 +++++------ ...derServiceRegistryFilterRelationalTests.cs | 50 +++++++++++++++++++ 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs index 27f88f1..045e311 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -265,20 +265,20 @@ namespace SeaHaven.DataServices.Helpers return query; return query.Where(w => - normalized.Any(pm => - w.ServiceId.HasValue - ? (w.ServiceDefinition != null - && w.ServiceDefinition.IsActive - && w.ServiceDefinition.IsDeleted != true - && !(w.LifecycleStatus == LifecycleStatus.Completed - || (w.LifecycleStatus == null - && CompletedLegacyStatuses.Contains(((w.LegacyStatus ?? w.Status) ?? "").Trim().ToLower()))) - ? (w.ServiceDefinition.Name ?? "").ToLower().Contains(pm) - : ((w.ServiceDefinition != null - && (w.ServiceDefinition.Name ?? "").ToLower().Contains(pm)) - || (w.ServiceNameSnapshot ?? w.Trade ?? w.Problem ?? "").ToLower().Contains(pm))) - : (w.Trade ?? "").ToLower().Contains(pm) - || (w.Problem ?? "").ToLower().Contains(pm))); + w.ServiceId.HasValue + ? (w.ServiceDefinition != null + && w.ServiceDefinition.IsActive + && w.ServiceDefinition.IsDeleted != true + && !(w.LifecycleStatus == LifecycleStatus.Completed + || (w.LifecycleStatus == null + && CompletedLegacyStatuses.Contains(((w.LegacyStatus ?? w.Status) ?? "").Trim().ToLower()))) + ? normalized.Contains(((w.ServiceDefinition.Name ?? "").Trim()).ToLower()) + : ((w.ServiceDefinition != null + && normalized.Contains(((w.ServiceDefinition.Name ?? "").Trim()).ToLower())) + || normalized.Contains(((w.ServiceNameSnapshot ?? w.Trade ?? w.Problem ?? "").Trim()).ToLower()))) + : normalized.Any(pm => + (w.Trade ?? "").ToLower().Contains(pm) + || (w.Problem ?? "").ToLower().Contains(pm))); } /// diff --git a/SeaHavenIndustries.Tests/WorkOrderServiceRegistryFilterRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderServiceRegistryFilterRelationalTests.cs index 1f81bcc..c6fb0b6 100644 --- a/SeaHavenIndustries.Tests/WorkOrderServiceRegistryFilterRelationalTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderServiceRegistryFilterRelationalTests.cs @@ -39,6 +39,24 @@ public sealed class WorkOrderServiceRegistryFilterRelationalTests Trade = "Electrical", IconKey = "electrical", IsActive = true + }, + new Service + { + Id = 30, + Name = "QA Service", + NormalizedName = "QA SERVICE", + Trade = "Electrical", + IconKey = "electrical", + IsActive = true + }, + new Service + { + Id = 31, + Name = "QA Service Extended", + NormalizedName = "QA SERVICE EXTENDED", + Trade = "Electrical", + IconKey = "electrical", + IsActive = true }); context.workOrders.AddRange( new WorkOrder @@ -76,6 +94,22 @@ public sealed class WorkOrderServiceRegistryFilterRelationalTests ServiceNameSnapshot = "Completed Archive Beta", Trade = "Completed Archive Beta", Status = "Done" + }, + new WorkOrder + { + Id = 6, + ServiceId = 30, + ServiceNameSnapshot = "QA Service", + Trade = "QA Service", + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 7, + ServiceId = 31, + ServiceNameSnapshot = "QA Service Extended", + Trade = "QA Service Extended", + LifecycleStatus = LifecycleStatus.Scheduled }); await context.SaveChangesAsync(); @@ -147,6 +181,18 @@ public sealed class WorkOrderServiceRegistryFilterRelationalTests .OrderBy(id => id) .ToListAsync(); + var exactRegistryNameMatches = await WorkOrderBoardQueryFilters + .ApplyPmTypeFilter(context.workOrders.AsNoTracking(), new[] { "QA Service" }) + .Select(w => w.Id) + .OrderBy(id => id) + .ToListAsync(); + + var multipleRegistryNameMatches = await WorkOrderBoardQueryFilters + .ApplyPmTypeFilter(context.workOrders.AsNoTracking(), new[] { "QA Service", "QA Service Extended" }) + .Select(w => w.Id) + .OrderBy(id => id) + .ToListAsync(); + var legacyTradeMatches = await WorkOrderBoardQueryFilters .ApplyPmTypeFilter(context.workOrders.AsNoTracking(), new[] { "Legacy Cooling" }) .Select(w => w.Id) @@ -170,6 +216,8 @@ public sealed class WorkOrderServiceRegistryFilterRelationalTests $"completed-snapshot-filter={string.Join(',', completedSnapshotMatches)}", $"legacy-completed-snapshot-filter={string.Join(',', legacyCompletedSnapshotMatches)}", $"current-name-alias-filter={string.Join(',', currentNameAliasMatches)}", + $"exact-registry-filter={string.Join(',', exactRegistryNameMatches)}", + $"multiple-registry-filter={string.Join(',', multipleRegistryNameMatches)}", $"legacy-trade-filter={string.Join(',', legacyTradeMatches)}", $"legacy-problem-filter={string.Join(',', legacyProblemMatches)}" }); @@ -186,6 +234,8 @@ public sealed class WorkOrderServiceRegistryFilterRelationalTests "completed-snapshot-filter=4", "legacy-completed-snapshot-filter=5", "current-name-alias-filter=1,4,5", + "exact-registry-filter=6", + "multiple-registry-filter=6,7", "legacy-trade-filter=3", "legacy-problem-filter=3" }); From 969b972581563f1992442daaffe1d17eba901218 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 30 Sep 2026 20:19:41 -0300 Subject: [PATCH 5/5] fix(workorders): search registry service labels --- .../Helpers/WorkOrderBoardSearchFilter.cs | 2 + ...iceRegistryKeywordSearchRelationalTests.cs | 210 ++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 SeaHavenIndustries.Tests/WorkOrderServiceRegistryKeywordSearchRelationalTests.cs diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs index 909540c..0886c11 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs @@ -48,6 +48,8 @@ namespace SeaHaven.DataServices.Helpers || (w.AssignToUser != null && ((w.AssignToUser.FirstName ?? "") + " " + (w.AssignToUser.LastName ?? "")).ToLower().Contains(s)) || (w.Trade ?? "").ToLower().Contains(s) || (w.Problem ?? "").ToLower().Contains(s) + || (w.ServiceDefinition != null && (w.ServiceDefinition.Name ?? "").ToLower().Contains(s)) + || (w.ServiceNameSnapshot ?? "").ToLower().Contains(s) || (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.CompanyName ?? "").ToLower().Contains(s)) || (w.TechnicianAssigned != false && w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.ContactName ?? "").ToLower().Contains(s)) || (w.Status ?? "").ToLower().Contains(s) diff --git a/SeaHavenIndustries.Tests/WorkOrderServiceRegistryKeywordSearchRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderServiceRegistryKeywordSearchRelationalTests.cs new file mode 100644 index 0000000..6af82c5 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderServiceRegistryKeywordSearchRelationalTests.cs @@ -0,0 +1,210 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Helpers; +using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHavenIndustries.Tests; + +public sealed class WorkOrderServiceRegistryKeywordSearchRelationalTests +{ + [Fact] + public async Task Search_MatchesCurrentAndHistoricalServiceLabelsAndPreservesExistingAliases() + { + await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new SqliteSearchTestDbContext(options); + await context.Database.EnsureCreatedAsync(); + + context.Services.AddRange( + new Service + { + Id = 10, + Name = "Chiller Maintenance Original", + NormalizedName = "CHILLER MAINTENANCE ORIGINAL", + Trade = "HVAC", + IconKey = "hvac", + IsActive = true + }, + new Service + { + Id = 20, + Name = "Roof Maintenance Current", + NormalizedName = "ROOF MAINTENANCE CURRENT", + Trade = "Roofing", + IconKey = "roofing", + IsActive = true + }, + new Service + { + Id = 30, + Name = "Pump Service Current", + NormalizedName = "PUMP SERVICE CURRENT", + Trade = "Plumbing", + IconKey = "plumbing", + IsActive = false + }); + context.Vendors.Add(new Vendor + { + Id = 40, + CompanyName = "Acme Vendor Group", + ContactName = "Morgan Technician" + }); + context.Dispatches.Add(new Dispatch { Id = 50, VendorId = 40, Status = "Sent" }); + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + ServiceId = 10, + ServiceNameSnapshot = "Legacy Chiller Label", + Trade = "HVAC", + Problem = "Cooling noise", + LifecycleStatus = LifecycleStatus.Scheduled, + WorkOrderType = WorkOrderType.PM, + ScheduledDate = new DateTime(2026, 9, 15), + PrimaryDispatchId = 50, + TechnicianAssigned = true + }, + new WorkOrder + { + Id = 2, + ServiceId = 20, + ServiceNameSnapshot = "Frozen Roof Label", + Trade = "Roofing", + Problem = "Roof leak", + LifecycleStatus = LifecycleStatus.Completed, + WorkOrderType = WorkOrderType.PM, + ScheduledDate = new DateTime(2026, 9, 15) + }, + new WorkOrder + { + Id = 3, + ServiceId = 30, + ServiceNameSnapshot = "Historical Pump Label", + Trade = "Plumbing", + Problem = "Pump noise", + LifecycleStatus = LifecycleStatus.Scheduled, + WorkOrderType = WorkOrderType.PM, + ScheduledDate = new DateTime(2026, 9, 15) + }, + new WorkOrder + { + Id = 4, + Trade = "Legacy Boiler Service", + Problem = "Oil leak", + LifecycleStatus = LifecycleStatus.Scheduled, + WorkOrderType = WorkOrderType.Reactive, + ScheduledDate = new DateTime(2026, 9, 15) + }, + new WorkOrder + { + Id = 5, + Trade = "Unlinked vendor WO", + LifecycleStatus = LifecycleStatus.Scheduled, + WorkOrderType = WorkOrderType.Reactive, + ScheduledDate = new DateTime(2026, 9, 15), + PrimaryDispatchId = 50, + TechnicianAssigned = false + }); + await context.SaveChangesAsync(); + + var renamedService = await context.Services.SingleAsync(service => service.Id == 10); + renamedService.Name = "Northstar Chiller Service"; + renamedService.NormalizedName = "NORTHSTAR CHILLER SERVICE"; + await context.SaveChangesAsync(); + + async Task SearchIds(string search) + => await WorkOrderBoardSearchFilter + .Apply(context.workOrders.AsNoTracking(), search) + .Select(workOrder => workOrder.Id) + .OrderBy(id => id) + .ToArrayAsync(); + + Assert.Equal(new[] { 1 }, await SearchIds("Northstar Chiller Service")); + Assert.Equal(new[] { 1 }, await SearchIds("Legacy Chiller Label")); + Assert.Equal(new[] { 2 }, await SearchIds("Frozen Roof Label")); + Assert.Equal(new[] { 2 }, await SearchIds("Roof Maintenance Current")); + Assert.Equal(new[] { 3 }, await SearchIds("Historical Pump Label")); + Assert.Equal(new[] { 3 }, await SearchIds("Pump Service Current")); + Assert.Equal(new[] { 4 }, await SearchIds("Legacy Boiler Service")); + Assert.Equal(new[] { 4 }, await SearchIds("Oil leak")); + Assert.Equal(new[] { 1, 5 }, await SearchIds("Acme Vendor Group")); + Assert.Equal(new[] { 1 }, await SearchIds("Morgan Technician")); + + var advancedSearch = new WorkOrderAdvancedSearchDataService(context); + var matchingFacet = await advancedSearch.SearchAsync(CreateQuery( + search: "Northstar Chiller Service", + pmTypes: new[] { "Northstar Chiller Service" })); + Assert.Equal(new[] { 1 }, matchingFacet.Rows.Select(row => row.Id)); + Assert.Equal("Northstar Chiller Service", Assert.Single(matchingFacet.Rows).Trade); + + var conflictingFacet = await advancedSearch.SearchAsync(CreateQuery( + search: "Northstar Chiller Service", + pmTypes: new[] { "Roof Maintenance Current" })); + Assert.Empty(conflictingFacet.Rows); + Assert.Equal(0, conflictingFacet.TotalCount); + } + + private static WorkOrderAdvancedSearchQuery CreateQuery(string search, IReadOnlyList? pmTypes = null) + => new( + Search: search, + DateFrom: new DateOnly(2026, 9, 1), + DateTo: new DateOnly(2026, 9, 30), + UnscheduledOnly: false, + Sites: null, + Regions: null, + Types: null, + Overdue: false, + Dispatchers: null, + Statuses: null, + PmTypes: pmTypes, + VendorIds: null, + DocStatuses: null, + Severities: null, + Rescheduled: false, + CarriedOver: false, + AddOn: false, + AvetaOnly: false, + FlagColors: null, + InternalOnly: false, + HasUplift: false, + UpliftStatuses: null, + MyWorkOrders: false, + CurrentUserId: null, + Page: 0, + PageSize: 100, + SortBy: "scheduledDate", + SortDir: "asc"); + + private sealed class SqliteSearchTestDbContext : ApplicationDbContext + { + public SqliteSearchTestDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + foreach (var index in builder.Model.GetEntityTypes().SelectMany(entity => entity.GetIndexes())) + { + if (index.GetFilter() != null) + index.SetFilter(null); + } + + foreach (var entityType in new[] { typeof(WorkOrder), typeof(Dispatch) }) + { + var property = builder.Entity(entityType).Property("RowVersion").Metadata; + property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never; + property.IsConcurrencyToken = false; + } + } + } +}