diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index f12d774..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); @@ -36,6 +38,17 @@ namespace SeaHaven.DataServices.Helpers w.WorkOrderType, w.ServiceId, w.ServiceNameSnapshot, + ServiceDisplayName = w.ServiceId.HasValue + ? (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, SiteNotes = w.Locations != null ? w.Locations.Notes : null, @@ -180,7 +193,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..045e311 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. @@ -263,9 +265,20 @@ namespace SeaHaven.DataServices.Helpers return query; 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.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/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/WorkOrderServiceRegistryFilterRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderServiceRegistryFilterRelationalTests.cs new file mode 100644 index 0000000..c6fb0b6 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderServiceRegistryFilterRelationalTests.cs @@ -0,0 +1,271 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +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_RespectCompletedSnapshotsAndKeepCurrentAliases() + { + 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 = "Original Inactive Name", + NormalizedName = "ORIGINAL INACTIVE NAME", + 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 + { + 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" + }, + 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" + }, + 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(); + + 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), + 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 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) + .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 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 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) + .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}", + $"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)}", + $"exact-registry-filter={string.Join(',', exactRegistryNameMatches)}", + $"multiple-registry-filter={string.Join(',', multipleRegistryNameMatches)}", + $"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", + "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", + "exact-registry-filter=6", + "multiple-registry-filter=6,7", + "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; + } + } + } +} 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; + } + } + } +}