diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs
index 2cc73ee..7358083 100644
--- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs
+++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs
@@ -213,10 +213,10 @@ public class DashboardServiceTests
AccountUser(1), query, CancellationToken.None);
stats.Total.Should().Be(4);
- stats.Breakdown.Overdue.Should().Be(1);
+ stats.Breakdown.Overdue.Should().Be(0);
stats.Breakdown.Other.Should().Be(0);
stats.Breakdown.PM.Should().Be(2);
- stats.Breakdown.Emergency.Should().Be(0);
+ stats.Breakdown.Emergency.Should().Be(1);
stats.Breakdown.Reactive.Should().Be(1);
(stats.Breakdown.PM + stats.Breakdown.Emergency + stats.Breakdown.Reactive
+ stats.Breakdown.Overdue + stats.Breakdown.Other).Should().Be(stats.Total);
diff --git a/Data.SeaHavenIndustries/Enums/WorkOrderEnums.cs b/Data.SeaHavenIndustries/Enums/WorkOrderEnums.cs
index f50824f..11ada94 100644
--- a/Data.SeaHavenIndustries/Enums/WorkOrderEnums.cs
+++ b/Data.SeaHavenIndustries/Enums/WorkOrderEnums.cs
@@ -35,6 +35,8 @@ namespace Data.SeaHavenIndustries.Enums
Inspection = 5,
Reactive = 6,
AddOn = 7,
+ /// Client never responded after the due date passed. Distinct from the derived past-due overlay.
+ Overdue = 8,
Other = 99
}
diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs
index 583aaa2..6557db2 100644
--- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs
+++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs
@@ -58,9 +58,9 @@ namespace SeaHaven.DataServices.Helpers
}
///
- /// Filters by real (including )
- /// and/or past-due rows when is true.
- /// Types and overdue are combined with OR when both are present.
+ /// Filters by stored (including and
+ /// ) and/or past-due rows when is true.
+ /// Past due is a status overlay, so types and overdue are combined with AND when both are present.
///
public static IQueryable ApplyTypeAndOverdueFilter(
IQueryable query,
@@ -94,8 +94,8 @@ namespace SeaHaven.DataServices.Helpers
&& w.ScheduledDate.Value.Date < today
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled)
- || (w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value))
- || (includeAddOnFlag && w.IsAddOn));
+ && ((w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value))
+ || (includeAddOnFlag && w.IsAddOn)));
}
return query.Where(w =>
diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs
index 441802b..5232121 100644
--- a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs
+++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs
@@ -57,6 +57,7 @@ namespace SeaHaven.DataServices.Helpers
|| (w.WorkOrderType == WorkOrderType.Project && "project".Contains(s))
|| (w.WorkOrderType == WorkOrderType.Inspection && "inspection".Contains(s))
|| (w.WorkOrderType == WorkOrderType.Reactive && "reactive".Contains(s))
+ || (w.WorkOrderType == WorkOrderType.Overdue && "overdue".Contains(s))
|| (w.WorkOrderType == WorkOrderType.AddOn && ("add-on".Contains(s) || "addon".Contains(s)))
|| (w.IsAddOn && ("add-on".Contains(s) || "addon".Contains(s)))
|| (w.WorkOrderType == WorkOrderType.Other && "other".Contains(s))
diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs
index b6ca553..44938d8 100644
--- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs
+++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs
@@ -10,7 +10,7 @@ namespace SeaHaven.Services.DTOs
public List? Dispatchers { get; set; }
public bool MyWorkOrders { get; set; }
public List? Types { get; set; }
- /// When true, include past-due (isPastDue) rows. Combined with Types via OR.
+ /// When true, keep only past-due (isPastDue) rows. Combined with Types via AND.
public bool Overdue { get; set; }
public string? Search { get; set; }
}
diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs
index 38b4c24..de8f90d 100644
--- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs
+++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs
@@ -98,7 +98,7 @@ namespace SeaHaven.Services.DTOs
public List? Sites { get; set; }
public List? Regions { get; set; }
public List? Types { get; set; }
- /// When true, include past-due (isPastDue) rows. Combined with Types via OR.
+ /// When true, keep only past-due (isPastDue) rows. Combined with Types via AND.
public bool Overdue { get; set; }
public List? Dispatchers { get; set; }
public List? Statuses { get; set; }
diff --git a/SeaHaven.Services/Helpers/DashboardMetrics.cs b/SeaHaven.Services/Helpers/DashboardMetrics.cs
index 885ac7d..b75101d 100644
--- a/SeaHaven.Services/Helpers/DashboardMetrics.cs
+++ b/SeaHaven.Services/Helpers/DashboardMetrics.cs
@@ -21,9 +21,7 @@ public static class DashboardMetrics
?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus);
var completed = status == LifecycleStatus.Completed;
var canceled = status == LifecycleStatus.Canceled;
- var pastDue = IsPastDue(workOrder, status, today);
-
- AddBreakdownCount(breakdown, workOrder.WorkOrderType, pastDue);
+ AddBreakdownCount(breakdown, workOrder.WorkOrderType);
var due = completed || (workOrder.ScheduledDate is DateTime scheduled
&& DateOnly.FromDateTime(scheduled.Date) <= today);
@@ -81,25 +79,11 @@ public static class DashboardMetrics
.ThenBy(bucket => bucket.Status, StringComparer.OrdinalIgnoreCase)
.ToList();
- private static bool IsPastDue(
- DashboardWorkOrder workOrder,
- LifecycleStatus? status,
- DateOnly today)
- => workOrder.ScheduledDate is DateTime scheduled
- && DateOnly.FromDateTime(scheduled.Date) < today
- && status is not LifecycleStatus.Completed and not LifecycleStatus.Canceled;
-
+ // Partition by the stored type only; past due is an overlay on any type, not a bucket.
private static void AddBreakdownCount(
DashboardBreakdownDTO breakdown,
- WorkOrderType? type,
- bool pastDue)
+ WorkOrderType? type)
{
- if (pastDue)
- {
- breakdown.Overdue++;
- return;
- }
-
switch (type)
{
case WorkOrderType.PM:
@@ -111,6 +95,9 @@ public static class DashboardMetrics
case WorkOrderType.Reactive:
breakdown.Reactive++;
break;
+ case WorkOrderType.Overdue:
+ breakdown.Overdue++;
+ break;
default:
breakdown.Other++;
break;
diff --git a/SeaHaven.Services/Helpers/WorkOrderCatalogType.cs b/SeaHaven.Services/Helpers/WorkOrderCatalogType.cs
new file mode 100644
index 0000000..41daa0e
--- /dev/null
+++ b/SeaHaven.Services/Helpers/WorkOrderCatalogType.cs
@@ -0,0 +1,17 @@
+using Data.SeaHavenIndustries.Enums;
+
+namespace SeaHaven.Services.Helpers
+{
+ ///
+ /// The services registry and completion-doc templates are keyed on PM, Reactive and Emergency.
+ /// An Overdue work order is PM work the client never confirmed, so it uses the PM catalog.
+ ///
+ public static class WorkOrderCatalogType
+ {
+ public static WorkOrderType For(WorkOrderType type)
+ => type == WorkOrderType.Overdue ? WorkOrderType.PM : type;
+
+ public static WorkOrderType? For(WorkOrderType? type)
+ => type.HasValue ? For(type.Value) : null;
+ }
+}
diff --git a/SeaHaven.Services/Helpers/WorkOrderTypeMapper.cs b/SeaHaven.Services/Helpers/WorkOrderTypeMapper.cs
index 4eda69f..d12f555 100644
--- a/SeaHaven.Services/Helpers/WorkOrderTypeMapper.cs
+++ b/SeaHaven.Services/Helpers/WorkOrderTypeMapper.cs
@@ -4,7 +4,7 @@ namespace SeaHaven.Services.Helpers
{
public static class WorkOrderTypeMapper
{
- /// Board FE types. Overdue is not a type — use .
+ /// Board FE types. Overdue is a dispatcher-assigned type; past due is derived — see .
public static bool TryParseFeLabel(string? label, out WorkOrderType type)
{
type = default;
@@ -20,7 +20,7 @@ namespace SeaHaven.Services.Helpers
"project" => Set(WorkOrderType.Project, out type),
"inspection" => Set(WorkOrderType.Inspection, out type),
"other" => Set(WorkOrderType.Other, out type),
- "overdue" => false,
+ "overdue" => Set(WorkOrderType.Overdue, out type),
_ => Enum.TryParse(label, true, out type)
};
}
@@ -30,6 +30,7 @@ namespace SeaHaven.Services.Helpers
WorkOrderType.PM => "PM",
WorkOrderType.Reactive => "Reactive",
WorkOrderType.Emergency => "Emergency",
+ WorkOrderType.Overdue => "Overdue",
WorkOrderType.AddOn => "Add-On",
WorkOrderType.PO => "PO",
WorkOrderType.Project => "Project",
diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs
index 149a47b..6218c9b 100644
--- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs
@@ -298,7 +298,8 @@ namespace SeaHaven.Services.Implementation
return null;
}
- if (!ServicesRegistryService.AllowedWorkOrderTypes.Contains(workOrderType))
+ var catalogType = WorkOrderCatalogType.For(workOrderType);
+ if (!ServicesRegistryService.AllowedWorkOrderTypes.Contains(catalogType))
{
if (serviceId.HasValue)
throw new WorkOrderBoardValidationException(
@@ -307,7 +308,7 @@ namespace SeaHaven.Services.Implementation
return null;
}
- var services = await _servicesRegistryService.GetAllAsync(true, workOrderType, cancellationToken);
+ var services = await _servicesRegistryService.GetAllAsync(true, catalogType, cancellationToken);
if (serviceId.HasValue)
{
var selected = services.FirstOrDefault(service => service.Id == serviceId.Value);
diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
index 5a04011..fcd8721 100644
--- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
@@ -370,11 +370,6 @@ namespace SeaHaven.Services.Implementation
if (string.IsNullOrWhiteSpace(value))
throw new WorkOrderBoardValidationException("InvalidValue", "workOrderType value is required.");
- if (string.Equals(value.Trim(), "Overdue", StringComparison.OrdinalIgnoreCase))
- throw new WorkOrderBoardValidationException(
- "InvalidValue",
- "Overdue is not a work order type; it is derived from dueDate (isPastDue).");
-
if (!WorkOrderTypeMapper.TryParseFeLabel(value, out var parsed))
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}");
@@ -426,10 +421,11 @@ namespace SeaHaven.Services.Implementation
throw new WorkOrderBoardValidationException("ServiceInvalid", "serviceId must be a positive integer.");
var service = await _servicesRegistryService.GetByIdAsync(serviceId, cancellationToken);
- if (!ServicesRegistryService.AllowedWorkOrderTypes.Contains(workOrder.WorkOrderType ?? WorkOrderType.PM)
+ var catalogType = WorkOrderCatalogType.For(workOrder.WorkOrderType ?? WorkOrderType.PM);
+ if (!ServicesRegistryService.AllowedWorkOrderTypes.Contains(catalogType)
|| service == null
|| !service.IsActive
- || !service.SupportedWorkOrderTypes.Contains(workOrder.WorkOrderType ?? WorkOrderType.PM))
+ || !service.SupportedWorkOrderTypes.Contains(catalogType))
{
throw new WorkOrderBoardValidationException(
"ServiceInvalid",
@@ -458,7 +454,7 @@ namespace SeaHaven.Services.Implementation
throw new WorkOrderBoardValidationException("ServiceTypeInvalid", "Service registry is unavailable.");
var service = await _servicesRegistryService.GetByIdAsync(serviceId, cancellationToken);
- if (service == null || !service.SupportedWorkOrderTypes.Contains(workOrderType))
+ if (service == null || !service.SupportedWorkOrderTypes.Contains(WorkOrderCatalogType.For(workOrderType)))
throw new WorkOrderBoardValidationException(
"ServiceTypeInvalid",
"The selected service does not support this work order type.");
@@ -467,7 +463,6 @@ namespace SeaHaven.Services.Implementation
private static WorkOrderType ParseWorkOrderType(string? value)
{
if (string.IsNullOrWhiteSpace(value)
- || string.Equals(value.Trim(), "Overdue", StringComparison.OrdinalIgnoreCase)
|| !WorkOrderTypeMapper.TryParseFeLabel(value, out var parsed))
{
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}");
diff --git a/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs b/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs
index 534cd6b..f2f0895 100644
--- a/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs
@@ -105,7 +105,9 @@ namespace SeaHaven.Services.Implementation
await _completionData.SaveAsync(cancellationToken);
var extended = await _detailData.GetExtendedFieldsAsync(workOrderId);
- var template = await _templateData.ResolveForWorkOrderAsync(workOrder.Trade, workOrder.WorkOrderType);
+ var template = await _templateData.ResolveForWorkOrderAsync(
+ workOrder.Trade,
+ WorkOrderCatalogType.For(workOrder.WorkOrderType));
var signoffs = await _detailData.GetDispatchSignoffsAsync(workOrderId);
return new WorkOrderCompletionDto
diff --git a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs
index 9e77a69..432996d 100644
--- a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs
@@ -48,7 +48,7 @@ namespace SeaHaven.Services.Implementation
var template = await _templateData.ResolveForWorkOrderAsync(
extended?.Trade ?? boardRow.Pm,
- boardRow.WorkOrderType);
+ WorkOrderCatalogType.For(boardRow.WorkOrderType));
var authorIds = WorkOrderCommentProjection.CollectAuthorUserIds(comments);
var authorNames = await _userDataService.GetDisplayNamesByIdsAsync(authorIds);
diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs
index 6a1f2e3..1e2ce22 100644
--- a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs
@@ -601,7 +601,7 @@ public class WorkOrderAdvancedSearchServiceTests
}
[Fact]
- public async Task SearchAsync_OtherPlusOverdue_ReturnsUnion()
+ public async Task SearchAsync_OtherPlusOverdue_ReturnsIntersection()
{
await using var context = CreateContext();
var today = DateTime.UtcNow.Date;
@@ -610,7 +610,7 @@ public class WorkOrderAdvancedSearchServiceTests
new WorkOrder
{
Id = 1,
- WorkOrderType = WorkOrderType.PM,
+ WorkOrderType = WorkOrderType.Other,
DueDate = today.AddDays(5),
ScheduledDate = today.AddDays(-3),
LifecycleStatus = LifecycleStatus.Scheduled,
@@ -648,10 +648,11 @@ public class WorkOrderAdvancedSearchServiceTests
Overdue = true
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
- Assert.Equal(2, result.TotalCount);
- Assert.Contains(result.Items, i => i.Id == 1 && i.IsPastDue);
- Assert.Contains(result.Items, i => i.Id == 2 && i.WorkOrderType == WorkOrderType.Other);
- Assert.DoesNotContain(result.Items, i => i.Id == 3);
+ // Past due is a status overlay: it narrows the type filter instead of widening it.
+ var row = Assert.Single(result.Items);
+ Assert.Equal(1, row.Id);
+ Assert.True(row.IsPastDue);
+ Assert.Equal(WorkOrderType.Other, row.WorkOrderType);
}
[Fact]
diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs
index b2c715e..fcf096e 100644
--- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs
@@ -581,7 +581,7 @@ public class WorkOrderBoardUpdateServiceTests
}
[Fact]
- public async Task PatchField_RejectsOverdueAsWorkOrderType()
+ public async Task PatchField_AcceptsOverdueAsWorkOrderType()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
@@ -594,17 +594,15 @@ public class WorkOrderBoardUpdateServiceTests
context.workOrders.Add(wo);
await context.SaveChangesAsync();
- var ex = await Assert.ThrowsAsync(() =>
- service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
- {
- Field = WorkOrderBoardFieldNames.WorkOrderType,
- Value = "Overdue",
- WorkOrderVersion = ToVersion(wo)
- }, "actor-1"));
+ var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
+ {
+ Field = WorkOrderBoardFieldNames.WorkOrderType,
+ Value = "Overdue",
+ WorkOrderVersion = ToVersion(wo)
+ }, "actor-1");
- Assert.Equal("InvalidValue", ex.Code);
- Assert.Contains("isPastDue", ex.Message, StringComparison.Ordinal);
- Assert.Contains("dueDate", ex.Message, StringComparison.OrdinalIgnoreCase);
+ Assert.Equal(WorkOrderType.Overdue, result.WorkOrderType);
+ Assert.Equal(WorkOrderType.Overdue, (await context.workOrders.SingleAsync(w => w.Id == 1)).WorkOrderType);
}
[Fact]
diff --git a/SeaHavenIndustries.Tests/WorkOrderOverdueTypeTests.cs b/SeaHavenIndustries.Tests/WorkOrderOverdueTypeTests.cs
new file mode 100644
index 0000000..0b3657b
--- /dev/null
+++ b/SeaHavenIndustries.Tests/WorkOrderOverdueTypeTests.cs
@@ -0,0 +1,393 @@
+using Data.SeaHavenIndustries;
+using Data.SeaHavenIndustries.Enums;
+using Microsoft.EntityFrameworkCore;
+using SeaHaven.DataServices.Implementation;
+using SeaHaven.DataServices.Interfaces;
+using SeaHaven.Services.DTOs;
+using SeaHaven.Services.Exceptions;
+using SeaHaven.Services.Helpers;
+using SeaHaven.Services.Implementation;
+using SeaHaven.Services.Validation;
+
+namespace SeaHavenIndustries.Tests;
+
+// Overdue is a dispatcher-assigned work-order type: no severity, PM catalog
+// (services and completion-doc templates), and distinct from the derived
+// past-due overlay.
+public class WorkOrderOverdueTypeTests
+{
+ private const int PmServiceId = 10;
+ private const int ReactiveServiceId = 11;
+
+ private static ApplicationDbContext NewContext()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(Guid.NewGuid().ToString())
+ .Options;
+ return new ApplicationDbContext(options);
+ }
+
+ private static void SeedServices(ApplicationDbContext context)
+ {
+ context.Services.AddRange(
+ NewService(PmServiceId, "Backflow Preventers", WorkOrderType.PM),
+ NewService(ReactiveServiceId, "Leak Repair", WorkOrderType.Reactive));
+ }
+
+ private static Service NewService(int id, string name, WorkOrderType type) => new()
+ {
+ Id = id,
+ Name = name,
+ NormalizedName = name.ToUpperInvariant(),
+ Trade = "Plumbing & Water Systems",
+ IconKey = "plumbing-water-systems",
+ IsActive = true,
+ SupportedWorkOrderTypes = new List
+ {
+ new() { WorkOrderType = type }
+ }
+ };
+
+ private static WorkOrderBoardCreateService NewCreateService(ApplicationDbContext context)
+ {
+ WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
+ var boardData = new WorkOrderBoardDataService(context);
+ var resolver = WorkOrderAccountTestHelpers.Resolver(context);
+ var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
+ return new WorkOrderBoardCreateService(
+ boardData,
+ new WorkOrderBoardMutationDataService(context),
+ new WorkOrderBoardService(boardData, resolver),
+ new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks),
+ new WorkOrderBoardCreateValidation(),
+ resolver,
+ new WorkOrderPocDataService(context),
+ new ServicesRegistryService(new ServicesRegistryDataService(context)));
+ }
+
+ private static WorkOrderBoardUpdateService NewUpdateService(ApplicationDbContext context)
+ {
+ var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
+ return new WorkOrderBoardUpdateService(
+ new WorkOrderBoardDataService(context),
+ new WorkOrderBoardMutationDataService(context),
+ new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks),
+ new ServicesRegistryService(new ServicesRegistryDataService(context)));
+ }
+
+ private static string ToVersion(WorkOrder workOrder)
+ => Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 });
+
+ [Fact]
+ public async Task Create_Overdue_WithoutSeverity_PersistsOverdueType()
+ {
+ await using var context = NewContext();
+ var service = NewCreateService(context);
+
+ var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
+ {
+ WorkOrderType = WorkOrderType.Overdue,
+ SiteCode = "BK5",
+ LocationId = 1
+ }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
+
+ Assert.Equal(WorkOrderType.Overdue, result.WorkOrderType);
+ Assert.Null(result.Severity);
+ var persisted = await context.workOrders.SingleAsync();
+ Assert.Equal(WorkOrderType.Overdue, persisted.WorkOrderType);
+ Assert.Equal(8, (int)persisted.WorkOrderType!.Value);
+ }
+
+ [Fact]
+ public async Task Create_Overdue_DropsSuppliedSeverity()
+ {
+ await using var context = NewContext();
+ var service = NewCreateService(context);
+
+ var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
+ {
+ WorkOrderType = WorkOrderType.Overdue,
+ SiteCode = "BK5",
+ LocationId = 1,
+ Severity = 2
+ }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
+
+ Assert.Null(result.Severity);
+ Assert.Null((await context.workOrders.SingleAsync()).Severity);
+ }
+
+ [Fact]
+ public async Task Create_Overdue_AcceptsPmCatalogService()
+ {
+ await using var context = NewContext();
+ SeedServices(context);
+ var service = NewCreateService(context);
+
+ var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
+ {
+ WorkOrderType = WorkOrderType.Overdue,
+ SiteCode = "BK5",
+ LocationId = 1,
+ ServiceId = PmServiceId
+ }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
+
+ Assert.Equal(PmServiceId, result.ServiceId);
+ Assert.Equal("Backflow Preventers", (await context.workOrders.SingleAsync()).ServiceNameSnapshot);
+ }
+
+ [Fact]
+ public async Task Create_Overdue_RejectsReactiveOnlyService()
+ {
+ await using var context = NewContext();
+ SeedServices(context);
+ var service = NewCreateService(context);
+
+ var ex = await Assert.ThrowsAsync(() =>
+ service.CreateAsync(new WorkOrderBoardCreateRequestDto
+ {
+ WorkOrderType = WorkOrderType.Overdue,
+ SiteCode = "BK5",
+ LocationId = 1,
+ ServiceId = ReactiveServiceId
+ }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
+
+ Assert.Equal("ServiceInvalid", ex.Code);
+ }
+
+ [Fact]
+ public async Task PatchType_ToOverdue_ClearsSeverity()
+ {
+ await using var context = NewContext();
+ var wo = new WorkOrder
+ {
+ Id = 1,
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ WorkOrderType = WorkOrderType.Reactive,
+ Severity = "3",
+ RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
+ };
+ context.workOrders.Add(wo);
+ await context.SaveChangesAsync();
+
+ var result = await NewUpdateService(context).PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
+ {
+ Field = WorkOrderBoardFieldNames.WorkOrderType,
+ Value = "Overdue",
+ WorkOrderVersion = ToVersion(wo)
+ }, "actor-1");
+
+ Assert.Equal(WorkOrderType.Overdue, result.WorkOrderType);
+ Assert.Null(result.Severity);
+ var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
+ Assert.Equal(WorkOrderType.Overdue, persisted.WorkOrderType);
+ Assert.Null(persisted.Severity);
+ }
+
+ [Fact]
+ public async Task PatchType_PmWithPmService_ToOverdue_KeepsService()
+ {
+ await using var context = NewContext();
+ SeedServices(context);
+ var wo = new WorkOrder
+ {
+ Id = 1,
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ WorkOrderType = WorkOrderType.PM,
+ ServiceId = PmServiceId,
+ RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
+ };
+ context.workOrders.Add(wo);
+ await context.SaveChangesAsync();
+
+ var result = await NewUpdateService(context).PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
+ {
+ Field = WorkOrderBoardFieldNames.WorkOrderType,
+ Value = "Overdue",
+ WorkOrderVersion = ToVersion(wo)
+ }, "actor-1");
+
+ Assert.Equal(WorkOrderType.Overdue, result.WorkOrderType);
+ Assert.Equal(PmServiceId, (await context.workOrders.SingleAsync(w => w.Id == 1)).ServiceId);
+ }
+
+ [Fact]
+ public async Task PatchServiceId_OnOverdue_AcceptsPmCatalogService()
+ {
+ await using var context = NewContext();
+ SeedServices(context);
+ var wo = new WorkOrder
+ {
+ Id = 1,
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ WorkOrderType = WorkOrderType.Overdue,
+ RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
+ };
+ context.workOrders.Add(wo);
+ await context.SaveChangesAsync();
+
+ await NewUpdateService(context).PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
+ {
+ Field = WorkOrderBoardFieldNames.ServiceId,
+ Value = PmServiceId.ToString(),
+ WorkOrderVersion = ToVersion(wo)
+ }, "actor-1");
+
+ var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
+ Assert.Equal(PmServiceId, persisted.ServiceId);
+ Assert.Equal("Backflow Preventers", persisted.Trade);
+ }
+
+ [Fact]
+ public async Task GetDetail_Overdue_ResolvesPmCompletionTemplate()
+ {
+ await using var context = NewContext();
+ context.workOrders.Add(new WorkOrder
+ {
+ Id = 1,
+ InternalWONumber = "10000000001",
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ WorkOrderType = WorkOrderType.Overdue,
+ ScheduledDate = DateTime.UtcNow.AddDays(1),
+ DocStatus = DocStatus.No,
+ RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
+ });
+ context.CompletionDocTemplates.Add(new CompletionDocTemplate
+ {
+ Name = "PM Completion",
+ ServiceKey = "pm-generic",
+ WorkOrderType = WorkOrderType.PM,
+ TemplateUrl = "https://example.com/pm.pdf",
+ IsActive = true
+ });
+ await context.SaveChangesAsync();
+
+ var boardData = new WorkOrderBoardDataService(context);
+ var resolver = WorkOrderAccountTestHelpers.Resolver(context);
+ var service = new WorkOrderDetailService(
+ new WorkOrderBoardService(boardData, resolver),
+ new WorkOrderDetailDataService(context),
+ new CompletionDocTemplateDataService(context),
+ new UserDataService(context),
+ resolver);
+
+ var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
+
+ Assert.NotNull(detail);
+ Assert.Equal(DocStatus.No, detail!.Completion.DocStatus);
+ Assert.NotNull(detail.Completion.Template);
+ Assert.Equal("PM Completion", detail.Completion.Template!.Name);
+ }
+
+ [Fact]
+ public async Task Search_TypeOverdue_ReturnsOverdueTypeOnly_NotPastDueRows()
+ {
+ await using var context = NewContext();
+ var today = DateTime.UtcNow.Date;
+ context.workOrders.AddRange(
+ new WorkOrder
+ {
+ Id = 1,
+ WorkOrderType = WorkOrderType.Overdue,
+ ScheduledDate = today.AddDays(2),
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ InternalWONumber = "10000000001"
+ },
+ new WorkOrder
+ {
+ Id = 2,
+ WorkOrderType = WorkOrderType.PM,
+ ScheduledDate = today.AddDays(-3),
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ InternalWONumber = "10000000002"
+ },
+ new WorkOrder
+ {
+ Id = 3,
+ WorkOrderType = WorkOrderType.Overdue,
+ ScheduledDate = today.AddDays(-2),
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ InternalWONumber = "10000000003"
+ });
+ await context.SaveChangesAsync();
+
+ var result = await NewSearchService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto
+ {
+ DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
+ DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)),
+ DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
+ Types = new List { WorkOrderType.Overdue }
+ }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
+
+ Assert.Equal(new[] { 1, 3 }, result.Items.Select(i => i.Id).OrderBy(id => id));
+ Assert.All(result.Items, i => Assert.Equal(WorkOrderType.Overdue, i.WorkOrderType));
+ }
+
+ [Fact]
+ public async Task Search_TypeOverdueWithPastDue_ReturnsIntersection()
+ {
+ await using var context = NewContext();
+ var today = DateTime.UtcNow.Date;
+ context.workOrders.AddRange(
+ new WorkOrder
+ {
+ Id = 1,
+ WorkOrderType = WorkOrderType.Overdue,
+ ScheduledDate = today.AddDays(2),
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ InternalWONumber = "10000000001"
+ },
+ new WorkOrder
+ {
+ Id = 2,
+ WorkOrderType = WorkOrderType.PM,
+ ScheduledDate = today.AddDays(-3),
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ InternalWONumber = "10000000002"
+ },
+ new WorkOrder
+ {
+ Id = 3,
+ WorkOrderType = WorkOrderType.Overdue,
+ ScheduledDate = today.AddDays(-2),
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ InternalWONumber = "10000000003"
+ });
+ await context.SaveChangesAsync();
+
+ var result = await NewSearchService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto
+ {
+ DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
+ DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)),
+ DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
+ Types = new List { WorkOrderType.Overdue },
+ Overdue = true
+ }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
+
+ var row = Assert.Single(result.Items);
+ Assert.Equal(3, row.Id);
+ Assert.True(row.IsPastDue);
+ }
+
+ [Fact]
+ public void DashboardBreakdown_CountsByTypeOnly()
+ {
+ var today = new DateOnly(2026, 9, 25);
+ var pastDue = today.AddDays(-3).ToDateTime(TimeOnly.MinValue);
+ var future = today.AddDays(3).ToDateTime(TimeOnly.MinValue);
+ var rows = new List
+ {
+ new(1, null, WorkOrderType.Overdue, LifecycleStatus.Scheduled, null, future, null, null),
+ new(2, null, WorkOrderType.PM, LifecycleStatus.Scheduled, null, pastDue, null, null),
+ new(3, null, WorkOrderType.Overdue, LifecycleStatus.Scheduled, null, pastDue, null, null),
+ };
+
+ var result = DashboardMetrics.Calculate(rows, today);
+
+ Assert.Equal(2, result.Breakdown.Overdue);
+ Assert.Equal(1, result.Breakdown.PM);
+ Assert.Equal(0, result.Breakdown.Other);
+ }
+
+ private static WorkOrderAdvancedSearchService NewSearchService(ApplicationDbContext context)
+ => new(new WorkOrderAdvancedSearchDataService(context), WorkOrderAccountTestHelpers.Resolver(context));
+}
diff --git a/SeaHavenIndustries.Tests/WorkOrderTypeMapperTests.cs b/SeaHavenIndustries.Tests/WorkOrderTypeMapperTests.cs
index b25bcd5..e56b28c 100644
--- a/SeaHavenIndustries.Tests/WorkOrderTypeMapperTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderTypeMapperTests.cs
@@ -10,21 +10,18 @@ public class WorkOrderTypeMapperTests
[InlineData("Reactive", WorkOrderType.Reactive)]
[InlineData("Emergency", WorkOrderType.Emergency)]
[InlineData("Add-On", WorkOrderType.AddOn)]
+ [InlineData("Overdue", WorkOrderType.Overdue)]
+ [InlineData("overdue", WorkOrderType.Overdue)]
public void TryParseFeLabel_BoardTypes(string label, WorkOrderType expected)
{
Assert.True(WorkOrderTypeMapper.TryParseFeLabel(label, out var type));
Assert.Equal(expected, type);
}
- [Fact]
- public void TryParseFeLabel_Overdue_ReturnsFalse()
- {
- Assert.False(WorkOrderTypeMapper.TryParseFeLabel("Overdue", out _));
- }
-
[Theory]
[InlineData(WorkOrderType.AddOn, "Add-On")]
[InlineData(WorkOrderType.Reactive, "Reactive")]
+ [InlineData(WorkOrderType.Overdue, "Overdue")]
public void ToFeLabel_ReturnsBoardLabel(WorkOrderType type, string expected)
{
Assert.Equal(expected, WorkOrderTypeMapper.ToFeLabel(type));