mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-05 13:02:10 +00:00
chore(work-orders): merge SH-121 facets and keep SH-184 IsAddOn migration
This commit is contained in:
commit
fa05b22df6
8 changed files with 384 additions and 1 deletions
|
|
@ -0,0 +1,25 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <summary>
|
||||||
|
/// SH-121 originally added <c>IsAddOn</c> for search facets. Column + legacy backfill
|
||||||
|
/// now live in <c>20260730150000_WoIsAddOn</c> (SH-184). This migration is a no-op so
|
||||||
|
/// both PRs can land without a duplicate column.
|
||||||
|
/// </summary>
|
||||||
|
public partial class SH121_BoardSearchFacets : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -80,7 +80,7 @@ namespace Data.SeaHavenIndustries
|
||||||
// --- Analytics slice ---
|
// --- Analytics slice ---
|
||||||
public int RescheduleCount { get; set; }
|
public int RescheduleCount { get; set; }
|
||||||
public int CarriedOver { get; set; }
|
public int CarriedOver { get; set; }
|
||||||
/// <summary>Recalculated on schedule set/change/clear (SH-184); create hint when unscheduled.</summary>
|
/// <summary>Recalculated on schedule set/change/clear (SH-184); create hint when unscheduled. Board/search facet.</summary>
|
||||||
public bool IsAddOn { get; set; }
|
public bool IsAddOn { get; set; }
|
||||||
public OperationalFlags OperationalFlags { get; set; }
|
public OperationalFlags OperationalFlags { get; set; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Helpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// SH-121/SH-196 advanced search facets (severity, indicators, flag, internal WO#, uplift).
|
||||||
|
/// </summary>
|
||||||
|
public static class WorkOrderBoardSearchFacetFilters
|
||||||
|
{
|
||||||
|
private static readonly WorkOrderType[] SeverityEligibleTypes =
|
||||||
|
{
|
||||||
|
WorkOrderType.Emergency,
|
||||||
|
WorkOrderType.Reactive,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly string[] RevokedUpliftStatuses =
|
||||||
|
{
|
||||||
|
"Withdrawn",
|
||||||
|
"Cancelled",
|
||||||
|
};
|
||||||
|
|
||||||
|
public static IQueryable<WorkOrder> ApplySeverityFilter(
|
||||||
|
IQueryable<WorkOrder> query,
|
||||||
|
IReadOnlyList<int>? severities)
|
||||||
|
{
|
||||||
|
if (severities == null || severities.Count == 0)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
var allowed = severities
|
||||||
|
.Where(s => s is >= 1 and <= 5)
|
||||||
|
.Select(s => s.ToString())
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (allowed.Count == 0)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
return query.Where(w =>
|
||||||
|
w.WorkOrderType != null
|
||||||
|
&& SeverityEligibleTypes.Contains(w.WorkOrderType.Value)
|
||||||
|
&& w.Severity != null
|
||||||
|
&& allowed.Contains(w.Severity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IQueryable<WorkOrder> ApplyRescheduledFilter(
|
||||||
|
IQueryable<WorkOrder> query,
|
||||||
|
bool rescheduled)
|
||||||
|
{
|
||||||
|
if (!rescheduled)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
return query.Where(w => w.RescheduleCount >= 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IQueryable<WorkOrder> ApplyCarriedOverFilter(
|
||||||
|
IQueryable<WorkOrder> query,
|
||||||
|
bool carriedOver)
|
||||||
|
{
|
||||||
|
if (!carriedOver)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
return query.Where(w => w.CarriedOver >= 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IQueryable<WorkOrder> ApplyAddOnFilter(
|
||||||
|
IQueryable<WorkOrder> query,
|
||||||
|
bool addOn)
|
||||||
|
{
|
||||||
|
if (!addOn)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
return query.Where(w => w.IsAddOn);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IQueryable<WorkOrder> ApplyFlagColorFilter(
|
||||||
|
IQueryable<WorkOrder> query,
|
||||||
|
IReadOnlyList<string>? flagColors)
|
||||||
|
{
|
||||||
|
if (flagColors == null || flagColors.Count == 0)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
var normalized = flagColors
|
||||||
|
.Where(c => !string.IsNullOrWhiteSpace(c))
|
||||||
|
.Select(c => c.Trim().ToUpperInvariant())
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (normalized.Count == 0)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
return query.Where(w =>
|
||||||
|
w.FlagColor != null && normalized.Contains(w.FlagColor.ToUpper()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IQueryable<WorkOrder> ApplyInternalOnlyFilter(
|
||||||
|
IQueryable<WorkOrder> query,
|
||||||
|
bool internalOnly)
|
||||||
|
{
|
||||||
|
if (!internalOnly)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
return query.Where(w =>
|
||||||
|
w.InternalWONumber != null
|
||||||
|
&& EF.Functions.Like(w.InternalWONumber, "SH%"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IQueryable<WorkOrder> ApplyUpliftFilter(
|
||||||
|
IQueryable<WorkOrder> query,
|
||||||
|
ApplicationDbContext context,
|
||||||
|
bool hasUplift,
|
||||||
|
IReadOnlyList<string>? upliftStatuses)
|
||||||
|
{
|
||||||
|
if (!hasUplift)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
var mappedStatuses = MapUpliftStatuses(upliftStatuses);
|
||||||
|
|
||||||
|
return query.Where(w => context.DispatchUpliftRequests.Any(u =>
|
||||||
|
(u.IsDeleted == null || u.IsDeleted == false)
|
||||||
|
&& u.Status != "Expired"
|
||||||
|
&& !RevokedUpliftStatuses.Contains(u.Status)
|
||||||
|
&& u.Dispatch != null
|
||||||
|
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
|
||||||
|
&& (
|
||||||
|
u.Dispatch.WorkOrderId == w.Id
|
||||||
|
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id))
|
||||||
|
&& (mappedStatuses.Count == 0 || mappedStatuses.Contains(u.Status))));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static List<string> MapUpliftStatuses(IReadOnlyList<string>? upliftStatuses)
|
||||||
|
{
|
||||||
|
if (upliftStatuses == null || upliftStatuses.Count == 0)
|
||||||
|
return new List<string>();
|
||||||
|
|
||||||
|
var mapped = new List<string>();
|
||||||
|
foreach (var raw in upliftStatuses)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
switch (raw.Trim().ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "pending":
|
||||||
|
mapped.Add("Pending");
|
||||||
|
mapped.Add("ChangesRequested");
|
||||||
|
break;
|
||||||
|
case "approved":
|
||||||
|
mapped.Add("Approved");
|
||||||
|
break;
|
||||||
|
case "auto_approved":
|
||||||
|
mapped.Add("NoApprovalRequired");
|
||||||
|
break;
|
||||||
|
case "rejected":
|
||||||
|
mapped.Add("Rejected");
|
||||||
|
mapped.Add("Denied");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapped.Distinct(StringComparer.Ordinal).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,14 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
baseQuery = WorkOrderBoardQueryFilters.ApplyPmTypeFilter(baseQuery, query.PmTypes);
|
baseQuery = WorkOrderBoardQueryFilters.ApplyPmTypeFilter(baseQuery, query.PmTypes);
|
||||||
baseQuery = WorkOrderBoardQueryFilters.ApplyVendorFilter(baseQuery, query.VendorIds);
|
baseQuery = WorkOrderBoardQueryFilters.ApplyVendorFilter(baseQuery, query.VendorIds);
|
||||||
baseQuery = WorkOrderBoardQueryFilters.ApplyDocStatusFilter(baseQuery, query.DocStatuses);
|
baseQuery = WorkOrderBoardQueryFilters.ApplyDocStatusFilter(baseQuery, query.DocStatuses);
|
||||||
|
baseQuery = WorkOrderBoardSearchFacetFilters.ApplySeverityFilter(baseQuery, query.Severities);
|
||||||
|
baseQuery = WorkOrderBoardSearchFacetFilters.ApplyRescheduledFilter(baseQuery, query.Rescheduled);
|
||||||
|
baseQuery = WorkOrderBoardSearchFacetFilters.ApplyCarriedOverFilter(baseQuery, query.CarriedOver);
|
||||||
|
baseQuery = WorkOrderBoardSearchFacetFilters.ApplyAddOnFilter(baseQuery, query.AddOn);
|
||||||
|
baseQuery = WorkOrderBoardSearchFacetFilters.ApplyFlagColorFilter(baseQuery, query.FlagColors);
|
||||||
|
baseQuery = WorkOrderBoardSearchFacetFilters.ApplyInternalOnlyFilter(baseQuery, query.InternalOnly);
|
||||||
|
baseQuery = WorkOrderBoardSearchFacetFilters.ApplyUpliftFilter(
|
||||||
|
baseQuery, _context, query.HasUplift, query.UpliftStatuses);
|
||||||
|
|
||||||
var normalizedSearch = WorkOrderBoardSearchFilter.NormalizeSearch(query.Search);
|
var normalizedSearch = WorkOrderBoardSearchFilter.NormalizeSearch(query.Search);
|
||||||
if (normalizedSearch != null)
|
if (normalizedSearch != null)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,14 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
IReadOnlyList<string>? PmTypes,
|
IReadOnlyList<string>? PmTypes,
|
||||||
IReadOnlyList<int>? VendorIds,
|
IReadOnlyList<int>? VendorIds,
|
||||||
IReadOnlyList<DocStatus>? DocStatuses,
|
IReadOnlyList<DocStatus>? DocStatuses,
|
||||||
|
IReadOnlyList<int>? Severities,
|
||||||
|
bool Rescheduled,
|
||||||
|
bool CarriedOver,
|
||||||
|
bool AddOn,
|
||||||
|
IReadOnlyList<string>? FlagColors,
|
||||||
|
bool InternalOnly,
|
||||||
|
bool HasUplift,
|
||||||
|
IReadOnlyList<string>? UpliftStatuses,
|
||||||
bool MyWorkOrders,
|
bool MyWorkOrders,
|
||||||
string? CurrentUserId,
|
string? CurrentUserId,
|
||||||
int Page,
|
int Page,
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,14 @@ namespace SeaHaven.Services.DTOs
|
||||||
public List<string>? PmTypes { get; set; }
|
public List<string>? PmTypes { get; set; }
|
||||||
public List<int>? VendorIds { get; set; }
|
public List<int>? VendorIds { get; set; }
|
||||||
public List<DocStatus>? DocStatuses { get; set; }
|
public List<DocStatus>? DocStatuses { get; set; }
|
||||||
|
public List<int>? Severities { get; set; }
|
||||||
|
public bool Rescheduled { get; set; }
|
||||||
|
public bool CarriedOver { get; set; }
|
||||||
|
public bool AddOn { get; set; }
|
||||||
|
public List<string>? FlagColors { get; set; }
|
||||||
|
public bool InternalOnly { get; set; }
|
||||||
|
public bool HasUplift { get; set; }
|
||||||
|
public List<string>? UpliftStatuses { get; set; }
|
||||||
public bool MyWorkOrders { get; set; }
|
public bool MyWorkOrders { get; set; }
|
||||||
public string? SortBy { get; set; }
|
public string? SortBy { get; set; }
|
||||||
public string? SortDir { get; set; }
|
public string? SortDir { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,14 @@ namespace SeaHaven.Services.Implementation
|
||||||
query.PmTypes,
|
query.PmTypes,
|
||||||
query.VendorIds,
|
query.VendorIds,
|
||||||
query.DocStatuses,
|
query.DocStatuses,
|
||||||
|
query.Severities,
|
||||||
|
query.Rescheduled,
|
||||||
|
query.CarriedOver,
|
||||||
|
query.AddOn,
|
||||||
|
query.FlagColors,
|
||||||
|
query.InternalOnly,
|
||||||
|
query.HasUplift,
|
||||||
|
query.UpliftStatuses,
|
||||||
query.MyWorkOrders,
|
query.MyWorkOrders,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
page,
|
page,
|
||||||
|
|
|
||||||
|
|
@ -669,4 +669,164 @@ public class WorkOrderAdvancedSearchServiceTests
|
||||||
Assert.Contains(result.Items, i => i.Id == 2 && i.IsAddOn && i.WorkOrderType == WorkOrderType.PM);
|
Assert.Contains(result.Items, i => i.Id == 2 && i.IsAddOn && i.WorkOrderType == WorkOrderType.PM);
|
||||||
Assert.DoesNotContain(result.Items, i => i.Id == 3);
|
Assert.DoesNotContain(result.Items, i => i.Id == 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchAsync_SeverityFilter_ReturnsEmergencyReactiveOnly()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var weekStart = new DateOnly(2026, 6, 22);
|
||||||
|
|
||||||
|
context.workOrders.AddRange(
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
WorkOrderType = WorkOrderType.Emergency,
|
||||||
|
Severity = "1",
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 23),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
InternalWONumber = "10000000001",
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 2,
|
||||||
|
WorkOrderType = WorkOrderType.Reactive,
|
||||||
|
Severity = "2",
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 24),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
InternalWONumber = "10000000002",
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 3,
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
Severity = "1",
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 25),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
InternalWONumber = "10000000003",
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var dataService = new WorkOrderAdvancedSearchDataService(context);
|
||||||
|
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(
|
||||||
|
dataService,
|
||||||
|
WorkOrderAccountTestHelpers.Resolver(context));
|
||||||
|
|
||||||
|
var result = await service.SearchAsync(
|
||||||
|
new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
|
||||||
|
{
|
||||||
|
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
|
||||||
|
DateFrom = weekStart,
|
||||||
|
DateTo = weekStart.AddDays(6),
|
||||||
|
Severities = new List<int> { 1 },
|
||||||
|
},
|
||||||
|
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
||||||
|
null);
|
||||||
|
|
||||||
|
Assert.Equal(1, result.TotalCount);
|
||||||
|
Assert.Equal(1, result.Items.Single().Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchAsync_InternalOnly_ReturnsShPrefixRows()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var weekStart = new DateOnly(2026, 6, 22);
|
||||||
|
|
||||||
|
context.workOrders.AddRange(
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
InternalWONumber = "SH-1001",
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 23),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 2,
|
||||||
|
InternalWONumber = "10000000002",
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 24),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var dataService = new WorkOrderAdvancedSearchDataService(context);
|
||||||
|
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(
|
||||||
|
dataService,
|
||||||
|
WorkOrderAccountTestHelpers.Resolver(context));
|
||||||
|
|
||||||
|
var result = await service.SearchAsync(
|
||||||
|
new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
|
||||||
|
{
|
||||||
|
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
|
||||||
|
DateFrom = weekStart,
|
||||||
|
DateTo = weekStart.AddDays(6),
|
||||||
|
InternalOnly = true,
|
||||||
|
},
|
||||||
|
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
||||||
|
null);
|
||||||
|
|
||||||
|
Assert.Equal(1, result.TotalCount);
|
||||||
|
Assert.Equal("SH-1001", result.Items.Single().WoNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchAsync_HasUpliftWithPendingStatus_FiltersRows()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var weekStart = new DateOnly(2026, 6, 22);
|
||||||
|
|
||||||
|
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
|
||||||
|
context.Dispatches.Add(new Dispatch { Id = 10, VendorId = 1, WorkOrderId = 1 });
|
||||||
|
context.Dispatches.Add(new Dispatch { Id = 11, VendorId = 1, WorkOrderId = 2 });
|
||||||
|
context.workOrders.AddRange(
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
InternalWONumber = "10000000001",
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 23),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
PrimaryDispatchId = 10,
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 2,
|
||||||
|
InternalWONumber = "10000000002",
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 24),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
PrimaryDispatchId = 11,
|
||||||
|
});
|
||||||
|
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||||
|
{
|
||||||
|
Id = 100,
|
||||||
|
DispatchId = 10,
|
||||||
|
RequestedNTE = 1500m,
|
||||||
|
Status = "Pending",
|
||||||
|
RequiredTier = 1,
|
||||||
|
NotificationStatus = "Pending",
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var dataService = new WorkOrderAdvancedSearchDataService(context);
|
||||||
|
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(
|
||||||
|
dataService,
|
||||||
|
WorkOrderAccountTestHelpers.Resolver(context));
|
||||||
|
|
||||||
|
var result = await service.SearchAsync(
|
||||||
|
new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
|
||||||
|
{
|
||||||
|
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
|
||||||
|
DateFrom = weekStart,
|
||||||
|
DateTo = weekStart.AddDays(6),
|
||||||
|
HasUplift = true,
|
||||||
|
UpliftStatuses = new List<string> { "pending" },
|
||||||
|
},
|
||||||
|
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
||||||
|
null);
|
||||||
|
|
||||||
|
Assert.Equal(1, result.TotalCount);
|
||||||
|
Assert.Equal(1, result.Items.Single().Id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue