From 17c2e968cd14cd341fe20a6cb8e9d72cbfb95d0b Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 13 Aug 2026 16:34:21 -0300 Subject: [PATCH] feat(work-orders): board search facets for SH-121/SH-196 --- .../20260813193000_SH121_BoardSearchFacets.cs | 29 +++ .../ApplicationDbContextModelSnapshot.cs | 3 + Data.SeaHavenIndustries/Models/WorkerOrder.cs | 2 + .../WorkOrderBoardSearchFacetFilters.cs | 166 ++++++++++++++++++ .../WorkOrderAdvancedSearchDataService.cs | 8 + .../WorkOrderAdvancedSearchModels.cs | 8 + .../DTOs/WorkOrderBoardRequestDTOs.cs | 8 + .../WorkOrderAdvancedSearchService.cs | 8 + .../WorkOrderBoardSearchTests.cs | 160 +++++++++++++++++ 9 files changed, 392 insertions(+) create mode 100644 Data.SeaHavenIndustries/Migrations/20260813193000_SH121_BoardSearchFacets.cs create mode 100644 SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs diff --git a/Data.SeaHavenIndustries/Migrations/20260813193000_SH121_BoardSearchFacets.cs b/Data.SeaHavenIndustries/Migrations/20260813193000_SH121_BoardSearchFacets.cs new file mode 100644 index 0000000..db23f42 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260813193000_SH121_BoardSearchFacets.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class SH121_BoardSearchFacets : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsAddOn", + table: "workOrders", + type: "bit", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsAddOn", + table: "workOrders"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 813853c..a126058 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -2393,6 +2393,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("CarriedOver") .HasColumnType("int"); + b.Property("IsAddOn") + .HasColumnType("bit"); + b.Property("CompletedDate") .HasColumnType("datetime2"); diff --git a/Data.SeaHavenIndustries/Models/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs index 59f2390..bf4817a 100644 --- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs +++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs @@ -80,6 +80,8 @@ namespace Data.SeaHavenIndustries // --- Analytics slice --- public int RescheduleCount { get; set; } public int CarriedOver { get; set; } + /// SH-126 add-on indicator (not a WO type); used by board/search facet filter. + public bool IsAddOn { get; set; } public OperationalFlags OperationalFlags { get; set; } // --- Completion slice --- diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs new file mode 100644 index 0000000..08b2c2d --- /dev/null +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs @@ -0,0 +1,166 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; + +namespace SeaHaven.DataServices.Helpers +{ + /// + /// SH-121/SH-196 advanced search facets (severity, indicators, flag, internal WO#, uplift). + /// + public static class WorkOrderBoardSearchFacetFilters + { + private static readonly WorkOrderType[] SeverityEligibleTypes = + { + WorkOrderType.Emergency, + WorkOrderType.Reactive, + }; + + private static readonly string[] RevokedUpliftStatuses = + { + "Withdrawn", + "Cancelled", + }; + + public static IQueryable ApplySeverityFilter( + IQueryable query, + IReadOnlyList? severities) + { + if (severities == null || severities.Count == 0) + return query; + + var allowed = severities + .Where(s => s is >= 1 and <= 5) + .Select(s => s.ToString()) + .Distinct() + .ToList(); + + if (allowed.Count == 0) + return query; + + return query.Where(w => + w.WorkOrderType != null + && SeverityEligibleTypes.Contains(w.WorkOrderType.Value) + && w.Severity != null + && allowed.Contains(w.Severity)); + } + + public static IQueryable ApplyRescheduledFilter( + IQueryable query, + bool rescheduled) + { + if (!rescheduled) + return query; + + return query.Where(w => w.RescheduleCount >= 2); + } + + public static IQueryable ApplyCarriedOverFilter( + IQueryable query, + bool carriedOver) + { + if (!carriedOver) + return query; + + return query.Where(w => w.CarriedOver >= 2); + } + + public static IQueryable ApplyAddOnFilter( + IQueryable query, + bool addOn) + { + if (!addOn) + return query; + + return query.Where(w => w.IsAddOn); + } + + public static IQueryable ApplyFlagColorFilter( + IQueryable query, + IReadOnlyList? flagColors) + { + if (flagColors == null || flagColors.Count == 0) + return query; + + var normalized = flagColors + .Where(c => !string.IsNullOrWhiteSpace(c)) + .Select(c => c.Trim().ToUpperInvariant()) + .Distinct() + .ToList(); + + if (normalized.Count == 0) + return query; + + return query.Where(w => + w.FlagColor != null && normalized.Contains(w.FlagColor.ToUpper())); + } + + public static IQueryable ApplyInternalOnlyFilter( + IQueryable query, + bool internalOnly) + { + if (!internalOnly) + return query; + + return query.Where(w => + w.InternalWONumber != null + && EF.Functions.Like(w.InternalWONumber, "SH%")); + } + + public static IQueryable ApplyUpliftFilter( + IQueryable query, + ApplicationDbContext context, + bool hasUplift, + IReadOnlyList? upliftStatuses) + { + if (!hasUplift) + return query; + + var mappedStatuses = MapUpliftStatuses(upliftStatuses); + + return query.Where(w => context.DispatchUpliftRequests.Any(u => + (u.IsDeleted == null || u.IsDeleted == false) + && u.Status != "Expired" + && !RevokedUpliftStatuses.Contains(u.Status) + && u.Dispatch != null + && (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false) + && ( + u.Dispatch.WorkOrderId == w.Id + || u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id)) + && (mappedStatuses.Count == 0 || mappedStatuses.Contains(u.Status)))); + } + + internal static List MapUpliftStatuses(IReadOnlyList? upliftStatuses) + { + if (upliftStatuses == null || upliftStatuses.Count == 0) + return new List(); + + var mapped = new List(); + foreach (var raw in upliftStatuses) + { + if (string.IsNullOrWhiteSpace(raw)) + continue; + + switch (raw.Trim().ToLowerInvariant()) + { + case "pending": + mapped.Add("Pending"); + mapped.Add("ChangesRequested"); + break; + case "approved": + mapped.Add("Approved"); + break; + case "auto_approved": + mapped.Add("NoApprovalRequired"); + break; + case "rejected": + mapped.Add("Rejected"); + mapped.Add("Denied"); + break; + } + } + + return mapped.Distinct(StringComparer.Ordinal).ToList(); + } + } + +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs index de1f4d2..1d4892d 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs @@ -28,6 +28,14 @@ namespace SeaHaven.DataServices.Implementation baseQuery = WorkOrderBoardQueryFilters.ApplyPmTypeFilter(baseQuery, query.PmTypes); baseQuery = WorkOrderBoardQueryFilters.ApplyVendorFilter(baseQuery, query.VendorIds); baseQuery = WorkOrderBoardQueryFilters.ApplyDocStatusFilter(baseQuery, query.DocStatuses); + baseQuery = WorkOrderBoardSearchFacetFilters.ApplySeverityFilter(baseQuery, query.Severities); + baseQuery = WorkOrderBoardSearchFacetFilters.ApplyRescheduledFilter(baseQuery, query.Rescheduled); + baseQuery = WorkOrderBoardSearchFacetFilters.ApplyCarriedOverFilter(baseQuery, query.CarriedOver); + baseQuery = WorkOrderBoardSearchFacetFilters.ApplyAddOnFilter(baseQuery, query.AddOn); + baseQuery = WorkOrderBoardSearchFacetFilters.ApplyFlagColorFilter(baseQuery, query.FlagColors); + baseQuery = WorkOrderBoardSearchFacetFilters.ApplyInternalOnlyFilter(baseQuery, query.InternalOnly); + baseQuery = WorkOrderBoardSearchFacetFilters.ApplyUpliftFilter( + baseQuery, _context, query.HasUplift, query.UpliftStatuses); var normalizedSearch = WorkOrderBoardSearchFilter.NormalizeSearch(query.Search); if (normalizedSearch != null) diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs index d1ef77f..b965e86 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs @@ -15,6 +15,14 @@ namespace SeaHaven.DataServices.Interfaces IReadOnlyList? PmTypes, IReadOnlyList? VendorIds, IReadOnlyList? DocStatuses, + IReadOnlyList? Severities, + bool Rescheduled, + bool CarriedOver, + bool AddOn, + IReadOnlyList? FlagColors, + bool InternalOnly, + bool HasUplift, + IReadOnlyList? UpliftStatuses, bool MyWorkOrders, string? CurrentUserId, int Page, diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index 3ca672c..930e014 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -63,6 +63,14 @@ namespace SeaHaven.Services.DTOs public List? PmTypes { get; set; } public List? VendorIds { get; set; } public List? DocStatuses { get; set; } + public List? Severities { get; set; } + public bool Rescheduled { get; set; } + public bool CarriedOver { get; set; } + public bool AddOn { get; set; } + public List? FlagColors { get; set; } + public bool InternalOnly { get; set; } + public bool HasUplift { get; set; } + public List? UpliftStatuses { get; set; } public bool MyWorkOrders { get; set; } public string? SortBy { get; set; } public string? SortDir { get; set; } diff --git a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs index a4d332d..f1e79cf 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs @@ -51,6 +51,14 @@ namespace SeaHaven.Services.Implementation query.PmTypes, query.VendorIds, query.DocStatuses, + query.Severities, + query.Rescheduled, + query.CarriedOver, + query.AddOn, + query.FlagColors, + query.InternalOnly, + query.HasUplift, + query.UpliftStatuses, query.MyWorkOrders, currentUserId, page, diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs index 780c25c..179d88f 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs @@ -605,4 +605,164 @@ public class WorkOrderAdvancedSearchServiceTests Assert.Contains("sortBy", ex.Message); } + + [Fact] + public async Task SearchAsync_SeverityFilter_ReturnsEmergencyReactiveOnly() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + WorkOrderType = WorkOrderType.Emergency, + Severity = "1", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000001", + }, + new WorkOrder + { + Id = 2, + WorkOrderType = WorkOrderType.Reactive, + Severity = "2", + ScheduledDate = new DateTime(2026, 6, 24), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000002", + }, + new WorkOrder + { + Id = 3, + WorkOrderType = WorkOrderType.PM, + Severity = "1", + ScheduledDate = new DateTime(2026, 6, 25), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000003", + }); + + await context.SaveChangesAsync(); + + var dataService = new WorkOrderAdvancedSearchDataService(context); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService( + dataService, + WorkOrderAccountTestHelpers.Resolver(context)); + + var result = await service.SearchAsync( + new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = weekStart, + DateTo = weekStart.AddDays(6), + Severities = new List { 1 }, + }, + WorkOrderAccountTestHelpers.OrgWideAdmin(), + null); + + Assert.Equal(1, result.TotalCount); + Assert.Equal(1, result.Items.Single().Id); + } + + [Fact] + public async Task SearchAsync_InternalOnly_ReturnsShPrefixRows() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + InternalWONumber = "SH-1001", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled, + }, + new WorkOrder + { + Id = 2, + InternalWONumber = "10000000002", + ScheduledDate = new DateTime(2026, 6, 24), + LifecycleStatus = LifecycleStatus.Scheduled, + }); + + await context.SaveChangesAsync(); + + var dataService = new WorkOrderAdvancedSearchDataService(context); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService( + dataService, + WorkOrderAccountTestHelpers.Resolver(context)); + + var result = await service.SearchAsync( + new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = weekStart, + DateTo = weekStart.AddDays(6), + InternalOnly = true, + }, + WorkOrderAccountTestHelpers.OrgWideAdmin(), + null); + + Assert.Equal(1, result.TotalCount); + Assert.Equal("SH-1001", result.Items.Single().WoNumber); + } + + [Fact] + public async Task SearchAsync_HasUpliftWithPendingStatus_FiltersRows() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); + context.Dispatches.Add(new Dispatch { Id = 10, VendorId = 1, WorkOrderId = 1 }); + context.Dispatches.Add(new Dispatch { Id = 11, VendorId = 1, WorkOrderId = 2 }); + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + }, + new WorkOrder + { + Id = 2, + InternalWONumber = "10000000002", + ScheduledDate = new DateTime(2026, 6, 24), + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 11, + }); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + Id = 100, + DispatchId = 10, + RequestedNTE = 1500m, + Status = "Pending", + RequiredTier = 1, + NotificationStatus = "Pending", + }); + + await context.SaveChangesAsync(); + + var dataService = new WorkOrderAdvancedSearchDataService(context); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService( + dataService, + WorkOrderAccountTestHelpers.Resolver(context)); + + var result = await service.SearchAsync( + new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = weekStart, + DateTo = weekStart.AddDays(6), + HasUplift = true, + UpliftStatuses = new List { "pending" }, + }, + WorkOrderAccountTestHelpers.OrgWideAdmin(), + null); + + Assert.Equal(1, result.TotalCount); + Assert.Equal(1, result.Items.Single().Id); + } }