mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
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.
This commit is contained in:
parent
385812c5c3
commit
11fed06f03
15 changed files with 742 additions and 23 deletions
|
|
@ -49,6 +49,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
private readonly IWorkOrderBoardUpdateService _boardUpdateService;
|
||||
private readonly IWorkOrderBoardCreateService _boardCreateService;
|
||||
private readonly IWorkOrderBoardCancelService _boardCancelService;
|
||||
private readonly IWorkOrderAdvancedSearchService _advancedSearchService;
|
||||
|
||||
public WorkOrderController(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
|
|
@ -69,7 +70,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
IWorkOrderBoardService workOrderBoardService,
|
||||
IWorkOrderBoardUpdateService boardUpdateService,
|
||||
IWorkOrderBoardCreateService boardCreateService,
|
||||
IWorkOrderBoardCancelService boardCancelService)
|
||||
IWorkOrderBoardCancelService boardCancelService,
|
||||
IWorkOrderAdvancedSearchService advancedSearchService)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_workOrderService = workOrderService;
|
||||
|
|
@ -90,6 +92,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
_boardUpdateService = boardUpdateService;
|
||||
_boardCreateService = boardCreateService;
|
||||
_boardCancelService = boardCancelService;
|
||||
_advancedSearchService = advancedSearchService;
|
||||
}
|
||||
|
||||
[HttpGet("board")]
|
||||
|
|
@ -121,6 +124,14 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("board/search")]
|
||||
public async Task<IActionResult> 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<IActionResult> GetDispatcherLookups()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Phase4_SearchIndexes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -138,24 +138,5 @@ namespace SeaHaven.DataServices.Helpers
|
|||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public static IQueryable<WorkOrder> ApplySearchFilter(IQueryable<WorkOrder> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
47
SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs
Normal file
47
SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs
Normal file
|
|
@ -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<WorkOrder> Apply(IQueryable<WorkOrder> 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))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<WorkOrderAdvancedSearchResult> 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<WorkOrder> ApplySort(IQueryable<WorkOrder> 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)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -37,10 +37,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(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
public interface IWorkOrderAdvancedSearchDataService
|
||||
{
|
||||
Task<WorkOrderAdvancedSearchResult> SearchAsync(WorkOrderAdvancedSearchQuery query);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string>? Sites,
|
||||
IReadOnlyList<WorkOrderType>? Types,
|
||||
IReadOnlyList<string>? Dispatchers,
|
||||
IReadOnlyList<LifecycleStatus>? Statuses,
|
||||
IReadOnlyList<string>? PmTypes,
|
||||
IReadOnlyList<int>? VendorIds,
|
||||
IReadOnlyList<DocStatus>? DocStatuses,
|
||||
bool MyWorkOrders,
|
||||
string? CurrentUserId,
|
||||
int Page,
|
||||
int PageSize,
|
||||
string SortBy,
|
||||
string SortDir);
|
||||
|
||||
public record WorkOrderAdvancedSearchResult(
|
||||
IReadOnlyList<WorkOrderBoardRawRow> Rows,
|
||||
int TotalCount);
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
||||
/// <summary>Advanced search / filter query for the board.</summary>
|
||||
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<string>? Sites { get; set; }
|
||||
public List<WorkOrderType>? Types { get; set; }
|
||||
public List<string>? Dispatchers { get; set; }
|
||||
public List<LifecycleStatus>? Statuses { get; set; }
|
||||
public List<string>? PmTypes { get; set; }
|
||||
public List<int>? VendorIds { get; set; }
|
||||
public List<DocStatus>? DocStatuses { get; set; }
|
||||
public bool MyWorkOrders { get; set; }
|
||||
public string? SortBy { get; set; }
|
||||
public string? SortDir { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<PagedResult<WorkOrderBoardRowDto>> 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<WorkOrderBoardRowDto>
|
||||
{
|
||||
Items = result.Rows.Select(r => WorkOrderBoardService.MapRawRow(r, utcNow)).ToList(),
|
||||
TotalCount = result.TotalCount,
|
||||
Page = page,
|
||||
PageSize = pageSize
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IWorkOrderAdvancedSearchService
|
||||
{
|
||||
Task<PagedResult<WorkOrderBoardRowDto>> SearchAsync(
|
||||
WorkOrderAdvancedSearchQueryDto query,
|
||||
string? currentUserId);
|
||||
}
|
||||
}
|
||||
240
SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs
Normal file
240
SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs
Normal file
|
|
@ -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<ApplicationDbContext>()
|
||||
.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<ApplicationDbContext>()
|
||||
.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<ArgumentException>(() =>
|
||||
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<ApplicationDbContext>()
|
||||
.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<string> { "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);
|
||||
}
|
||||
}
|
||||
40
scripts/load-test/README.md
Normal file
40
scripts/load-test/README.md
Normal file
|
|
@ -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 = "<seu-token>"
|
||||
$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
|
||||
60
scripts/load-test/work-order-search.k6.js
Normal file
60
scripts/load-test/work-order-search.k6.js
Normal file
|
|
@ -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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue