mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 16:19:07 +00:00
Compare commits
3 commits
1283bf7349
...
c9a80f5c79
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9a80f5c79 | ||
|
|
fa05b22df6 | ||
|
|
17c2e968cd |
18 changed files with 467 additions and 76 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 ---
|
||||
public int RescheduleCount { 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 OperationalFlags OperationalFlags { get; set; }
|
||||
|
||||
|
|
|
|||
|
|
@ -73,8 +73,8 @@ namespace SeaHaven.DataServices.Helpers
|
|||
if (overdue && !hasTypes)
|
||||
{
|
||||
return query.Where(w =>
|
||||
w.DueDate != null
|
||||
&& w.DueDate.Value.Date < today
|
||||
w.ScheduledDate != null
|
||||
&& w.ScheduledDate.Value.Date < today
|
||||
&& w.LifecycleStatus != LifecycleStatus.Completed
|
||||
&& w.LifecycleStatus != LifecycleStatus.Canceled);
|
||||
}
|
||||
|
|
@ -82,8 +82,8 @@ namespace SeaHaven.DataServices.Helpers
|
|||
if (overdue)
|
||||
{
|
||||
return query.Where(w =>
|
||||
(w.DueDate != null
|
||||
&& w.DueDate.Value.Date < today
|
||||
(w.ScheduledDate != null
|
||||
&& w.ScheduledDate.Value.Date < today
|
||||
&& w.LifecycleStatus != LifecycleStatus.Completed
|
||||
&& w.LifecycleStatus != LifecycleStatus.Canceled)
|
||||
|| (w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value))
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -61,8 +61,8 @@ namespace SeaHaven.DataServices.Helpers
|
|||
|| (w.IsAddOn && ("add-on".Contains(s) || "addon".Contains(s)))
|
||||
|| (w.WorkOrderType == WorkOrderType.Other && "other".Contains(s))
|
||||
|| ("overdue".Contains(s)
|
||||
&& w.DueDate != null
|
||||
&& w.DueDate.Value.Date < today
|
||||
&& w.ScheduledDate != null
|
||||
&& w.ScheduledDate.Value.Date < today
|
||||
&& w.LifecycleStatus != LifecycleStatus.Completed
|
||||
&& w.LifecycleStatus != LifecycleStatus.Canceled)
|
||||
|| (w.LifecycleStatus == LifecycleStatus.Incomplete && "incomplete".Contains(s))
|
||||
|
|
|
|||
|
|
@ -28,6 +28,14 @@ namespace SeaHaven.DataServices.Implementation
|
|||
baseQuery = WorkOrderBoardQueryFilters.ApplyPmTypeFilter(baseQuery, query.PmTypes);
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyVendorFilter(baseQuery, query.VendorIds);
|
||||
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);
|
||||
if (normalizedSearch != null)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,14 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
IReadOnlyList<string>? PmTypes,
|
||||
IReadOnlyList<int>? VendorIds,
|
||||
IReadOnlyList<DocStatus>? DocStatuses,
|
||||
IReadOnlyList<int>? Severities,
|
||||
bool Rescheduled,
|
||||
bool CarriedOver,
|
||||
bool AddOn,
|
||||
IReadOnlyList<string>? FlagColors,
|
||||
bool InternalOnly,
|
||||
bool HasUplift,
|
||||
IReadOnlyList<string>? UpliftStatuses,
|
||||
bool MyWorkOrders,
|
||||
string? CurrentUserId,
|
||||
int Page,
|
||||
|
|
|
|||
|
|
@ -68,6 +68,14 @@ namespace SeaHaven.Services.DTOs
|
|||
public List<string>? PmTypes { get; set; }
|
||||
public List<int>? VendorIds { 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 string? SortBy { get; set; }
|
||||
public string? SortDir { get; set; }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
|
|
@ -6,38 +6,38 @@ namespace SeaHaven.Services.Helpers
|
|||
public static class WorkOrderDerivedFields
|
||||
{
|
||||
/// <summary>
|
||||
/// Past due overlay from DueDate (deadline) — never a WorkOrderType.
|
||||
/// Independent of ScheduledDate (planned work / Schedule On).
|
||||
/// Past due overlay from Schedule On — never a WorkOrderType. Matches SHOC board design (SH-185).
|
||||
/// Changing Due Date alone must never trigger or clear it.
|
||||
/// </summary>
|
||||
public static bool IsPastDue(WorkOrder workOrder, DateOnly? asOf = null)
|
||||
{
|
||||
var today = asOf ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
if (workOrder.DueDate == null)
|
||||
if (workOrder.ScheduledDate == null)
|
||||
return false;
|
||||
|
||||
if (LifecycleStatusMapper.IsTerminal(workOrder.LifecycleStatus))
|
||||
return false;
|
||||
|
||||
return DateOnly.FromDateTime(workOrder.DueDate.Value.Date) < today;
|
||||
return DateOnly.FromDateTime(workOrder.ScheduledDate.Value.Date) < today;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Board read-model past-due check against DueDate. Compares UTC calendar dates;
|
||||
/// Board read-model past-due check against ScheduledDate. Compares UTC calendar dates;
|
||||
/// business/location timezone support is planned for a later phase.
|
||||
/// </summary>
|
||||
public static bool IsPastDue(
|
||||
DateTime? dueDate,
|
||||
DateTime? scheduledDate,
|
||||
LifecycleStatus? lifecycleStatus,
|
||||
DateTime? utcNow = null)
|
||||
{
|
||||
if (!dueDate.HasValue)
|
||||
if (!scheduledDate.HasValue)
|
||||
return false;
|
||||
|
||||
if (LifecycleStatusMapper.IsTerminal(lifecycleStatus))
|
||||
return false;
|
||||
|
||||
var today = (utcNow ?? DateTime.UtcNow).Date;
|
||||
return dueDate.Value.Date < today;
|
||||
return scheduledDate.Value.Date < today;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -51,6 +51,14 @@ namespace SeaHaven.Services.Implementation
|
|||
query.PmTypes,
|
||||
query.VendorIds,
|
||||
query.DocStatuses,
|
||||
query.Severities,
|
||||
query.Rescheduled,
|
||||
query.CarriedOver,
|
||||
query.AddOn,
|
||||
query.FlagColors,
|
||||
query.InternalOnly,
|
||||
query.HasUplift,
|
||||
query.UpliftStatuses,
|
||||
query.MyWorkOrders,
|
||||
currentUserId,
|
||||
page,
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ namespace SeaHaven.Services.Implementation
|
|||
CarriedOver = row.CarriedOver,
|
||||
IsAddOn = row.IsAddOn,
|
||||
WorkOrderType = row.WorkOrderType,
|
||||
IsPastDue = WorkOrderDerivedFields.IsPastDue(row.DueDate, row.LifecycleStatus, utcNow),
|
||||
IsPastDue = WorkOrderDerivedFields.IsPastDue(row.ScheduledDate, row.LifecycleStatus, utcNow),
|
||||
SiteCode = row.SiteCode,
|
||||
LocationName = row.LocationName,
|
||||
PocName = row.PocName,
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ namespace SeaHaven.Services.Implementation
|
|||
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
|
||||
throw new WorkOrderBoardValidationException("CanceledReadOnly", "Work order is read-only in its current status.");
|
||||
|
||||
var isPastDue = WorkOrderDerivedFields.IsPastDue(workOrder.DueDate, workOrder.LifecycleStatus);
|
||||
var isPastDue = WorkOrderDerivedFields.IsPastDue(workOrder.ScheduledDate, workOrder.LifecycleStatus);
|
||||
if (WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(canonicalField, isPastDue))
|
||||
throw new WorkOrderBoardValidationException("PastDueStatusBlocked", "Status cannot be changed while work order is past due. Update due date first.");
|
||||
|
||||
|
|
|
|||
|
|
@ -141,8 +141,11 @@ public class WorkOrderBoardConcurrencyTests
|
|||
WorkOrderVersion = Convert.ToBase64String(wo!.RowVersion!)
|
||||
}, "actor-1");
|
||||
|
||||
var auditCount = await context.WorkOrderAuditLogs.CountAsync();
|
||||
Assert.Equal(2, auditCount);
|
||||
var audits = await context.WorkOrderAuditLogs.ToListAsync();
|
||||
Assert.Equal(3, audits.Count);
|
||||
Assert.Contains(audits, a => a.FieldName == "IsAddOn");
|
||||
Assert.Contains(audits, a => a.FieldName == "ScheduledDate");
|
||||
Assert.Contains(audits, a => a.FieldName == "RescheduleCount");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -379,7 +379,7 @@ public class WorkOrderAdvancedSearchServiceTests
|
|||
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
|
||||
|
||||
Assert.Equal(1, result.TotalCount);
|
||||
Assert.Equal(1, result.Items.First().Id);
|
||||
Assert.Equal(3, result.Items.First().Id);
|
||||
Assert.True(result.Items.First().IsPastDue);
|
||||
}
|
||||
|
||||
|
|
@ -394,8 +394,8 @@ public class WorkOrderAdvancedSearchServiceTests
|
|||
{
|
||||
Id = 1,
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
DueDate = today.AddDays(-3),
|
||||
ScheduledDate = today.AddDays(5),
|
||||
DueDate = today.AddDays(5),
|
||||
ScheduledDate = today.AddDays(-3),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
InternalWONumber = "10000000001"
|
||||
},
|
||||
|
|
@ -498,9 +498,9 @@ public class WorkOrderAdvancedSearchServiceTests
|
|||
|
||||
var overdue = Assert.Single(result.Items);
|
||||
Assert.Equal(0, result.Page);
|
||||
Assert.Equal(2, overdue.Id);
|
||||
Assert.Equal(1, overdue.Id);
|
||||
Assert.True(overdue.IsPastDue);
|
||||
Assert.DoesNotContain(result.Items, i => i.Id == 1);
|
||||
Assert.DoesNotContain(result.Items, i => i.Id == 2);
|
||||
Assert.DoesNotContain(result.Items, i => i.Id == 3);
|
||||
Assert.DoesNotContain(result.Items, i => i.Id == 4);
|
||||
}
|
||||
|
|
@ -669,4 +669,164 @@ public class WorkOrderAdvancedSearchServiceTests
|
|||
Assert.Contains(result.Items, i => i.Id == 2 && i.IsAddOn && i.WorkOrderType == WorkOrderType.PM);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,11 @@ public class WorkOrderBoardUpdateServiceTests
|
|||
}, "actor-1");
|
||||
|
||||
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
|
||||
Assert.Equal(2, await context.WorkOrderAuditLogs.CountAsync());
|
||||
var audits = await context.WorkOrderAuditLogs.ToListAsync();
|
||||
Assert.Equal(3, audits.Count);
|
||||
Assert.Contains(audits, a => a.FieldName == "IsAddOn");
|
||||
Assert.Contains(audits, a => a.FieldName == "ScheduledDate");
|
||||
Assert.Contains(audits, a => a.Action == AuditActionType.StatusChanged.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -91,8 +95,8 @@ public class WorkOrderBoardUpdateServiceTests
|
|||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
DueDate = DateTime.UtcNow.Date.AddDays(-3),
|
||||
ScheduledDate = DateTime.UtcNow.Date.AddDays(5),
|
||||
DueDate = DateTime.UtcNow.Date.AddDays(5),
|
||||
ScheduledDate = DateTime.UtcNow.Date.AddDays(-3),
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
|
|
@ -110,7 +114,7 @@ public class WorkOrderBoardUpdateServiceTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_RescheduleAlone_DoesNotClearPastDueBlock()
|
||||
public async Task PatchField_FutureSchedule_ClearsPastDueAndAllowsStatusChange()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
|
|
@ -132,23 +136,23 @@ public class WorkOrderBoardUpdateServiceTests
|
|||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.True(row.IsPastDue);
|
||||
Assert.False(row.IsPastDue);
|
||||
Assert.Equal(DateTime.UtcNow.Date.AddDays(7), row.ScheduledDate!.Value.Date);
|
||||
|
||||
wo = await context.workOrders.SingleAsync(w => w.Id == 1);
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.LifecycleStatus,
|
||||
Value = "Complete",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1"));
|
||||
var afterStatus = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.LifecycleStatus,
|
||||
Value = "Complete",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal("PastDueStatusBlocked", ex.Code);
|
||||
Assert.Equal(LifecycleStatus.Completed, afterStatus.LifecycleStatus);
|
||||
Assert.False(afterStatus.IsPastDue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_FutureDueDate_ClearsPastDueAndAllowsStatusChange()
|
||||
public async Task PatchField_DueDateAlone_DoesNotClearPastDue()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
|
|
@ -170,19 +174,19 @@ public class WorkOrderBoardUpdateServiceTests
|
|||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.False(afterDue.IsPastDue);
|
||||
Assert.True(afterDue.IsPastDue);
|
||||
Assert.Equal(DateTime.UtcNow.Date.AddDays(-1), afterDue.ScheduledDate!.Value.Date);
|
||||
|
||||
wo = await context.workOrders.SingleAsync(w => w.Id == 1);
|
||||
var afterStatus = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.LifecycleStatus,
|
||||
Value = "Complete",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.LifecycleStatus,
|
||||
Value = "Complete",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1"));
|
||||
|
||||
Assert.Equal(LifecycleStatus.Completed, afterStatus.LifecycleStatus);
|
||||
Assert.False(afterStatus.IsPastDue);
|
||||
Assert.Equal("PastDueStatusBlocked", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -12,21 +12,7 @@ public class WorkOrderDerivedFieldsTests
|
|||
private static readonly DateTime TodayDate = DateTime.UtcNow.Date;
|
||||
|
||||
[Fact]
|
||||
public void IsPastDue_WhenDuePastAndScheduleFuture_ReturnsTrue()
|
||||
{
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
DueDate = Past,
|
||||
ScheduledDate = Future,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled
|
||||
};
|
||||
|
||||
Assert.True(WorkOrderDerivedFields.IsPastDue(wo, Today));
|
||||
Assert.True(WorkOrderDerivedFields.IsPastDue(wo.DueDate, wo.LifecycleStatus));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPastDue_WhenDueFutureAndSchedulePast_ReturnsFalse()
|
||||
public void IsPastDue_WhenSchedulePastAndDueFuture_ReturnsTrue()
|
||||
{
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
|
|
@ -35,16 +21,31 @@ public class WorkOrderDerivedFieldsTests
|
|||
LifecycleStatus = LifecycleStatus.Scheduled
|
||||
};
|
||||
|
||||
Assert.False(WorkOrderDerivedFields.IsPastDue(wo, Today));
|
||||
Assert.True(WorkOrderDerivedFields.IsPastDue(wo, Today));
|
||||
Assert.True(WorkOrderDerivedFields.IsPastDue(wo.ScheduledDate, wo.LifecycleStatus));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPastDue_WhenDueDateNull_ReturnsFalse()
|
||||
public void IsPastDue_WhenScheduleFutureAndDuePast_ReturnsFalse()
|
||||
{
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
DueDate = null,
|
||||
ScheduledDate = Past,
|
||||
DueDate = Past,
|
||||
ScheduledDate = Future,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled
|
||||
};
|
||||
|
||||
Assert.False(WorkOrderDerivedFields.IsPastDue(wo, Today));
|
||||
Assert.False(WorkOrderDerivedFields.IsPastDue(wo.ScheduledDate, wo.LifecycleStatus));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPastDue_WhenScheduledDateNull_ReturnsFalse()
|
||||
{
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
DueDate = Past,
|
||||
ScheduledDate = null,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled
|
||||
};
|
||||
|
||||
|
|
@ -53,12 +54,12 @@ public class WorkOrderDerivedFieldsTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPastDue_WhenDueEqualsToday_ReturnsFalse()
|
||||
public void IsPastDue_WhenScheduleEqualsToday_ReturnsFalse()
|
||||
{
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
DueDate = TodayDate,
|
||||
ScheduledDate = Past,
|
||||
DueDate = Past,
|
||||
ScheduledDate = TodayDate,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -111,8 +111,8 @@ public class WorkOrderDetailServiceTests
|
|||
Id = 1,
|
||||
InternalWONumber = "00000000001",
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
DueDate = DateTime.UtcNow.AddDays(-2),
|
||||
ScheduledDate = DateTime.UtcNow.AddDays(3),
|
||||
DueDate = DateTime.UtcNow.AddDays(3),
|
||||
ScheduledDate = DateTime.UtcNow.AddDays(-2),
|
||||
Trade = "HVAC PM",
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
DocStatus = DocStatus.No,
|
||||
|
|
|
|||
|
|
@ -310,8 +310,8 @@ public class WorkOrderWeekRolledTests
|
|||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
DueDate = pastDate,
|
||||
ScheduledDate = DateTime.UtcNow.Date.AddDays(5),
|
||||
DueDate = DateTime.UtcNow.Date.AddDays(5),
|
||||
ScheduledDate = pastDate,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
OperationalFlags = OperationalFlags.None
|
||||
});
|
||||
|
|
@ -335,8 +335,8 @@ public class WorkOrderWeekRolledTests
|
|||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
DueDate = futureDate,
|
||||
ScheduledDate = DateTime.UtcNow.Date.AddDays(-3),
|
||||
DueDate = DateTime.UtcNow.Date.AddDays(-3),
|
||||
ScheduledDate = futureDate,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
OperationalFlags = OperationalFlags.PastDue
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue