feat(work-orders): add Overdue work order type

Overdue (8) is a dispatcher-assigned type, separate from the derived
past-due overlay. It takes no severity, resolves services and
completion-doc templates from the PM catalog, and filters as its own
type. The past-due flag now narrows a type filter instead of widening it,
and the dashboard breakdown partitions by stored type.
This commit is contained in:
Alexandre Brandizzi 2026-09-25 10:57:34 -03:00
parent 06eae2fb02
commit b545d4a4fe
17 changed files with 461 additions and 66 deletions

View file

@ -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);

View file

@ -35,6 +35,8 @@ namespace Data.SeaHavenIndustries.Enums
Inspection = 5,
Reactive = 6,
AddOn = 7,
/// <summary>Client never responded after the due date passed. Distinct from the derived past-due overlay.</summary>
Overdue = 8,
Other = 99
}

View file

@ -58,9 +58,9 @@ namespace SeaHaven.DataServices.Helpers
}
/// <summary>
/// Filters by real <paramref name="types"/> (including <see cref="WorkOrderType.Other"/>)
/// and/or past-due rows when <paramref name="overdue"/> is true.
/// Types and overdue are combined with OR when both are present.
/// Filters by stored <paramref name="types"/> (including <see cref="WorkOrderType.Overdue"/> and
/// <see cref="WorkOrderType.Other"/>) and/or past-due rows when <paramref name="overdue"/> is true.
/// Past due is a status overlay, so types and overdue are combined with AND when both are present.
/// </summary>
public static IQueryable<WorkOrder> ApplyTypeAndOverdueFilter(
IQueryable<WorkOrder> 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 =>

View file

@ -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))

View file

@ -10,7 +10,7 @@ namespace SeaHaven.Services.DTOs
public List<string>? Dispatchers { get; set; }
public bool MyWorkOrders { get; set; }
public List<WorkOrderType>? Types { get; set; }
/// <summary>When true, include past-due (isPastDue) rows. Combined with Types via OR.</summary>
/// <summary>When true, keep only past-due (isPastDue) rows. Combined with Types via AND.</summary>
public bool Overdue { get; set; }
public string? Search { get; set; }
}

View file

@ -98,7 +98,7 @@ namespace SeaHaven.Services.DTOs
public List<string>? Sites { get; set; }
public List<string>? Regions { get; set; }
public List<WorkOrderType>? Types { get; set; }
/// <summary>When true, include past-due (isPastDue) rows. Combined with Types via OR.</summary>
/// <summary>When true, keep only past-due (isPastDue) rows. Combined with Types via AND.</summary>
public bool Overdue { get; set; }
public List<string>? Dispatchers { get; set; }
public List<LifecycleStatus>? Statuses { get; set; }

View file

@ -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;

View file

@ -0,0 +1,17 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.Helpers
{
/// <summary>
/// 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.
/// </summary>
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;
}
}

View file

@ -4,7 +4,7 @@ namespace SeaHaven.Services.Helpers
{
public static class WorkOrderTypeMapper
{
/// <summary>Board FE types. Overdue is not a type — use <see cref="WorkOrderDerivedFields.IsPastDue(System.DateTime?, LifecycleStatus?, System.DateTime?)"/>.</summary>
/// <summary>Board FE types. Overdue is a dispatcher-assigned type; past due is derived — see <see cref="WorkOrderDerivedFields.IsPastDue(System.DateTime?, LifecycleStatus?, System.DateTime?)"/>.</summary>
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",

View file

@ -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);

View file

@ -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}");

View file

@ -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

View file

@ -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);

View file

@ -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]

View file

@ -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<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WorkOrderType,
Value = "Overdue",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
}, "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]

View file

@ -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<ApplicationDbContext>()
.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<ServiceWorkOrderType>
{
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<WorkOrderBoardValidationException>(() =>
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> { 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> { 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<DashboardWorkOrder>
{
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));
}

View file

@ -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));