mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-03 20:13:22 +00:00
Merge pull request #16 from Sea-Haven-Industries/feat/work-orders-phase-4
Feat/work orders phase 4
This commit is contained in:
commit
917bad3ee1
20 changed files with 4109 additions and 25 deletions
101
Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs
Normal file
101
Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
using Api.SeaHavenIndustries.Controllers;
|
||||||
|
using Api.SeaHavenIndustries.DTOs;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Moq;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class WorkOrderControllerSearchTests
|
||||||
|
{
|
||||||
|
private static ApplicationDbContext NewContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new ApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UserManager<ApplicationUser> MockUserManager()
|
||||||
|
{
|
||||||
|
var store = new Mock<IUserStore<ApplicationUser>>();
|
||||||
|
return new Mock<UserManager<ApplicationUser>>(
|
||||||
|
store.Object, null!, null!, null!, null!, null!, null!, null!, null!).Object;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkOrderController NewController(
|
||||||
|
ApplicationDbContext ctx,
|
||||||
|
IWorkOrderAdvancedSearchService advancedSearch)
|
||||||
|
{
|
||||||
|
var controller = new WorkOrderController(
|
||||||
|
MockUserManager(),
|
||||||
|
Mock.Of<IWorkOrderService>(),
|
||||||
|
Mock.Of<IWorkOrderDataService>(),
|
||||||
|
Mock.Of<ICommentDataService>(),
|
||||||
|
Mock.Of<IQuotesDataService>(),
|
||||||
|
Mock.Of<IUserDataService>(),
|
||||||
|
Mock.Of<IDispatchDataService>(),
|
||||||
|
Mock.Of<IVendorDataService>(),
|
||||||
|
Mock.Of<ITaskListTemplateDataService>(),
|
||||||
|
ctx,
|
||||||
|
Mock.Of<IWebHostEnvironment>(),
|
||||||
|
Mock.Of<IHttpContextAccessor>(),
|
||||||
|
null!,
|
||||||
|
null!,
|
||||||
|
Mock.Of<IConfiguration>(),
|
||||||
|
Mock.Of<IWorkOrderBoardService>(),
|
||||||
|
Mock.Of<IWorkOrderBoardUpdateService>(),
|
||||||
|
Mock.Of<IWorkOrderBoardCreateService>(),
|
||||||
|
Mock.Of<IWorkOrderBoardCancelService>(),
|
||||||
|
advancedSearch);
|
||||||
|
|
||||||
|
controller.ControllerContext = new ControllerContext
|
||||||
|
{
|
||||||
|
HttpContext = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
User = new System.Security.Claims.ClaimsPrincipal(
|
||||||
|
new System.Security.Claims.ClaimsIdentity(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new System.Security.Claims.Claim(
|
||||||
|
System.Security.Claims.ClaimTypes.NameIdentifier,
|
||||||
|
"test-user")
|
||||||
|
},
|
||||||
|
"Test"))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchBoard_WhenCustomPresetMissingDates_ReturnsBadRequest()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var advancedSearch = new WorkOrderAdvancedSearchService(
|
||||||
|
Mock.Of<IWorkOrderAdvancedSearchDataService>());
|
||||||
|
var controller = NewController(ctx, advancedSearch);
|
||||||
|
|
||||||
|
var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto
|
||||||
|
{
|
||||||
|
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom
|
||||||
|
});
|
||||||
|
|
||||||
|
var badRequest = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||||
|
var body = badRequest.Value.Should().BeOfType<Response>().Subject;
|
||||||
|
body.Status.Should().Be("Error");
|
||||||
|
body.Message.Should().Contain("dateFrom");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -49,6 +49,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,
|
||||||
|
|
@ -69,7 +70,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;
|
||||||
|
|
@ -90,6 +92,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
_boardUpdateService = boardUpdateService;
|
_boardUpdateService = boardUpdateService;
|
||||||
_boardCreateService = boardCreateService;
|
_boardCreateService = boardCreateService;
|
||||||
_boardCancelService = boardCancelService;
|
_boardCancelService = boardCancelService;
|
||||||
|
_advancedSearchService = advancedSearchService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -134,6 +137,21 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("board/search")]
|
||||||
|
public async Task<IActionResult> SearchBoard([FromQuery] WorkOrderAdvancedSearchQueryDto query)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
var result = await _advancedSearchService.SearchAsync(query, userId);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Dispatcher dropdown options. Currently returns all non-deleted users
|
/// Dispatcher dropdown options. Currently returns all non-deleted users
|
||||||
/// (role-filtered assignee list deferred until roles settle).
|
/// (role-filtered assignee list deferred until roles settle).
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,28 @@ namespace Data.SeaHavenIndustries
|
||||||
.Property(w => w.RowVersion)
|
.Property(w => w.RowVersion)
|
||||||
.IsRowVersion();
|
.IsRowVersion();
|
||||||
|
|
||||||
|
// Bounded lengths required for SQL Server index keys (nvarchar(max) is not indexable).
|
||||||
|
// SiteCode: board create contract MaximumLength(32). InternalWONumber: normalized to 11 digits.
|
||||||
|
builder.Entity<WorkOrder>()
|
||||||
|
.Property(w => w.SiteCode)
|
||||||
|
.HasMaxLength(32);
|
||||||
|
|
||||||
|
builder.Entity<WorkOrder>()
|
||||||
|
.Property(w => w.InternalWONumber)
|
||||||
|
.HasMaxLength(11);
|
||||||
|
|
||||||
|
builder.Entity<WorkOrder>()
|
||||||
|
.HasIndex(w => w.SiteCode)
|
||||||
|
.HasFilter("[istemplate] = 0");
|
||||||
|
|
||||||
|
builder.Entity<WorkOrder>()
|
||||||
|
.HasIndex(w => w.InternalWONumber)
|
||||||
|
.HasFilter("[istemplate] = 0");
|
||||||
|
|
||||||
|
builder.Entity<WorkOrder>()
|
||||||
|
.HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate })
|
||||||
|
.HasFilter("[istemplate] = 0");
|
||||||
|
|
||||||
builder.Entity<Dispatch>()
|
builder.Entity<Dispatch>()
|
||||||
.Property(d => d.RowVersion)
|
.Property(d => d.RowVersion)
|
||||||
.IsRowVersion();
|
.IsRowVersion();
|
||||||
|
|
|
||||||
3008
Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.Designer.cs
generated
Normal file
3008
Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,84 @@
|
||||||
|
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.AlterColumn<string>(
|
||||||
|
name: "SiteCode",
|
||||||
|
table: "workOrders",
|
||||||
|
type: "nvarchar(32)",
|
||||||
|
maxLength: 32,
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "nvarchar(max)");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "InternalWONumber",
|
||||||
|
table: "workOrders",
|
||||||
|
type: "nvarchar(11)",
|
||||||
|
maxLength: 11,
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "nvarchar(max)");
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "SiteCode",
|
||||||
|
table: "workOrders",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "nvarchar(32)",
|
||||||
|
oldMaxLength: 32);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "InternalWONumber",
|
||||||
|
table: "workOrders",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "nvarchar(11)",
|
||||||
|
oldMaxLength: 11);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2280,7 +2280,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("InternalWONumber")
|
b.Property<string>("InternalWONumber")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasMaxLength(11)
|
||||||
|
.HasColumnType("nvarchar(11)");
|
||||||
|
|
||||||
b.Property<bool?>("IsDeleted")
|
b.Property<bool?>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
@ -2358,7 +2359,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("SiteCode")
|
b.Property<string>("SiteCode")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("nvarchar(32)");
|
||||||
|
|
||||||
b.Property<string>("Source")
|
b.Property<string>("Source")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
@ -2405,12 +2407,21 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
|
||||||
b.HasIndex("AssignTo", "ScheduledDate");
|
b.HasIndex("AssignTo", "ScheduledDate");
|
||||||
|
|
||||||
|
b.HasIndex("InternalWONumber")
|
||||||
|
.HasFilter("[istemplate] = 0");
|
||||||
|
|
||||||
|
b.HasIndex("LifecycleStatus", "ScheduledDate")
|
||||||
|
.HasFilter("[istemplate] = 0");
|
||||||
|
|
||||||
b.HasIndex("LocationId");
|
b.HasIndex("LocationId");
|
||||||
|
|
||||||
b.HasIndex("PrimaryDispatchId");
|
b.HasIndex("PrimaryDispatchId");
|
||||||
|
|
||||||
b.HasIndex("ScheduledDate");
|
b.HasIndex("ScheduledDate");
|
||||||
|
|
||||||
|
b.HasIndex("SiteCode")
|
||||||
|
.HasFilter("[istemplate] = 0");
|
||||||
|
|
||||||
b.HasIndex("WorkOrderType");
|
b.HasIndex("WorkOrderType");
|
||||||
|
|
||||||
b.ToTable("workOrders");
|
b.ToTable("workOrders");
|
||||||
|
|
|
||||||
|
|
@ -143,24 +143,5 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#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();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
180
SeaHavenIndustries.Tests/Phase4SearchIndexesSqlServerTests.cs
Normal file
180
SeaHavenIndustries.Tests/Phase4SearchIndexesSqlServerTests.cs
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
using System.Reflection;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Migrations;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class Phase4SearchIndexesSqlServerTests
|
||||||
|
{
|
||||||
|
private const string LocalDbMaster =
|
||||||
|
@"Server=(localdb)\MSSQLLocalDB;Database=master;Trusted_Connection=True;TrustServerCertificate=True;Connect Timeout=3";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WorkOrder_IndexedStringColumns_HaveBoundedMaxLengths()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseSqlServer(LocalDbMaster)
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
using var context = new ApplicationDbContext(options);
|
||||||
|
var entity = context.Model.FindEntityType(typeof(WorkOrder));
|
||||||
|
Assert.NotNull(entity);
|
||||||
|
|
||||||
|
Assert.Equal(32, entity.FindProperty(nameof(WorkOrder.SiteCode))!.GetMaxLength());
|
||||||
|
Assert.Equal(11, entity.FindProperty(nameof(WorkOrder.InternalWONumber))!.GetMaxLength());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Phase4_SearchIndexes_ApplyOnSqlServer_WhenLocalDbAvailable()
|
||||||
|
{
|
||||||
|
if (!await IsLocalDbAvailableAsync())
|
||||||
|
return;
|
||||||
|
|
||||||
|
var dbName = $"Phase4SearchIndexes_{Guid.NewGuid():N}";
|
||||||
|
var connectionString =
|
||||||
|
$@"Server=(localdb)\MSSQLLocalDB;Database={dbName};Trusted_Connection=True;TrustServerCertificate=True";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var connection = new SqlConnection(connectionString);
|
||||||
|
await connection.OpenAsync();
|
||||||
|
|
||||||
|
await using (var createTable = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
createTable.CommandText =
|
||||||
|
"""
|
||||||
|
CREATE TABLE workOrders (
|
||||||
|
Id int NOT NULL IDENTITY PRIMARY KEY,
|
||||||
|
SiteCode nvarchar(max) NULL,
|
||||||
|
InternalWONumber nvarchar(max) NULL,
|
||||||
|
LifecycleStatus int NULL,
|
||||||
|
ScheduledDate datetime2 NULL,
|
||||||
|
istemplate bit NULL
|
||||||
|
);
|
||||||
|
""";
|
||||||
|
await createTable.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseSqlServer(connection)
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
await using var context = new ApplicationDbContext(options);
|
||||||
|
await ApplyMigrationUpAsync(context, new Phase4_SearchIndexes());
|
||||||
|
|
||||||
|
await using (var assertCmd = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
assertCmd.CommandText =
|
||||||
|
"""
|
||||||
|
SELECT c.name, t.name AS type_name, c.max_length
|
||||||
|
FROM sys.columns c
|
||||||
|
INNER JOIN sys.types t ON c.user_type_id = t.user_type_id
|
||||||
|
WHERE c.object_id = OBJECT_ID(N'workOrders')
|
||||||
|
AND c.name IN (N'SiteCode', N'InternalWONumber')
|
||||||
|
ORDER BY c.name;
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var reader = await assertCmd.ExecuteReaderAsync();
|
||||||
|
var columns = new Dictionary<string, (string Type, short MaxLength)>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
while (await reader.ReadAsync())
|
||||||
|
{
|
||||||
|
columns[reader.GetString(0)] = (reader.GetString(1), reader.GetInt16(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal("nvarchar", columns["InternalWONumber"].Type);
|
||||||
|
Assert.Equal(22, columns["InternalWONumber"].MaxLength); // nvarchar(11) => 22 bytes
|
||||||
|
Assert.Equal("nvarchar", columns["SiteCode"].Type);
|
||||||
|
Assert.Equal(64, columns["SiteCode"].MaxLength); // nvarchar(32) => 64 bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var indexCmd = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
indexCmd.CommandText =
|
||||||
|
"""
|
||||||
|
SELECT name
|
||||||
|
FROM sys.indexes
|
||||||
|
WHERE object_id = OBJECT_ID(N'workOrders')
|
||||||
|
AND name IN (
|
||||||
|
N'IX_workOrders_SiteCode',
|
||||||
|
N'IX_workOrders_InternalWONumber',
|
||||||
|
N'IX_workOrders_LifecycleStatus_ScheduledDate'
|
||||||
|
);
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var reader = await indexCmd.ExecuteReaderAsync();
|
||||||
|
var indexes = new List<string>();
|
||||||
|
while (await reader.ReadAsync())
|
||||||
|
indexes.Add(reader.GetString(0));
|
||||||
|
|
||||||
|
Assert.Contains("IX_workOrders_SiteCode", indexes);
|
||||||
|
Assert.Contains("IX_workOrders_InternalWONumber", indexes);
|
||||||
|
Assert.Contains("IX_workOrders_LifecycleStatus_ScheduledDate", indexes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await DropDatabaseAsync(dbName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ApplyMigrationUpAsync(ApplicationDbContext context, Migration migration)
|
||||||
|
{
|
||||||
|
var builder = new MigrationBuilder(context.Database.ProviderName!);
|
||||||
|
var up = typeof(Migration).GetMethod("Up", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||||
|
?? throw new InvalidOperationException("Migration.Up not found.");
|
||||||
|
up.Invoke(migration, [builder]);
|
||||||
|
|
||||||
|
var sqlGenerator = context.GetService<IMigrationsSqlGenerator>();
|
||||||
|
var commands = sqlGenerator.Generate(builder.Operations, model: null);
|
||||||
|
foreach (var command in commands)
|
||||||
|
await context.Database.ExecuteSqlRawAsync(command.CommandText);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> IsLocalDbAvailableAsync()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var connection = new SqlConnection(LocalDbMaster);
|
||||||
|
await connection.OpenAsync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (SqlException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task DropDatabaseAsync(string dbName)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var connection = new SqlConnection(LocalDbMaster);
|
||||||
|
await connection.OpenAsync();
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText =
|
||||||
|
$"""
|
||||||
|
IF DB_ID(N'{dbName}') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER DATABASE [{dbName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
|
||||||
|
DROP DATABASE [{dbName}];
|
||||||
|
END
|
||||||
|
""";
|
||||||
|
await command.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort cleanup for ephemeral LocalDB databases.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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