From b545d4a4fe6a735b978b9204fcb272398067e1c6 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 10:57:34 -0300 Subject: [PATCH] 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. --- .../DashboardServiceTests.cs | 4 +- .../Enums/WorkOrderEnums.cs | 2 + .../Helpers/WorkOrderBoardQueryFilters.cs | 10 +- .../Helpers/WorkOrderBoardSearchFilter.cs | 1 + SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs | 2 +- .../DTOs/WorkOrderBoardRequestDTOs.cs | 2 +- SeaHaven.Services/Helpers/DashboardMetrics.cs | 25 +- .../Helpers/WorkOrderCatalogType.cs | 17 + .../Helpers/WorkOrderTypeMapper.cs | 5 +- .../WorkOrderBoardCreateService.cs | 5 +- .../WorkOrderBoardUpdateService.cs | 13 +- .../WorkOrderCompletionService.cs | 4 +- .../Implementation/WorkOrderDetailService.cs | 2 +- .../WorkOrderBoardSearchTests.cs | 13 +- .../WorkOrderBoardUpdateServiceTests.cs | 20 +- .../WorkOrderOverdueTypeTests.cs | 393 ++++++++++++++++++ .../WorkOrderTypeMapperTests.cs | 9 +- 17 files changed, 461 insertions(+), 66 deletions(-) create mode 100644 SeaHaven.Services/Helpers/WorkOrderCatalogType.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderOverdueTypeTests.cs 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));