From 4617e8ba83f6e1c1eee7460dc50f7abf5d4ea97a Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 8 Jul 2026 14:58:59 -0300 Subject: [PATCH] feat(work-orders): add Phase 4 board search and advanced search API Harden contextual search on the weekly board and expose paginated cross-week GET /board/search with date presets, FE filter params, SQL indexes, and unit tests. --- .../Controllers/WorkOrderController.cs | 13 +- .../20260624200000_Phase4_SearchIndexes.cs | 48 ++++ .../Helpers/WorkOrderBoardQueryFilters.cs | 19 -- .../Helpers/WorkOrderBoardSearchFilter.cs | 47 ++++ .../WorkOrderAdvancedSearchDataService.cs | 64 +++++ .../WorkOrderBoardDataService.cs | 7 +- .../IWorkOrderAdvancedSearchDataService.cs | 9 + .../WorkOrderAdvancedSearchModels.cs | 27 ++ .../DTOs/WorkOrderBoardRequestDTOs.cs | 22 ++ .../WorkOrderSearchDateRangeResolver.cs | 95 +++++++ .../WorkOrderAdvancedSearchService.cs | 63 +++++ .../IWorkOrderAdvancedSearchService.cs | 11 + .../WorkOrderBoardSearchTests.cs | 240 ++++++++++++++++++ scripts/load-test/README.md | 40 +++ scripts/load-test/work-order-search.k6.js | 60 +++++ 15 files changed, 742 insertions(+), 23 deletions(-) create mode 100644 Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs create mode 100644 SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs create mode 100644 SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IWorkOrderAdvancedSearchDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderAdvancedSearchService.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs create mode 100644 scripts/load-test/README.md create mode 100644 scripts/load-test/work-order-search.k6.js diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index df2d601..f227ffd 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -47,6 +47,7 @@ namespace Api.SeaHavenIndustries.Controllers private readonly IWorkOrderBoardUpdateService _boardUpdateService; private readonly IWorkOrderBoardCreateService _boardCreateService; private readonly IWorkOrderBoardCancelService _boardCancelService; + private readonly IWorkOrderAdvancedSearchService _advancedSearchService; public WorkOrderController( UserManager userManager, @@ -67,7 +68,8 @@ namespace Api.SeaHavenIndustries.Controllers IWorkOrderBoardService workOrderBoardService, IWorkOrderBoardUpdateService boardUpdateService, IWorkOrderBoardCreateService boardCreateService, - IWorkOrderBoardCancelService boardCancelService) + IWorkOrderBoardCancelService boardCancelService, + IWorkOrderAdvancedSearchService advancedSearchService) { _userManager = userManager; _workOrderService = workOrderService; @@ -88,6 +90,7 @@ namespace Api.SeaHavenIndustries.Controllers _boardUpdateService = boardUpdateService; _boardCreateService = boardCreateService; _boardCancelService = boardCancelService; + _advancedSearchService = advancedSearchService; } [HttpGet("board")] @@ -114,6 +117,14 @@ namespace Api.SeaHavenIndustries.Controllers return Ok(result); } + [HttpGet("board/search")] + public async Task SearchBoard([FromQuery] WorkOrderAdvancedSearchQueryDto query) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var result = await _advancedSearchService.SearchAsync(query, userId); + return Ok(result); + } + [HttpGet("lookups/dispatchers")] public async Task GetDispatcherLookups() { diff --git a/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs b/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs new file mode 100644 index 0000000..427b6cf --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class Phase4_SearchIndexes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_workOrders_SiteCode", + table: "workOrders", + column: "SiteCode", + filter: "[istemplate] = 0"); + + migrationBuilder.CreateIndex( + name: "IX_workOrders_InternalWONumber", + table: "workOrders", + column: "InternalWONumber", + filter: "[istemplate] = 0"); + + migrationBuilder.CreateIndex( + name: "IX_workOrders_LifecycleStatus_ScheduledDate", + table: "workOrders", + columns: new[] { "LifecycleStatus", "ScheduledDate" }, + filter: "[istemplate] = 0"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_workOrders_SiteCode", + table: "workOrders"); + + migrationBuilder.DropIndex( + name: "IX_workOrders_InternalWONumber", + table: "workOrders"); + + migrationBuilder.DropIndex( + name: "IX_workOrders_LifecycleStatus_ScheduledDate", + table: "workOrders"); + } + } +} diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs index 35465ca..bb9919b 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -134,24 +134,5 @@ namespace SeaHaven.DataServices.Helpers && w.TargetWeek >= dateFrom && w.TargetWeek <= dateTo)); } - - public static IQueryable ApplySearchFilter(IQueryable query, string? search) - { - if (string.IsNullOrWhiteSpace(search)) - return query; - - var s = search.Trim().ToLower(); - return query.Where(w => - (w.SiteCode ?? "").ToLower().Contains(s) - || (w.InternalWONumber ?? "").ToLower().Contains(s) - || (w.WorkerOrderNumber ?? "").ToLower().Contains(s) - || (w.Locations != null && (w.Locations.Name ?? "").ToLower().Contains(s)) - || (w.AssignToUser != null && ((w.AssignToUser.FirstName ?? "") + " " + (w.AssignToUser.LastName ?? "")).ToLower().Contains(s)) - || (w.Service ?? "").ToLower().Contains(s) - || (w.Trade ?? "").ToLower().Contains(s) - || (w.Problem ?? "").ToLower().Contains(s) - || (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.CompanyName ?? "").ToLower().Contains(s)) - || (w.Status ?? "").ToLower().Contains(s)); - } } } diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs new file mode 100644 index 0000000..eac12ba --- /dev/null +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs @@ -0,0 +1,47 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.DataServices.Helpers +{ + public static class WorkOrderBoardSearchFilter + { + public static string? NormalizeSearch(string? search) + { + if (string.IsNullOrWhiteSpace(search)) + return null; + + var trimmed = search.Trim(); + return trimmed.Length < 2 ? null : trimmed; + } + + public static IQueryable Apply(IQueryable query, string? search) + { + var normalized = NormalizeSearch(search); + if (normalized == null) + return query; + + var s = normalized.ToLower(); + return query.Where(w => + (w.SiteCode ?? "").ToLower().Contains(s) + || (w.InternalWONumber ?? "").ToLower().Contains(s) + || (w.WorkerOrderNumber ?? "").ToLower().Contains(s) + || (w.Locations != null && (w.Locations.Name ?? "").ToLower().Contains(s)) + || (w.AssignToUser != null && ((w.AssignToUser.FirstName ?? "") + " " + (w.AssignToUser.LastName ?? "")).ToLower().Contains(s)) + || (w.Trade ?? "").ToLower().Contains(s) + || (w.Problem ?? "").ToLower().Contains(s) + || (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.CompanyName ?? "").ToLower().Contains(s)) + || (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.ContactName ?? "").ToLower().Contains(s)) + || (w.Status ?? "").ToLower().Contains(s) + || (w.LifecycleStatus == LifecycleStatus.Incomplete && "incomplete".Contains(s)) + || (w.LifecycleStatus == LifecycleStatus.Pending && ("pending".Contains(s) || "on hold".Contains(s) || "onhold".Contains(s))) + || (w.LifecycleStatus == LifecycleStatus.Scheduled && "scheduled".Contains(s)) + || (w.LifecycleStatus == LifecycleStatus.EnRoute && ("en route".Contains(s) || "enroute".Contains(s))) + || (w.LifecycleStatus == LifecycleStatus.OnSite && ("on site".Contains(s) || "onsite".Contains(s))) + || (w.LifecycleStatus == LifecycleStatus.InProgress && ("in progress".Contains(s) || "inprogress".Contains(s))) + || (w.LifecycleStatus == LifecycleStatus.Completed && ("completed".Contains(s) || "complete".Contains(s) || "done".Contains(s) || "closed".Contains(s))) + || (w.LifecycleStatus == LifecycleStatus.Rescheduled && "rescheduled".Contains(s)) + || (w.LifecycleStatus == LifecycleStatus.Canceled && ("canceled".Contains(s) || "cancelled".Contains(s))) + || (w.LifecycleStatus == LifecycleStatus.PendingQuote && ("pending quote".Contains(s) || "pendingquote".Contains(s) || "pending approval".Contains(s) || "pendingapproval".Contains(s)))); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs new file mode 100644 index 0000000..556fe4f --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs @@ -0,0 +1,64 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Helpers; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderAdvancedSearchDataService : IWorkOrderAdvancedSearchDataService + { + private readonly ApplicationDbContext _context; + + public WorkOrderAdvancedSearchDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task SearchAsync(WorkOrderAdvancedSearchQuery query) + { + var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); + baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo); + baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites); + baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types); + baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter( + baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId); + baseQuery = WorkOrderBoardQueryFilters.ApplyStatusFilter(baseQuery, query.Statuses); + baseQuery = WorkOrderBoardQueryFilters.ApplyPmTypeFilter(baseQuery, query.PmTypes); + baseQuery = WorkOrderBoardQueryFilters.ApplyVendorFilter(baseQuery, query.VendorIds); + baseQuery = WorkOrderBoardQueryFilters.ApplyDocStatusFilter(baseQuery, query.DocStatuses); + + var normalizedSearch = WorkOrderBoardSearchFilter.NormalizeSearch(query.Search); + if (normalizedSearch != null) + baseQuery = WorkOrderBoardSearchFilter.Apply(baseQuery, normalizedSearch); + + var totalCount = await baseQuery.CountAsync(); + + var sorted = ApplySort(baseQuery, query.SortBy, query.SortDir); + var paged = sorted + .Skip((query.Page - 1) * query.PageSize) + .Take(query.PageSize); + + var rows = await WorkOrderBoardProjection.ProjectRowsAsync(paged, isUnscheduled: false); + return new WorkOrderAdvancedSearchResult(rows, totalCount); + } + + private static IQueryable ApplySort(IQueryable query, string sortBy, string sortDir) + { + var descending = string.Equals(sortDir, "desc", StringComparison.OrdinalIgnoreCase); + var field = (sortBy ?? "scheduledDate").Trim().ToLowerInvariant(); + + return field switch + { + "wonumber" or "woNumber" => descending + ? query.OrderByDescending(w => w.InternalWONumber) + : query.OrderBy(w => w.InternalWONumber), + "duedate" or "dueDate" => descending + ? query.OrderByDescending(w => w.DueDate).ThenBy(w => w.InternalWONumber) + : query.OrderBy(w => w.DueDate).ThenBy(w => w.InternalWONumber), + _ => descending + ? query.OrderByDescending(w => w.ScheduledDate).ThenByDescending(w => w.InternalWONumber) + : query.OrderBy(w => w.ScheduledDate).ThenBy(w => w.InternalWONumber) + }; + } + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs index 05a4e82..51936eb 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs @@ -38,10 +38,11 @@ namespace SeaHaven.DataServices.Implementation var scheduledTotal = await scheduledBase.CountAsync(); - if (!string.IsNullOrWhiteSpace(query.Search)) + var normalizedSearch = WorkOrderBoardSearchFilter.NormalizeSearch(query.Search); + if (normalizedSearch != null) { - scheduledBase = WorkOrderBoardQueryFilters.ApplySearchFilter(scheduledBase, query.Search); - unscheduledBase = WorkOrderBoardQueryFilters.ApplySearchFilter(unscheduledBase, query.Search); + scheduledBase = WorkOrderBoardSearchFilter.Apply(scheduledBase, normalizedSearch); + unscheduledBase = WorkOrderBoardSearchFilter.Apply(unscheduledBase, normalizedSearch); } var scheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync( diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderAdvancedSearchDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderAdvancedSearchDataService.cs new file mode 100644 index 0000000..f758550 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderAdvancedSearchDataService.cs @@ -0,0 +1,9 @@ +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IWorkOrderAdvancedSearchDataService + { + Task SearchAsync(WorkOrderAdvancedSearchQuery query); + } +} diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs new file mode 100644 index 0000000..0cede89 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs @@ -0,0 +1,27 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Interfaces +{ + public record WorkOrderAdvancedSearchQuery( + string? Search, + DateOnly DateFrom, + DateOnly DateTo, + IReadOnlyList? Sites, + IReadOnlyList? Types, + IReadOnlyList? Dispatchers, + IReadOnlyList? Statuses, + IReadOnlyList? PmTypes, + IReadOnlyList? VendorIds, + IReadOnlyList? DocStatuses, + bool MyWorkOrders, + string? CurrentUserId, + int Page, + int PageSize, + string SortBy, + string SortDir); + + public record WorkOrderAdvancedSearchResult( + IReadOnlyList Rows, + int TotalCount); +} diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index d3ac591..31fae59 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.Helpers; namespace SeaHaven.Services.DTOs { @@ -23,4 +24,25 @@ namespace SeaHaven.Services.DTOs public int? PocContactId { get; set; } public string? PocNotes { get; set; } } + + /// Advanced search / filter query for the board. + public class WorkOrderAdvancedSearchQueryDto + { + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 50; + public string? Search { get; set; } + public WorkOrderAdvancedSearchDatePreset? DatePreset { get; set; } + public DateOnly? DateFrom { get; set; } + public DateOnly? DateTo { get; set; } + public List? Sites { get; set; } + public List? Types { get; set; } + public List? Dispatchers { get; set; } + public List? Statuses { get; set; } + public List? PmTypes { get; set; } + public List? VendorIds { get; set; } + public List? DocStatuses { get; set; } + public bool MyWorkOrders { get; set; } + public string? SortBy { get; set; } + public string? SortDir { get; set; } + } } diff --git a/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs b/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs new file mode 100644 index 0000000..d41df2f --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs @@ -0,0 +1,95 @@ +namespace SeaHaven.Services.Helpers +{ + public enum WorkOrderAdvancedSearchDatePreset + { + ThisWeek, + LastWeek, + ThisMonth, + Last3Months, + NextWeek, + NextMonth, + Custom + } + + public record WorkOrderSearchDateRange(DateOnly From, DateOnly To); + + public static class WorkOrderSearchDateRangeResolver + { + public static WorkOrderSearchDateRange Resolve( + WorkOrderAdvancedSearchDatePreset? preset, + DateOnly? dateFrom, + DateOnly? dateTo, + DateOnly? referenceDate = null) + { + var today = referenceDate ?? DateOnly.FromDateTime(DateTime.UtcNow); + var resolvedPreset = preset ?? WorkOrderAdvancedSearchDatePreset.ThisWeek; + + if (resolvedPreset == WorkOrderAdvancedSearchDatePreset.Custom) + { + if (!dateFrom.HasValue || !dateTo.HasValue) + throw new ArgumentException("dateFrom and dateTo are required when datePreset is custom."); + + if (dateTo.Value < dateFrom.Value) + throw new ArgumentException("dateTo must be on or after dateFrom."); + + return new WorkOrderSearchDateRange(dateFrom.Value, dateTo.Value); + } + + return resolvedPreset switch + { + WorkOrderAdvancedSearchDatePreset.ThisWeek => ThisIsoWeek(today), + WorkOrderAdvancedSearchDatePreset.LastWeek => LastIsoWeek(today), + WorkOrderAdvancedSearchDatePreset.ThisMonth => ThisMonth(today), + WorkOrderAdvancedSearchDatePreset.Last3Months => Last3Months(today), + WorkOrderAdvancedSearchDatePreset.NextWeek => NextIsoWeek(today), + WorkOrderAdvancedSearchDatePreset.NextMonth => NextMonth(today), + _ => ThisIsoWeek(today) + }; + } + + public static DateOnly GetIsoWeekStart(DateOnly date) + { + var diff = (7 + (date.DayOfWeek - DayOfWeek.Monday)) % 7; + return date.AddDays(-diff); + } + + private static WorkOrderSearchDateRange ThisIsoWeek(DateOnly today) + { + var start = GetIsoWeekStart(today); + return new WorkOrderSearchDateRange(start, start.AddDays(6)); + } + + private static WorkOrderSearchDateRange LastIsoWeek(DateOnly today) + { + var start = GetIsoWeekStart(today).AddDays(-7); + return new WorkOrderSearchDateRange(start, start.AddDays(6)); + } + + private static WorkOrderSearchDateRange NextIsoWeek(DateOnly today) + { + var start = GetIsoWeekStart(today).AddDays(7); + return new WorkOrderSearchDateRange(start, start.AddDays(6)); + } + + private static WorkOrderSearchDateRange ThisMonth(DateOnly today) + { + var start = new DateOnly(today.Year, today.Month, 1); + var end = start.AddMonths(1).AddDays(-1); + return new WorkOrderSearchDateRange(start, end); + } + + private static WorkOrderSearchDateRange NextMonth(DateOnly today) + { + var start = new DateOnly(today.Year, today.Month, 1).AddMonths(1); + var end = start.AddMonths(1).AddDays(-1); + return new WorkOrderSearchDateRange(start, end); + } + + private static WorkOrderSearchDateRange Last3Months(DateOnly today) + { + var end = today; + var start = today.AddMonths(-3); + return new WorkOrderSearchDateRange(start, end); + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs new file mode 100644 index 0000000..285ec9e --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs @@ -0,0 +1,63 @@ +using SeaHaven.DataServices.Helpers; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderAdvancedSearchService : IWorkOrderAdvancedSearchService + { + private const int DefaultPageSize = 50; + private const int MaxPageSize = 100; + + private readonly IWorkOrderAdvancedSearchDataService _searchDataService; + + public WorkOrderAdvancedSearchService(IWorkOrderAdvancedSearchDataService searchDataService) + { + _searchDataService = searchDataService; + } + + public async Task> SearchAsync( + WorkOrderAdvancedSearchQueryDto query, + string? currentUserId) + { + var page = query.Page < 1 ? 1 : query.Page; + var pageSize = query.PageSize < 1 ? DefaultPageSize : Math.Min(query.PageSize, MaxPageSize); + + var dateRange = WorkOrderSearchDateRangeResolver.Resolve( + query.DatePreset, + query.DateFrom, + query.DateTo); + + var dataQuery = new WorkOrderAdvancedSearchQuery( + WorkOrderBoardSearchFilter.NormalizeSearch(query.Search), + dateRange.From, + dateRange.To, + query.Sites, + query.Types, + query.Dispatchers, + query.Statuses, + query.PmTypes, + query.VendorIds, + query.DocStatuses, + query.MyWorkOrders, + currentUserId, + page, + pageSize, + query.SortBy ?? "scheduledDate", + query.SortDir ?? "asc"); + + var result = await _searchDataService.SearchAsync(dataQuery); + var utcNow = DateTime.UtcNow; + + return new PagedResult + { + Items = result.Rows.Select(r => WorkOrderBoardService.MapRawRow(r, utcNow)).ToList(), + TotalCount = result.TotalCount, + Page = page, + PageSize = pageSize + }; + } + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Interfaces/IWorkOrderAdvancedSearchService.cs new file mode 100644 index 0000000..8da8835 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderAdvancedSearchService.cs @@ -0,0 +1,11 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderAdvancedSearchService + { + Task> SearchAsync( + WorkOrderAdvancedSearchQueryDto query, + string? currentUserId); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs new file mode 100644 index 0000000..290b63d --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs @@ -0,0 +1,240 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Helpers; +using SeaHaven.DataServices.Implementation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderBoardSearchFilterTests +{ + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + [Theory] + [InlineData("BK5", 1)] + [InlineData("10000000001", 1)] + [InlineData("HVAC Tech", 1)] + [InlineData("Acme HVAC", 1)] + [InlineData("sched", 1)] + public async Task Apply_MatchesExpectedField(string search, int expectedId) + { + await using var context = CreateContext(); + await SeedSearchFixtures(context); + + var ids = await WorkOrderBoardSearchFilter + .Apply(context.workOrders.AsQueryable(), search) + .Select(w => w.Id) + .ToListAsync(); + + Assert.Contains(expectedId, ids); + } + + [Fact] + public void NormalizeSearch_IgnoresSingleCharacter() + { + Assert.Null(WorkOrderBoardSearchFilter.NormalizeSearch("a")); + Assert.Null(WorkOrderBoardSearchFilter.NormalizeSearch(" ")); + Assert.Equal("ab", WorkOrderBoardSearchFilter.NormalizeSearch(" ab ")); + } + + [Fact] + public async Task Apply_LifecycleLabel_MatchesScheduled() + { + await using var context = CreateContext(); + await SeedSearchFixtures(context); + + var ids = await WorkOrderBoardSearchFilter + .Apply(context.workOrders.AsQueryable(), "scheduled") + .Select(w => w.Id) + .ToListAsync(); + + Assert.Single(ids); + Assert.Equal(1, ids[0]); + } + + private static async Task SeedSearchFixtures(ApplicationDbContext context) + { + context.Vendors.Add(new Vendor + { + Id = 1, + CompanyName = "Acme HVAC", + ContactName = "HVAC Tech", + Phone = "555-0100" + }); + context.Dispatches.Add(new Dispatch { Id = 10, VendorId = 1 }); + context.workOrders.Add(new WorkOrder + { + Id = 1, + SiteCode = "BK5", + InternalWONumber = "10000000001", + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10 + }); + await context.SaveChangesAsync(); + } +} + +public class WorkOrderBoardSearchCountsTests +{ + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + [Fact] + public async Task GetBoardRows_SearchReducesScheduledAndKeepsTotal() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + SiteCode = "BK5", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 2, + SiteCode = "BK6", + ScheduledDate = new DateTime(2026, 6, 24), + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 3, + SiteCode = "BK5", + ScheduledDate = null, + LifecycleStatus = LifecycleStatus.Incomplete + }); + + await context.SaveChangesAsync(); + + var service = new WorkOrderBoardDataService(context); + var result = await service.GetBoardRowsAsync(new SeaHaven.DataServices.Interfaces.WorkOrderBoardQuery( + weekStart, + weekStart.AddDays(4), + null, + false, + null, + "BK5", + null)); + + Assert.Equal(2, result.ScheduledTotalBeforeSearch); + Assert.Single(result.ScheduledRows); + Assert.Equal("BK5", result.ScheduledRows[0].SiteCode); + Assert.Single(result.UnscheduledRows); + Assert.Equal("BK5", result.UnscheduledRows[0].SiteCode); + } +} + +public class WorkOrderSearchDateRangeResolverTests +{ + [Fact] + public void Resolve_ThisWeek_UsesIsoMonday() + { + var reference = new DateOnly(2026, 6, 24); + var range = SeaHaven.Services.Helpers.WorkOrderSearchDateRangeResolver.Resolve( + SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek, + null, + null, + reference); + + Assert.Equal(new DateOnly(2026, 6, 22), range.From); + Assert.Equal(new DateOnly(2026, 6, 28), range.To); + } + + [Fact] + public void Resolve_Custom_RequiresDates() + { + var ex = Assert.Throws(() => + SeaHaven.Services.Helpers.WorkOrderSearchDateRangeResolver.Resolve( + SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + null, + null)); + + Assert.Contains("dateFrom", ex.Message); + } +} + +public class WorkOrderAdvancedSearchServiceTests +{ + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + [Fact] + public async Task SearchAsync_FiltersBySiteAndPaginates() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + SiteCode = "BK5", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000001" + }, + new WorkOrder + { + Id = 2, + SiteCode = "BK6", + ScheduledDate = new DateTime(2026, 6, 24), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000002" + }); + + await context.SaveChangesAsync(); + + var dataService = new WorkOrderAdvancedSearchDataService(context); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); + + var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = weekStart, + DateTo = weekStart.AddDays(6), + Sites = new List { "BK5" }, + Page = 1, + PageSize = 50 + }, null); + + Assert.Equal(1, result.TotalCount); + Assert.Single(result.Items); + Assert.Equal("BK5", result.Items.First().SiteCode); + } + + [Fact] + public async Task SearchAsync_ClampsPageSizeToMax() + { + await using var context = CreateContext(); + var dataService = new WorkOrderAdvancedSearchDataService(context); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); + + var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek, + PageSize = 500 + }, null); + + Assert.Equal(100, result.PageSize); + } +} diff --git a/scripts/load-test/README.md b/scripts/load-test/README.md new file mode 100644 index 0000000..c068043 --- /dev/null +++ b/scripts/load-test/README.md @@ -0,0 +1,40 @@ +# Load Test — Work Order Search (Tier S) + +Harness k6 para validar SLOs da Fase 4 em ambiente local ou staging. + +## Pré-requisitos + +- [k6](https://k6.io/docs/get-started/installation/) instalado +- API rodando com JWT válido +- (Opcional) Seed sintético: `scripts/seed-work-orders-search.ps1 -Count 15000` + +## Variáveis de ambiente + +| Variável | Descrição | Default | +|----------|-----------|---------| +| `BASE_URL` | URL base da API | `https://localhost:5001` | +| `JWT` | Bearer token | _(obrigatório)_ | +| `WEEK_START` | Segunda-feira ISO para board | `2026-06-22` | + +## Execução + +```powershell +$env:JWT = "" +$env:BASE_URL = "https://localhost:5001" +k6 run scripts/load-test/work-order-search.k6.js +``` + +## SLOs Tier S (p95) + +| Métrica | Limite | +|---------|--------| +| `board_no_search` | < 800ms | +| `board_with_search` | < 1000ms | +| `advanced_preset` | < 1200ms | +| `advanced_multi_filter` | < 1500ms | + +## Staging (pós Volume Discovery) + +1. Executar `scripts/volume-discovery.ps1` +2. Confirmar tier em `docs/work-orders/phase-4/search-tier-decision.md` +3. Repetir k6 com volume real; ajustar thresholds se tier = M diff --git a/scripts/load-test/work-order-search.k6.js b/scripts/load-test/work-order-search.k6.js new file mode 100644 index 0000000..ff69d67 --- /dev/null +++ b/scripts/load-test/work-order-search.k6.js @@ -0,0 +1,60 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Trend } from 'k6/metrics'; + +const boardNoSearch = new Trend('board_no_search', true); +const boardWithSearch = new Trend('board_with_search', true); +const advancedPreset = new Trend('advanced_preset', true); +const advancedMultiFilter = new Trend('advanced_multi_filter', true); + +const BASE_URL = __ENV.BASE_URL || 'https://localhost:5001'; +const JWT = __ENV.JWT || ''; +const WEEK_START = __ENV.WEEK_START || '2026-06-22'; + +export const options = { + vus: 10, + duration: '30s', + thresholds: { + board_no_search: ['p(95)<800'], + board_with_search: ['p(95)<1000'], + advanced_preset: ['p(95)<1200'], + advanced_multi_filter: ['p(95)<1500'], + }, +}; + +const headers = { + Authorization: `Bearer ${JWT}`, + Accept: 'application/json', +}; + +export default function () { + const boardRes = http.get( + `${BASE_URL}/api/workorders/board?weekStart=${WEEK_START}`, + { headers } + ); + boardNoSearch.add(boardRes.timings.duration); + check(boardRes, { 'board 200': (r) => r.status === 200 }); + + const searchRes = http.get( + `${BASE_URL}/api/workorders/board?weekStart=${WEEK_START}&search=BK5`, + { headers } + ); + boardWithSearch.add(searchRes.timings.duration); + check(searchRes, { 'board search 200': (r) => r.status === 200 }); + + const advRes = http.get( + `${BASE_URL}/api/workorders/board/search?datePreset=thisMonth&page=1&pageSize=50`, + { headers } + ); + advancedPreset.add(advRes.timings.duration); + check(advRes, { 'advanced preset 200': (r) => r.status === 200 }); + + const multiRes = http.get( + `${BASE_URL}/api/workorders/board/search?datePreset=thisMonth&sites=BK5&types=PM&search=HVAC&page=1`, + { headers } + ); + advancedMultiFilter.add(multiRes.timings.duration); + check(multiRes, { 'advanced multi 200': (r) => r.status === 200 }); + + sleep(0.5); +}