mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-06 13:32:13 +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
e5bda0714a
commit
4617e8ba83
15 changed files with 742 additions and 23 deletions
|
|
@ -47,6 +47,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
private readonly IWorkOrderBoardUpdateService _boardUpdateService;
|
private readonly IWorkOrderBoardUpdateService _boardUpdateService;
|
||||||
private readonly IWorkOrderBoardCreateService _boardCreateService;
|
private readonly IWorkOrderBoardCreateService _boardCreateService;
|
||||||
private readonly IWorkOrderBoardCancelService _boardCancelService;
|
private readonly IWorkOrderBoardCancelService _boardCancelService;
|
||||||
|
private readonly IWorkOrderAdvancedSearchService _advancedSearchService;
|
||||||
|
|
||||||
public WorkOrderController(
|
public WorkOrderController(
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
|
|
@ -67,7 +68,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
IWorkOrderBoardService workOrderBoardService,
|
IWorkOrderBoardService workOrderBoardService,
|
||||||
IWorkOrderBoardUpdateService boardUpdateService,
|
IWorkOrderBoardUpdateService boardUpdateService,
|
||||||
IWorkOrderBoardCreateService boardCreateService,
|
IWorkOrderBoardCreateService boardCreateService,
|
||||||
IWorkOrderBoardCancelService boardCancelService)
|
IWorkOrderBoardCancelService boardCancelService,
|
||||||
|
IWorkOrderAdvancedSearchService advancedSearchService)
|
||||||
{
|
{
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_workOrderService = workOrderService;
|
_workOrderService = workOrderService;
|
||||||
|
|
@ -88,6 +90,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
_boardUpdateService = boardUpdateService;
|
_boardUpdateService = boardUpdateService;
|
||||||
_boardCreateService = boardCreateService;
|
_boardCreateService = boardCreateService;
|
||||||
_boardCancelService = boardCancelService;
|
_boardCancelService = boardCancelService;
|
||||||
|
_advancedSearchService = advancedSearchService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("board")]
|
[HttpGet("board")]
|
||||||
|
|
@ -114,6 +117,14 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
return Ok(result);
|
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")]
|
[HttpGet("lookups/dispatchers")]
|
||||||
public async Task<IActionResult> GetDispatcherLookups()
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -134,24 +134,5 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
&& w.TargetWeek >= dateFrom
|
&& w.TargetWeek >= dateFrom
|
||||||
&& w.TargetWeek <= dateTo));
|
&& w.TargetWeek <= dateTo));
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -38,10 +38,11 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
|
|
||||||
var scheduledTotal = await scheduledBase.CountAsync();
|
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);
|
scheduledBase = WorkOrderBoardSearchFilter.Apply(scheduledBase, normalizedSearch);
|
||||||
unscheduledBase = WorkOrderBoardQueryFilters.ApplySearchFilter(unscheduledBase, query.Search);
|
unscheduledBase = WorkOrderBoardSearchFilter.Apply(unscheduledBase, normalizedSearch);
|
||||||
}
|
}
|
||||||
|
|
||||||
var scheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync(
|
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 Data.SeaHavenIndustries.Enums;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
|
||||||
namespace SeaHaven.Services.DTOs
|
namespace SeaHaven.Services.DTOs
|
||||||
{
|
{
|
||||||
|
|
@ -23,4 +24,25 @@ namespace SeaHaven.Services.DTOs
|
||||||
public int? PocContactId { get; set; }
|
public int? PocContactId { get; set; }
|
||||||
public string? PocNotes { 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