From 05dd262e807400fbe65fbe32c4194791196eb82b Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 28 Jul 2026 14:01:22 -0300 Subject: [PATCH 1/8] fix(work-orders): derive Past Due from DueDate instead of ScheduledDate Past Due must track the deadline (Due Date), not Schedule On. Keep dueDate and scheduledDate PATCH mutations independent so rescheduling alone does not clear Past Due. --- .../Helpers/WorkOrderBoardQueryFilters.cs | 8 +- .../Helpers/WorkOrderBoardSearchFilter.cs | 4 +- .../WorkOrderDomainJobDataService.cs | 4 +- .../Helpers/WorkOrderDerivedFields.cs | 15 +- .../Implementation/WorkOrderBoardService.cs | 2 +- .../WorkOrderBoardUpdateService.cs | 6 +- .../WorkOrderBoardMutationRulesTests.cs | 2 + .../WorkOrderBoardSearchTests.cs | 18 ++- .../WorkOrderBoardServiceTests.cs | 1 + .../WorkOrderBoardUpdateServiceTests.cs | 142 +++++++++++++++++- .../WorkOrderDerivedFieldsTests.cs | 65 +++++++- .../WorkOrderPhase6Tests.cs | 3 +- .../WorkOrderWeekRolledTests.cs | 11 +- docs/work-orders/phase-1/README.md | 7 +- 14 files changed, 250 insertions(+), 38 deletions(-) diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs index ba41941..f6d7bd6 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -71,8 +71,8 @@ namespace SeaHaven.DataServices.Helpers if (overdue && !hasTypes) { return query.Where(w => - w.ScheduledDate != null - && w.ScheduledDate.Value.Date < today + w.DueDate != null + && w.DueDate.Value.Date < today && w.LifecycleStatus != LifecycleStatus.Completed && w.LifecycleStatus != LifecycleStatus.Canceled); } @@ -80,8 +80,8 @@ namespace SeaHaven.DataServices.Helpers if (overdue) { return query.Where(w => - (w.ScheduledDate != null - && w.ScheduledDate.Value.Date < today + (w.DueDate != null + && w.DueDate.Value.Date < today && w.LifecycleStatus != LifecycleStatus.Completed && w.LifecycleStatus != LifecycleStatus.Canceled) || (w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value))); diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs index d6cf28d..3149f5c 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs @@ -60,8 +60,8 @@ namespace SeaHaven.DataServices.Helpers || (w.WorkOrderType == WorkOrderType.AddOn && ("add-on".Contains(s) || "addon".Contains(s))) || (w.WorkOrderType == WorkOrderType.Other && "other".Contains(s)) || ("overdue".Contains(s) - && w.ScheduledDate != null - && w.ScheduledDate.Value.Date < today + && w.DueDate != null + && w.DueDate.Value.Date < today && w.LifecycleStatus != LifecycleStatus.Completed && w.LifecycleStatus != LifecycleStatus.Canceled) || (w.LifecycleStatus == LifecycleStatus.Incomplete && "incomplete".Contains(s)) diff --git a/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs index 65d98bc..ca15639 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs @@ -120,7 +120,7 @@ namespace SeaHaven.DataServices.Implementation var batch = await _context.workOrders .Where(w => w.IsDeleted != true) .Where(w => w.istemplate != true) - .Where(w => w.ScheduledDate != null) + .Where(w => w.DueDate != null) .OrderBy(w => w.Id) .Skip(skip) .Take(batchSize) @@ -132,7 +132,7 @@ namespace SeaHaven.DataServices.Implementation foreach (var workOrder in batch) { examinedCount++; - var shouldBePastDue = workOrder.ScheduledDate!.Value.Date < today + var shouldBePastDue = workOrder.DueDate!.Value.Date < today && !TerminalStatuses.Contains(workOrder.LifecycleStatus); var hasFlag = workOrder.OperationalFlags.HasFlag(OperationalFlags.PastDue); diff --git a/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs b/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs index b64d16c..9cc77b9 100644 --- a/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs +++ b/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs @@ -6,37 +6,38 @@ namespace SeaHaven.Services.Helpers public static class WorkOrderDerivedFields { /// - /// Past due overlay — never a WorkOrderType. Matches SHOC board design. + /// Past due overlay from DueDate (deadline) — never a WorkOrderType. + /// Independent of ScheduledDate (planned work / Schedule On). /// public static bool IsPastDue(WorkOrder workOrder, DateOnly? asOf = null) { var today = asOf ?? DateOnly.FromDateTime(DateTime.UtcNow); - if (workOrder.ScheduledDate == null) + if (workOrder.DueDate == null) return false; if (LifecycleStatusMapper.IsTerminal(workOrder.LifecycleStatus)) return false; - return DateOnly.FromDateTime(workOrder.ScheduledDate.Value.Date) < today; + return DateOnly.FromDateTime(workOrder.DueDate.Value.Date) < today; } /// - /// Board read-model past-due check. Phase 1 compares UTC calendar dates; + /// Board read-model past-due check against DueDate. Compares UTC calendar dates; /// business/location timezone support is planned for a later phase. /// public static bool IsPastDue( - DateTime? scheduledDate, + DateTime? dueDate, LifecycleStatus? lifecycleStatus, DateTime? utcNow = null) { - if (!scheduledDate.HasValue) + if (!dueDate.HasValue) return false; if (LifecycleStatusMapper.IsTerminal(lifecycleStatus)) return false; var today = (utcNow ?? DateTime.UtcNow).Date; - return scheduledDate.Value.Date < today; + return dueDate.Value.Date < today; } /// diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index 04bf63c..1e840a9 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -94,7 +94,7 @@ namespace SeaHaven.Services.Implementation RescheduleCount = row.RescheduleCount, CarriedOver = row.CarriedOver, WorkOrderType = row.WorkOrderType, - IsPastDue = WorkOrderDerivedFields.IsPastDue(row.ScheduledDate, row.LifecycleStatus, utcNow), + IsPastDue = WorkOrderDerivedFields.IsPastDue(row.DueDate, row.LifecycleStatus, utcNow), SiteCode = row.SiteCode, LocationName = row.LocationName, PocName = row.PocName, diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 3e8c108..e717efc 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -61,9 +61,9 @@ 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.ScheduledDate, workOrder.LifecycleStatus); + var isPastDue = WorkOrderDerivedFields.IsPastDue(workOrder.DueDate, workOrder.LifecycleStatus); if (WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(canonicalField, isPastDue)) - throw new WorkOrderBoardValidationException("PastDueStatusBlocked", "Status cannot be changed while work order is past due. Reschedule first."); + throw new WorkOrderBoardValidationException("PastDueStatusBlocked", "Status cannot be changed while work order is past due. Update due date first."); _mutationData.SetExpectedWorkOrderVersion(workOrder, workOrderVersion); @@ -232,7 +232,7 @@ namespace SeaHaven.Services.Implementation if (string.Equals(value.Trim(), "Overdue", StringComparison.OrdinalIgnoreCase)) throw new WorkOrderBoardValidationException( "InvalidValue", - "Overdue is not a work order type; it is derived from scheduledDate (isPastDue)."); + "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}"); diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs index 933aa80..4cda7f8 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs @@ -22,6 +22,8 @@ public class WorkOrderBoardMutationRulesTests WorkOrderBoardFieldNames.LifecycleStatus, isPastDue: true)); Assert.False(WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue( WorkOrderBoardFieldNames.ScheduledDate, isPastDue: true)); + Assert.False(WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue( + WorkOrderBoardFieldNames.DueDate, isPastDue: true)); } [Fact] diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs index 780c25c..510da77 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs @@ -342,7 +342,8 @@ public class WorkOrderAdvancedSearchServiceTests { Id = 1, WorkOrderType = WorkOrderType.PM, - ScheduledDate = today.AddDays(-3), + DueDate = today.AddDays(-3), + ScheduledDate = today.AddDays(5), LifecycleStatus = LifecycleStatus.Scheduled, InternalWONumber = "10000000001" }, @@ -358,7 +359,8 @@ public class WorkOrderAdvancedSearchServiceTests { Id = 3, WorkOrderType = WorkOrderType.Reactive, - ScheduledDate = today.AddDays(1), + DueDate = today.AddDays(1), + ScheduledDate = today.AddDays(-5), LifecycleStatus = LifecycleStatus.Scheduled, InternalWONumber = "10000000003" }); @@ -392,7 +394,8 @@ public class WorkOrderAdvancedSearchServiceTests { Id = 1, WorkOrderType = WorkOrderType.PM, - ScheduledDate = today.AddDays(-3), + DueDate = today.AddDays(-3), + ScheduledDate = today.AddDays(5), LifecycleStatus = LifecycleStatus.Scheduled, InternalWONumber = "10000000001" }, @@ -408,6 +411,7 @@ public class WorkOrderAdvancedSearchServiceTests { Id = 3, WorkOrderType = WorkOrderType.Reactive, + DueDate = today.AddDays(1), ScheduledDate = today.AddDays(1), LifecycleStatus = LifecycleStatus.Scheduled, InternalWONumber = "10000000003" @@ -444,7 +448,8 @@ public class WorkOrderAdvancedSearchServiceTests { Id = 1, WorkOrderType = WorkOrderType.Other, - ScheduledDate = today.AddDays(5), + DueDate = today.AddDays(5), + ScheduledDate = today.AddDays(-5), LifecycleStatus = LifecycleStatus.Scheduled, InternalWONumber = "10000000001" }, @@ -452,7 +457,8 @@ public class WorkOrderAdvancedSearchServiceTests { Id = 2, WorkOrderType = WorkOrderType.PM, - ScheduledDate = today.AddDays(-3), + DueDate = today.AddDays(-3), + ScheduledDate = today.AddDays(5), LifecycleStatus = LifecycleStatus.Scheduled, InternalWONumber = "10000000002" }, @@ -460,6 +466,7 @@ public class WorkOrderAdvancedSearchServiceTests { Id = 3, WorkOrderType = WorkOrderType.Reactive, + DueDate = today.AddDays(-2), ScheduledDate = today.AddDays(-2), LifecycleStatus = LifecycleStatus.Completed, InternalWONumber = "10000000003" @@ -468,6 +475,7 @@ public class WorkOrderAdvancedSearchServiceTests { Id = 4, WorkOrderType = WorkOrderType.Reactive, + DueDate = today.AddDays(-1), ScheduledDate = today.AddDays(-1), LifecycleStatus = LifecycleStatus.Canceled, InternalWONumber = "10000000004" diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs index 92c4d36..3eca81d 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs @@ -391,6 +391,7 @@ public class WorkOrderBoardServiceTests { Id = 1, InternalWONumber = "10000000001", + DueDate = new DateTime(2026, 6, 20), ScheduledDate = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc), LifecycleStatus = LifecycleStatus.Scheduled }); diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index b775ee6..7c1b678 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -91,7 +91,8 @@ public class WorkOrderBoardUpdateServiceTests { Id = 1, LifecycleStatus = LifecycleStatus.Scheduled, - ScheduledDate = DateTime.UtcNow.Date.AddDays(-3), + DueDate = DateTime.UtcNow.Date.AddDays(-3), + ScheduledDate = DateTime.UtcNow.Date.AddDays(5), RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }; context.workOrders.Add(wo); @@ -108,6 +109,145 @@ public class WorkOrderBoardUpdateServiceTests Assert.Equal("PastDueStatusBlocked", ex.Code); } + [Fact] + public async Task PatchField_RescheduleAlone_DoesNotClearPastDueBlock() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + DueDate = DateTime.UtcNow.Date.AddDays(-3), + ScheduledDate = DateTime.UtcNow.Date.AddDays(-1), + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var futureSchedule = DateTime.UtcNow.Date.AddDays(7).ToString("yyyy-MM-dd"); + var row = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ScheduledDate, + Value = futureSchedule, + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.True(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(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.LifecycleStatus, + Value = "Complete", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("PastDueStatusBlocked", ex.Code); + } + + [Fact] + public async Task PatchField_FutureDueDate_ClearsPastDueAndAllowsStatusChange() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + DueDate = DateTime.UtcNow.Date.AddDays(-3), + ScheduledDate = DateTime.UtcNow.Date.AddDays(-1), + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var futureDue = DateTime.UtcNow.Date.AddDays(10).ToString("yyyy-MM-dd"); + var afterDue = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.DueDate, + Value = futureDue, + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.False(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"); + + Assert.Equal(LifecycleStatus.Completed, afterStatus.LifecycleStatus); + Assert.False(afterStatus.IsPastDue); + } + + [Fact] + public async Task PatchField_DueDate_DoesNotChangeScheduledDate() + { + var (context, service) = CreateSut(); + var scheduled = new DateTime(2026, 6, 20); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + DueDate = new DateTime(2026, 6, 18), + ScheduledDate = scheduled, + RescheduleCount = 2, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.DueDate, + Value = "2026-07-01", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(new DateTime(2026, 7, 1), result.DueDate!.Value.Date); + Assert.Equal(scheduled, result.ScheduledDate); + Assert.Equal(2, result.RescheduleCount); + + var persisted = await context.workOrders.SingleAsync(w => w.Id == 1); + Assert.Equal(scheduled, persisted.ScheduledDate); + Assert.Equal(2, persisted.RescheduleCount); + } + + [Fact] + public async Task PatchField_ScheduledDate_DoesNotChangeDueDate() + { + var (context, service) = CreateSut(); + var due = new DateTime(2026, 6, 18); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + DueDate = due, + ScheduledDate = new DateTime(2026, 6, 20), + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ScheduledDate, + Value = "2026-06-25", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(new DateTime(2026, 6, 25), result.ScheduledDate!.Value.Date); + Assert.Equal(due, result.DueDate); + + var persisted = await context.workOrders.SingleAsync(w => w.Id == 1); + Assert.Equal(due, persisted.DueDate); + } + [Fact] public async Task PatchField_RejectsOverdueAsWorkOrderType() { diff --git a/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs b/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs index 0d1f61c..49b5a61 100644 --- a/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs @@ -6,28 +6,79 @@ namespace SeaHavenIndustries.Tests; public class WorkOrderDerivedFieldsTests { + private static readonly DateOnly Today = DateOnly.FromDateTime(DateTime.UtcNow); + private static readonly DateTime Past = DateTime.UtcNow.Date.AddDays(-2); + private static readonly DateTime Future = DateTime.UtcNow.Date.AddDays(3); + private static readonly DateTime TodayDate = DateTime.UtcNow.Date; + [Fact] - public void IsPastDue_WhenScheduledBeforeTodayAndNotTerminal_ReturnsTrue() + public void IsPastDue_WhenDuePastAndScheduleFuture_ReturnsTrue() { var wo = new WorkOrder { - ScheduledDate = DateTime.UtcNow.AddDays(-2), + DueDate = Past, + ScheduledDate = Future, LifecycleStatus = LifecycleStatus.Scheduled }; - Assert.True(WorkOrderDerivedFields.IsPastDue(wo, DateOnly.FromDateTime(DateTime.UtcNow))); + Assert.True(WorkOrderDerivedFields.IsPastDue(wo, Today)); + Assert.True(WorkOrderDerivedFields.IsPastDue(wo.DueDate, wo.LifecycleStatus)); } [Fact] - public void IsPastDue_WhenCompleted_ReturnsFalse() + public void IsPastDue_WhenDueFutureAndSchedulePast_ReturnsFalse() { var wo = new WorkOrder { - ScheduledDate = DateTime.UtcNow.AddDays(-2), - LifecycleStatus = LifecycleStatus.Completed + DueDate = Future, + ScheduledDate = Past, + LifecycleStatus = LifecycleStatus.Scheduled }; - Assert.False(WorkOrderDerivedFields.IsPastDue(wo, DateOnly.FromDateTime(DateTime.UtcNow))); + Assert.False(WorkOrderDerivedFields.IsPastDue(wo, Today)); + } + + [Fact] + public void IsPastDue_WhenDueDateNull_ReturnsFalse() + { + var wo = new WorkOrder + { + DueDate = null, + ScheduledDate = Past, + LifecycleStatus = LifecycleStatus.Scheduled + }; + + Assert.False(WorkOrderDerivedFields.IsPastDue(wo, Today)); + Assert.False(WorkOrderDerivedFields.IsPastDue(null, LifecycleStatus.Scheduled)); + } + + [Fact] + public void IsPastDue_WhenDueEqualsToday_ReturnsFalse() + { + var wo = new WorkOrder + { + DueDate = TodayDate, + ScheduledDate = Past, + LifecycleStatus = LifecycleStatus.Scheduled + }; + + Assert.False(WorkOrderDerivedFields.IsPastDue(wo, Today)); + } + + [Theory] + [InlineData(LifecycleStatus.Completed)] + [InlineData(LifecycleStatus.Canceled)] + public void IsPastDue_WhenTerminal_ReturnsFalse(LifecycleStatus status) + { + var wo = new WorkOrder + { + DueDate = Past, + ScheduledDate = Past, + LifecycleStatus = status + }; + + Assert.False(WorkOrderDerivedFields.IsPastDue(wo, Today)); + Assert.False(WorkOrderDerivedFields.IsPastDue(Past, status)); } [Fact] diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index d6bd47e..044567b 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -111,7 +111,8 @@ public class WorkOrderDetailServiceTests Id = 1, InternalWONumber = "00000000001", LifecycleStatus = LifecycleStatus.Scheduled, - ScheduledDate = DateTime.UtcNow.AddDays(-2), + DueDate = DateTime.UtcNow.AddDays(-2), + ScheduledDate = DateTime.UtcNow.AddDays(3), Trade = "HVAC PM", WorkOrderType = WorkOrderType.PM, DocStatus = DocStatus.No, diff --git a/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs b/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs index bff60ca..539d4ab 100644 --- a/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs @@ -240,12 +240,14 @@ public class WorkOrderWeekRolledTests new WorkOrder { Id = 1, + DueDate = pastDate, ScheduledDate = pastDate, LifecycleStatus = LifecycleStatus.Scheduled }, new WorkOrder { Id = 2, + DueDate = pastDate, ScheduledDate = pastDate, LifecycleStatus = LifecycleStatus.Completed, OperationalFlags = OperationalFlags.PastDue @@ -253,6 +255,7 @@ public class WorkOrderWeekRolledTests new WorkOrder { Id = 3, + DueDate = futureDate, ScheduledDate = futureDate, LifecycleStatus = LifecycleStatus.Scheduled, OperationalFlags = OperationalFlags.PastDue @@ -307,7 +310,8 @@ public class WorkOrderWeekRolledTests context.workOrders.Add(new WorkOrder { Id = 1, - ScheduledDate = pastDate, + DueDate = pastDate, + ScheduledDate = DateTime.UtcNow.Date.AddDays(5), LifecycleStatus = LifecycleStatus.Scheduled, OperationalFlags = OperationalFlags.None }); @@ -319,7 +323,7 @@ public class WorkOrderWeekRolledTests Assert.NotNull(row); Assert.True(row!.IsPastDue); - Assert.False(WorkOrderDerivedFields.IsPastDue(pastDate, LifecycleStatus.Scheduled) == false); + Assert.True(WorkOrderDerivedFields.IsPastDue(pastDate, LifecycleStatus.Scheduled)); } [Fact] @@ -331,7 +335,8 @@ public class WorkOrderWeekRolledTests context.workOrders.Add(new WorkOrder { Id = 1, - ScheduledDate = futureDate, + DueDate = futureDate, + ScheduledDate = DateTime.UtcNow.Date.AddDays(-3), LifecycleStatus = LifecycleStatus.Scheduled, OperationalFlags = OperationalFlags.PastDue }); diff --git a/docs/work-orders/phase-1/README.md b/docs/work-orders/phase-1/README.md index 660bfb3..449aac5 100644 --- a/docs/work-orders/phase-1/README.md +++ b/docs/work-orders/phase-1/README.md @@ -88,10 +88,13 @@ Lista opções para o filtro/dropdown de assignee do board. ### isPastDue ``` -ScheduledDate < UTC hoje -AND LifecycleStatus NOT IN (Complete, Canceled, Closed) +DueDate != null +AND DueDate < UTC hoje +AND LifecycleStatus NOT IN (Completed, Canceled) ``` +`DueDate` null → `isPastDue = false`. Independent of `ScheduledDate` (Schedule On). + Implementação: `SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs` ### Vendor / Appt From f7ce2ee25d446e61101ded6acbd4b7a1e1ab0ce9 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 28 Jul 2026 14:08:45 -0300 Subject: [PATCH 2/8] fix(work-orders): align Overdue type rejection test with dueDate message --- SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index 7c1b678..dabf99d 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -272,7 +272,7 @@ public class WorkOrderBoardUpdateServiceTests Assert.Equal("InvalidValue", ex.Code); Assert.Contains("isPastDue", ex.Message, StringComparison.Ordinal); - Assert.Contains("scheduledDate", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("dueDate", ex.Message, StringComparison.OrdinalIgnoreCase); } [Fact] From 97e042f5524ad0bf1a69a86f184992d2b1bab767 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 30 Jul 2026 12:20:07 -0300 Subject: [PATCH 3/8] feat(work-orders): persist IsAddOn frozen at create (SH-126) Add set-once IsAddOn with server cutoff at create, board DTO exposure, legacy type-7 backfill, and Types=AddOn search compat. Aligns with FE PR #61 frozen contract. --- .../Migrations/20260730150000_WoIsAddOn.cs | 37 +++++ .../ApplicationDbContextModelSnapshot.cs | 3 + Data.SeaHavenIndustries/Models/WorkerOrder.cs | 2 + .../Helpers/WorkOrderBoardProjection.cs | 2 + .../Helpers/WorkOrderBoardQueryFilters.cs | 9 +- .../Helpers/WorkOrderBoardSearchFilter.cs | 1 + .../Interfaces/WorkOrderBoardModels.cs | 1 + SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs | 1 + .../DTOs/WorkOrderBoardRequestDTOs.cs | 5 + .../Helpers/WorkOrderAddonCutoff.cs | 77 ++++++++++ .../Helpers/WorkOrderBoardMutationRules.cs | 2 +- .../WorkOrderBoardCreateService.cs | 6 + .../Implementation/WorkOrderBoardService.cs | 1 + .../WorkOrderBoardUpdateService.cs | 44 +++++- .../Implementation/WorkOrderDetailService.cs | 1 + .../WorkOrderBoardCreateValidation.cs | 5 +- .../WorkOrderAddonCutoffTests.cs | 135 ++++++++++++++++++ .../WorkOrderBoardCreateServiceTests.cs | 73 ++++++++++ .../WorkOrderBoardSearchTests.cs | 54 +++++++ .../WorkOrderBoardServiceTests.cs | 29 ++++ .../WorkOrderBoardUpdateServiceTests.cs | 89 ++++++++++++ 21 files changed, 566 insertions(+), 11 deletions(-) create mode 100644 Data.SeaHavenIndustries/Migrations/20260730150000_WoIsAddOn.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderAddonCutoff.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderAddonCutoffTests.cs diff --git a/Data.SeaHavenIndustries/Migrations/20260730150000_WoIsAddOn.cs b/Data.SeaHavenIndustries/Migrations/20260730150000_WoIsAddOn.cs new file mode 100644 index 0000000..92484c0 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260730150000_WoIsAddOn.cs @@ -0,0 +1,37 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260730150000_WoIsAddOn")] + public partial class WoIsAddOn : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsAddOn", + table: "workOrders", + type: "bit", + nullable: false, + defaultValue: false); + + // Legacy WorkOrderType.AddOn (7) → flag; do not remap type (real type unknown). + migrationBuilder.Sql( + "UPDATE workOrders SET IsAddOn = 1 WHERE WorkOrderType = 7;"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsAddOn", + table: "workOrders"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 813853c..e938918 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -2458,6 +2458,9 @@ namespace Data.SeaHavenIndustries.Migrations .HasMaxLength(11) .HasColumnType("nvarchar(11)"); + b.Property("IsAddOn") + .HasColumnType("bit"); + b.Property("IsDeleted") .HasColumnType("bit"); diff --git a/Data.SeaHavenIndustries/Models/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs index 59f2390..3ce4cc9 100644 --- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs +++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs @@ -80,6 +80,8 @@ namespace Data.SeaHavenIndustries // --- Analytics slice --- public int RescheduleCount { get; set; } public int CarriedOver { get; set; } + /// Recalculated on schedule set/change/clear (SH-184); create hint when unscheduled. + public bool IsAddOn { get; set; } public OperationalFlags OperationalFlags { get; set; } // --- Completion slice --- diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index 3d43fe3..2b31222 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -18,6 +18,7 @@ namespace SeaHaven.DataServices.Helpers w.InternalWONumber, w.RescheduleCount, w.CarriedOver, + w.IsAddOn, w.WorkOrderType, w.SiteCode, LocationName = w.Locations != null ? w.Locations.Name : null, @@ -99,6 +100,7 @@ namespace SeaHaven.DataServices.Helpers w.InternalWONumber, w.RescheduleCount, w.CarriedOver, + w.IsAddOn, w.WorkOrderType, w.SiteCode, w.LocationName, diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs index f6d7bd6..c3aca36 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -66,8 +66,10 @@ namespace SeaHaven.DataServices.Helpers var today = (utcNow ?? DateTime.UtcNow).Date; var typeFilters = hasTypes ? types!.ToList() : new List(); + var includeAddOnFlag = typeFilters.Contains(WorkOrderType.AddOn); // Keep past-due predicate inline — EF cannot translate local method calls. + // Types=AddOn also matches IsAddOn (legacy type 7 OR flag on a real type). if (overdue && !hasTypes) { return query.Where(w => @@ -84,10 +86,13 @@ namespace SeaHaven.DataServices.Helpers && w.DueDate.Value.Date < today && w.LifecycleStatus != LifecycleStatus.Completed && w.LifecycleStatus != LifecycleStatus.Canceled) - || (w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value))); + || (w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value)) + || (includeAddOnFlag && w.IsAddOn)); } - return query.Where(w => w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value)); + return query.Where(w => + (w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value)) + || (includeAddOnFlag && w.IsAddOn)); } public static IQueryable ApplyTypeFilter( diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs index 3149f5c..be14d8c 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs @@ -58,6 +58,7 @@ namespace SeaHaven.DataServices.Helpers || (w.WorkOrderType == WorkOrderType.Inspection && "inspection".Contains(s)) || (w.WorkOrderType == WorkOrderType.Reactive && "reactive".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)) || ("overdue".Contains(s) && w.DueDate != null diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs index ca27ca0..ad46340 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -18,6 +18,7 @@ namespace SeaHaven.DataServices.Interfaces string? InternalWONumber, int RescheduleCount, int CarriedOver, + bool IsAddOn, WorkOrderType? WorkOrderType, string? SiteCode, string? LocationName, diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs index de3fe67..1a2c9c2 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -27,6 +27,7 @@ namespace SeaHaven.Services.DTOs public string? WoNumber { get; set; } public int RescheduleCount { get; set; } public int CarriedOver { get; set; } + public bool IsAddOn { get; set; } public WorkOrderType? WorkOrderType { get; set; } public bool IsPastDue { get; set; } public string? SiteCode { get; set; } diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index 3ca672c..5f1bbb9 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -14,6 +14,11 @@ namespace SeaHaven.Services.DTOs { public string? WoNumber { get; set; } public WorkOrderType WorkOrderType { get; set; } + /// + /// Manual Add-On override when unscheduled. Ignored when ScheduledDate or TargetWeek is set + /// (server computes from cutoff). + /// + public bool? IsAddOn { get; set; } public string? SiteCode { get; set; } /// /// Optional CRM customer name. Required when the caller is org-wide (no account_id) diff --git a/SeaHaven.Services/Helpers/WorkOrderAddonCutoff.cs b/SeaHaven.Services/Helpers/WorkOrderAddonCutoff.cs new file mode 100644 index 0000000..db81e62 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderAddonCutoff.cs @@ -0,0 +1,77 @@ +namespace SeaHaven.Services.Helpers +{ + /// + /// Server-side Add-On cutoff: Saturday 23:59:59 UTC of the week prior to the schedule week. + /// SH-184: recalculated whenever scheduling is set, changed, or cleared; audited on change. + /// Create may accept a client hint when unscheduled; schedule present ignores override. + /// + public static class WorkOrderAddonCutoff + { + /// + /// Cutoff = Monday-of(anchor) − 2 days, end of day UTC (prior-week Saturday 23:59:59). + /// + public static DateTime GetAddonCutoffUtc(DateTime anchor) + { + var monday = WorkOrderBoardMutationRules.GetWeekStartMonday(anchor); + var saturday = monday.Date.AddDays(-2); + return DateTime.SpecifyKind( + saturday.AddHours(23).AddMinutes(59).AddSeconds(59), + DateTimeKind.Utc); + } + + public static DateTime GetAddonCutoffUtc(DateOnly anchor) + => GetAddonCutoffUtc(anchor.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); + + /// + /// With schedule (ScheduledDate or TargetWeek): IsAddOn when createdAtUtc is after cutoff. + /// Without schedule: client (default false). + /// + public static bool ComputeIsAddOnAtCreation( + DateTime createdAtUtc, + DateTime? scheduledDate, + DateOnly? targetWeek, + bool? manualOverride) + { + return ComputeFromScheduleAnchor(createdAtUtc, scheduledDate, targetWeek, manualOverride); + } + + /// + /// SH-184 authoritative recalc after schedule set/change/clear. No manual override on patch. + /// Cleared schedule yields false. + /// + public static bool RecalculateIsAddOnFromSchedule( + DateTime createdAtUtc, + DateTime? scheduledDate, + DateOnly? targetWeek) + => ComputeFromScheduleAnchor(createdAtUtc, scheduledDate, targetWeek, manualOverride: false); + + private static bool ComputeFromScheduleAnchor( + DateTime createdAtUtc, + DateTime? scheduledDate, + DateOnly? targetWeek, + bool? manualOverride) + { + DateTime? anchor = null; + if (scheduledDate.HasValue) + anchor = scheduledDate.Value; + else if (targetWeek.HasValue) + anchor = targetWeek.Value.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); + + if (!anchor.HasValue) + return manualOverride ?? false; + + var cutoff = GetAddonCutoffUtc(anchor.Value); + var created = NormalizeUtc(createdAtUtc); + return created > cutoff; + } + + private static DateTime NormalizeUtc(DateTime value) + { + if (value.Kind == DateTimeKind.Utc) + return value; + if (value.Kind == DateTimeKind.Local) + return value.ToUniversalTime(); + return DateTime.SpecifyKind(value, DateTimeKind.Utc); + } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs index 3067947..3589d36 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs @@ -36,7 +36,7 @@ namespace SeaHaven.Services.Helpers ? DateOnly.FromDateTime(GetWeekStartMonday(scheduledDate.Value)) : null; - private static DateTime GetWeekStartMonday(DateTime date) + public static DateTime GetWeekStartMonday(DateTime date) { var diff = (7 + (date.DayOfWeek - DayOfWeek.Monday)) % 7; return date.Date.AddDays(-diff); diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index ee32e53..acba7d6 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -106,6 +106,12 @@ namespace SeaHaven.Services.Implementation WorkOrderBoardFieldMutations.SetInitialLifecycleStatus(workOrder); + workOrder.IsAddOn = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation( + workOrder.CreatedDate!.Value, + request.ScheduledDate, + request.TargetWeek, + request.IsAddOn); + var changes = new List { WorkOrderBoardFieldMutations.BoardFieldChange.Changed("InternalWONumber", "", woNumber), diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index 1e840a9..0389f4c 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -93,6 +93,7 @@ namespace SeaHaven.Services.Implementation WoNumber = row.InternalWONumber, RescheduleCount = row.RescheduleCount, CarriedOver = row.CarriedOver, + IsAddOn = row.IsAddOn, WorkOrderType = row.WorkOrderType, IsPastDue = WorkOrderDerivedFields.IsPastDue(row.DueDate, row.LifecycleStatus, utcNow), SiteCode = row.SiteCode, diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index e717efc..1306245 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -147,8 +147,8 @@ namespace SeaHaven.Services.Implementation WorkOrderBoardFieldNames.AssignTo => ApplyAssignTo(workOrder, value, auditField), WorkOrderBoardFieldNames.DueDate => new List { ApplyDateField(value, auditField, v => workOrder.DueDate = v, () => workOrder.DueDate) }, WorkOrderBoardFieldNames.ScheduledDate => ApplyScheduledDate(workOrder, value, auditField), - WorkOrderBoardFieldNames.TargetWeek => new List { ApplyTargetWeek(workOrder, value, auditField) }, - WorkOrderBoardFieldNames.ScheduleWeekOnly => new List { ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly) }, + WorkOrderBoardFieldNames.TargetWeek => ApplyTargetWeek(workOrder, value, auditField), + WorkOrderBoardFieldNames.ScheduleWeekOnly => ApplyScheduleWeekOnly(workOrder, value, auditField), WorkOrderBoardFieldNames.VendorId => new List { await ApplyVendorIdAsync(dispatch!, value, auditField) }, WorkOrderBoardFieldNames.ApptDate => new List { ApplyApptDate(dispatch!, value, auditField) }, WorkOrderBoardFieldNames.ApptTime => ApplyApptTime(workOrder, dispatch!, value), @@ -437,10 +437,11 @@ namespace SeaHaven.Services.Implementation } changes.AddRange(ApplyAutoScheduleSideEffects(workOrder)); + AppendIsAddOnRecalculation(workOrder, changes); return changes; } - private static FieldChange ApplyTargetWeek(WorkOrder workOrder, string? value, string auditField) + private static List ApplyTargetWeek(WorkOrder workOrder, string? value, string auditField) { DateOnly? parsed = null; if (!string.IsNullOrWhiteSpace(value)) @@ -450,13 +451,42 @@ namespace SeaHaven.Services.Implementation parsed = d; } + var changes = new List(); var old = workOrder.TargetWeek?.ToString("yyyy-MM-dd"); var newVal = parsed?.ToString("yyyy-MM-dd"); - if (old == newVal) - return FieldChange.Unchanged(auditField); + if (old != newVal) + { + workOrder.TargetWeek = parsed; + changes.Add(FieldChange.ForField(auditField, old, newVal)); + } - workOrder.TargetWeek = parsed; - return FieldChange.ForField(auditField, old, newVal); + AppendIsAddOnRecalculation(workOrder, changes); + return changes; + } + + private static List ApplyScheduleWeekOnly(WorkOrder workOrder, string? value, string auditField) + { + var change = ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly); + var changes = new List { change }; + if (change.HasChanged) + AppendIsAddOnRecalculation(workOrder, changes); + return changes; + } + + private static void AppendIsAddOnRecalculation(WorkOrder workOrder, List changes) + { + var createdAt = workOrder.CreatedDate ?? DateTime.UtcNow; + var oldValue = workOrder.IsAddOn.ToString(); + var newIsAddOn = WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule( + createdAt, + workOrder.ScheduledDate, + workOrder.TargetWeek); + + if (workOrder.IsAddOn == newIsAddOn) + return; + + workOrder.IsAddOn = newIsAddOn; + changes.Add(FieldChange.ForField("IsAddOn", oldValue, newIsAddOn.ToString())); } private static FieldChange ApplyBoolField( diff --git a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs index 0bb6eac..9da5034 100644 --- a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs @@ -95,6 +95,7 @@ namespace SeaHaven.Services.Implementation WoNumber = row.WoNumber, RescheduleCount = row.RescheduleCount, CarriedOver = row.CarriedOver, + IsAddOn = row.IsAddOn, WorkOrderType = row.WorkOrderType, IsPastDue = row.IsPastDue, SiteCode = row.SiteCode, diff --git a/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs index 8d0564d..8d7b1d5 100644 --- a/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs +++ b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs @@ -1,4 +1,5 @@ using FluentValidation; +using Data.SeaHavenIndustries.Enums; using SeaHaven.Services.DTOs; namespace SeaHaven.Services.Validation @@ -12,7 +13,9 @@ namespace SeaHaven.Services.Validation public WorkOrderBoardCreateValidation() { RuleFor(x => x.WorkOrderType) - .IsInEnum().WithMessage("workOrderType is required and must be a valid value."); + .IsInEnum().WithMessage("workOrderType is required and must be a valid value.") + .Must(t => t != WorkOrderType.AddOn) + .WithMessage("workOrderType AddOn is not allowed on create; use isAddOn with a real type."); RuleFor(x => x.SiteCode) .NotEmpty().WithMessage("siteCode is required.") diff --git a/SeaHavenIndustries.Tests/WorkOrderAddonCutoffTests.cs b/SeaHavenIndustries.Tests/WorkOrderAddonCutoffTests.cs new file mode 100644 index 0000000..d860482 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderAddonCutoffTests.cs @@ -0,0 +1,135 @@ +using SeaHaven.Services.Helpers; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderAddonCutoffTests +{ + // Schedule week Mon 2026-06-22 → cutoff Sat 2026-06-20 23:59:59 UTC + private static readonly DateTime ScheduleMonday = new(2026, 6, 22, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime ExpectedCutoff = new(2026, 6, 20, 23, 59, 59, DateTimeKind.Utc); + + [Fact] + public void GetAddonCutoffUtc_FromScheduledDate_IsPriorSaturdayEndOfDay() + { + // Wednesday in schedule week + var cutoff = WorkOrderAddonCutoff.GetAddonCutoffUtc(new DateTime(2026, 6, 24, 15, 0, 0, DateTimeKind.Utc)); + Assert.Equal(ExpectedCutoff, cutoff); + } + + [Fact] + public void GetAddonCutoffUtc_FromTargetWeekMonday_IsPriorSaturdayEndOfDay() + { + var cutoff = WorkOrderAddonCutoff.GetAddonCutoffUtc(new DateOnly(2026, 6, 22)); + Assert.Equal(ExpectedCutoff, cutoff); + } + + [Fact] + public void GetAddonCutoffUtc_FromMondayAnchor_MatchesGetWeekStartMondayMinusTwo() + { + var monday = WorkOrderBoardMutationRules.GetWeekStartMonday(ScheduleMonday); + Assert.Equal(ScheduleMonday.Date, monday.Date); + Assert.Equal(ExpectedCutoff, WorkOrderAddonCutoff.GetAddonCutoffUtc(ScheduleMonday)); + } + + [Fact] + public void Compute_AfterCutoff_ReturnsTrue_IgnoresManualFalse() + { + var created = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc); + var result = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation( + created, + scheduledDate: new DateTime(2026, 6, 24), + targetWeek: null, + manualOverride: false); + + Assert.True(result); + } + + [Fact] + public void Compute_BeforeCutoff_ReturnsFalse_IgnoresManualTrue() + { + var created = new DateTime(2026, 6, 20, 23, 59, 59, DateTimeKind.Utc); + var result = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation( + created, + scheduledDate: new DateTime(2026, 6, 24), + targetWeek: null, + manualOverride: true); + + Assert.False(result); + } + + [Fact] + public void Compute_ExactlyAtCutoff_ReturnsFalse() + { + var result = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation( + ExpectedCutoff, + scheduledDate: ScheduleMonday, + targetWeek: null, + manualOverride: true); + + Assert.False(result); + } + + [Fact] + public void Compute_TargetWeekOnly_UsesCutoff() + { + var after = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation( + new DateTime(2026, 6, 21, 12, 0, 0, DateTimeKind.Utc), + scheduledDate: null, + targetWeek: new DateOnly(2026, 6, 22), + manualOverride: false); + + var before = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation( + new DateTime(2026, 6, 19, 12, 0, 0, DateTimeKind.Utc), + scheduledDate: null, + targetWeek: new DateOnly(2026, 6, 22), + manualOverride: true); + + Assert.True(after); + Assert.False(before); + } + + [Fact] + public void Compute_NoSchedule_UsesManualOverride() + { + Assert.True(WorkOrderAddonCutoff.ComputeIsAddOnAtCreation( + DateTime.UtcNow, null, null, manualOverride: true)); + Assert.False(WorkOrderAddonCutoff.ComputeIsAddOnAtCreation( + DateTime.UtcNow, null, null, manualOverride: false)); + Assert.False(WorkOrderAddonCutoff.ComputeIsAddOnAtCreation( + DateTime.UtcNow, null, null, manualOverride: null)); + } + + [Fact] + public void Compute_ScheduledDatePreferredOverTargetWeek() + { + // Target week far future (cutoff after created) would be false; + // scheduled date near past (cutoff before created) → true. + var created = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc); + var result = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation( + created, + scheduledDate: new DateTime(2026, 6, 24), + targetWeek: new DateOnly(2099, 1, 5), + manualOverride: false); + + Assert.True(result); + } + + [Fact] + public void RecalculateIsAddOnFromSchedule_NoSchedule_ReturnsFalse() + { + var created = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc); + Assert.False(WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule(created, null, null)); + } + + [Fact] + public void RecalculateIsAddOnFromSchedule_AfterCutoff_ReturnsTrue() + { + var created = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc); + var result = WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule( + created, + scheduledDate: new DateTime(2026, 6, 24), + targetWeek: null); + + Assert.True(result); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs index bd5f2bf..6c01b17 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs @@ -351,4 +351,77 @@ public class WorkOrderBoardCreateServiceTests Assert.Equal("VendorNotFound", ex.Code); } + + [Fact] + public async Task Create_ScheduledAfterCutoff_SetsIsAddOnTrue_KeepsRealType() + { + // UtcNow (test run) is after cutoff for a 2026-06 schedule week. + var (context, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + ScheduledDate = new DateTime(2026, 6, 24), + IsAddOn = false + }, "actor-1"); + + Assert.True(result.IsAddOn); + Assert.Equal(WorkOrderType.PM, result.WorkOrderType); + var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id); + Assert.True(wo.IsAddOn); + Assert.Equal(WorkOrderType.PM, wo.WorkOrderType); + } + + [Fact] + public async Task Create_ScheduledBeforeCutoff_SetsIsAddOnFalse_IgnoresClientTrue() + { + // Far-future schedule → cutoff after UtcNow → not an add-on. + var (context, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.Reactive, + SiteCode = "BK5", + ScheduledDate = new DateTime(2099, 3, 10), + IsAddOn = true + }, "actor-1"); + + Assert.False(result.IsAddOn); + Assert.Equal(WorkOrderType.Reactive, result.WorkOrderType); + var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id); + Assert.False(wo.IsAddOn); + } + + [Fact] + public async Task Create_NoSchedule_ManualIsAddOnTrue() + { + var (context, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + IsAddOn = true + }, "actor-1"); + + Assert.True(result.IsAddOn); + var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id); + Assert.True(wo.IsAddOn); + } + + [Fact] + public async Task Create_WorkOrderTypeAddOn_ThrowsValidation() + { + var (_, service) = CreateSut(); + + var ex = await Assert.ThrowsAsync(() => + service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.AddOn, + SiteCode = "BK5" + }, "actor-1")); + + Assert.Contains(ex.Errors, e => e.PropertyName == "WorkOrderType"); + } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs index 510da77..9ba1845 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs @@ -613,4 +613,58 @@ public class WorkOrderAdvancedSearchServiceTests Assert.Contains("sortBy", ex.Message); } + + [Fact] + public async Task SearchAsync_TypesAddOn_MatchesLegacyTypeOrIsAddOnFlag() + { + await using var context = CreateContext(); + var today = DateTime.UtcNow.Date; + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + WorkOrderType = WorkOrderType.AddOn, + IsAddOn = false, + ScheduledDate = today.AddDays(1), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000001" + }, + new WorkOrder + { + Id = 2, + WorkOrderType = WorkOrderType.PM, + IsAddOn = true, + ScheduledDate = today.AddDays(2), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000002" + }, + new WorkOrder + { + Id = 3, + WorkOrderType = WorkOrderType.Reactive, + IsAddOn = false, + ScheduledDate = today.AddDays(3), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000003" + }); + + await context.SaveChangesAsync(); + + var dataService = new WorkOrderAdvancedSearchDataService(context); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); + + var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)), + DateTo = DateOnly.FromDateTime(today.AddMonths(1)), + Types = new List { WorkOrderType.AddOn } + }, null); + + Assert.Equal(2, result.TotalCount); + Assert.Contains(result.Items, i => i.Id == 1 && i.WorkOrderType == WorkOrderType.AddOn); + Assert.Contains(result.Items, i => i.Id == 2 && i.IsAddOn && i.WorkOrderType == WorkOrderType.PM); + Assert.DoesNotContain(result.Items, i => i.Id == 3); + } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs index 3eca81d..9d82c8c 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs @@ -412,6 +412,35 @@ public class WorkOrderBoardServiceTests Assert.Equal("wednesday", response.Scheduled[0].DayGroup); } + [Fact] + public async Task GetBoardAsync_MapsIsAddOn() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.Add(new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + WorkOrderType = WorkOrderType.PM, + IsAddOn = true, + ScheduledDate = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc), + LifecycleStatus = LifecycleStatus.Scheduled + }); + + await context.SaveChangesAsync(); + + var boardService = new WorkOrderBoardService(new WorkOrderBoardDataService(context)); + var response = await boardService.GetBoardAsync(new WorkOrderBoardQueryDto + { + WeekStart = weekStart + }, null); + + Assert.Single(response.Scheduled); + Assert.True(response.Scheduled[0].IsAddOn); + Assert.Equal(WorkOrderType.PM, response.Scheduled[0].WorkOrderType); + } + [Fact] public async Task GetBoardAsync_InvalidWeekWindow_Throws() { diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index dabf99d..ddab1d5 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -993,4 +993,93 @@ public class WorkOrderBoardUpdateServiceTests Assert.Null(result.CompletedDate); Assert.Null(context.workOrders.Single().CompletedDate); } + + [Fact] + public async Task PatchField_ClearsIsAddOn_WhenScheduleCleared() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + WorkOrderType = WorkOrderType.PM, + ScheduledDate = new DateTime(2026, 6, 20), + CreatedDate = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc), + IsAddOn = true, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ScheduledDate, + Value = "", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.False(result.IsAddOn); + var persisted = await context.workOrders.SingleAsync(w => w.Id == 1); + Assert.False(persisted.IsAddOn); + Assert.Single(await context.WorkOrderAuditLogs.Where(a => a.FieldName == "IsAddOn").ToListAsync()); + } + + [Fact] + public async Task PatchField_ScheduledDate_RecalculatesIsAddOn_AfterCrossWeekMove() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + WorkOrderType = WorkOrderType.PM, + ScheduledDate = new DateTime(2026, 6, 10), + CreatedDate = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc), + IsAddOn = false, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ScheduledDate, + Value = "2026-06-25", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.True(result.IsAddOn); + var persisted = await context.workOrders.SingleAsync(w => w.Id == 1); + Assert.True(persisted.IsAddOn); + } + + [Fact] + public async Task PatchField_WorkOrderType_DoesNotRecalculateIsAddOn() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + WorkOrderType = WorkOrderType.PM, + IsAddOn = true, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.WorkOrderType, + Value = "Reactive", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(WorkOrderType.Reactive, result.WorkOrderType); + Assert.True(result.IsAddOn); + var persisted = await context.workOrders.SingleAsync(w => w.Id == 1); + Assert.Equal(WorkOrderType.Reactive, persisted.WorkOrderType); + Assert.True(persisted.IsAddOn); + Assert.Empty(await context.WorkOrderAuditLogs.Where(a => a.FieldName == "IsAddOn").ToListAsync()); + } } From 633d80cbc9a9e92ce790589ff03ef83b415e418f Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Thu, 13 Aug 2026 13:35:28 -0300 Subject: [PATCH 4/8] feat(work-orders): recalculate IsAddOn on schedule set/change/clear (SH-184) Replace frozen-at-create behavior with authoritative server recalc and audit on schedule patches; document cross-repo contract with FE PR #102. --- docs/work-orders/phase-1/README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/work-orders/phase-1/README.md b/docs/work-orders/phase-1/README.md index 449aac5..cc37218 100644 --- a/docs/work-orders/phase-1/README.md +++ b/docs/work-orders/phase-1/README.md @@ -157,6 +157,18 @@ Endpoints legados (`GetWorkOrderList`, `GetWorkorderById`, etc.) **não foram al --- +## Add-On indicator (SH-184) + +- Coluna `IsAddOn` persistida; legacy `WorkOrderType.AddOn` (7) backfilled. +- **Create:** cutoff preview (scheduled) ou hint manual quando unscheduled (`request.IsAddOn`). +- **Patch schedule** (`scheduledDate`, `targetWeek`, `scheduleWeekOnly`): servidor **recalcula** `IsAddOn` e audita mudanças. +- **Schedule cleared:** `IsAddOn = false`. +- Search `Types=AddOn` matches legacy type 7 **or** `IsAddOn`. + +Contrato FE: [shoc-frontend-new PR #102](https://github.com/Sea-Haven-Industries/shoc-frontend-new/pull/102). + +--- + ## Próximo passo **Fase 2** — Inline edit + concorrência (dual RowVersion, audit por campo, PATCH granular). From 1283bf7349ba6bbfc09d774d01c768fe1fe18af6 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Thu, 13 Aug 2026 13:45:25 -0300 Subject: [PATCH 5/8] fix(tests): align IsAddOn board tests with account-scoped dev APIs Pass ClaimsPrincipal and account resolver args required after rebase onto dev. --- .../WorkOrderBoardCreateServiceTests.cs | 8 ++++---- SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs | 6 ++++-- SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs | 6 ++++-- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs index 6c01b17..16cd08d 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs @@ -364,7 +364,7 @@ public class WorkOrderBoardCreateServiceTests SiteCode = "BK5", ScheduledDate = new DateTime(2026, 6, 24), IsAddOn = false - }, "actor-1"); + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); Assert.True(result.IsAddOn); Assert.Equal(WorkOrderType.PM, result.WorkOrderType); @@ -385,7 +385,7 @@ public class WorkOrderBoardCreateServiceTests SiteCode = "BK5", ScheduledDate = new DateTime(2099, 3, 10), IsAddOn = true - }, "actor-1"); + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); Assert.False(result.IsAddOn); Assert.Equal(WorkOrderType.Reactive, result.WorkOrderType); @@ -403,7 +403,7 @@ public class WorkOrderBoardCreateServiceTests WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", IsAddOn = true - }, "actor-1"); + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); Assert.True(result.IsAddOn); var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id); @@ -420,7 +420,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.AddOn, SiteCode = "BK5" - }, "actor-1")); + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); Assert.Contains(ex.Errors, e => e.PropertyName == "WorkOrderType"); } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs index 9ba1845..79b6f7c 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs @@ -652,7 +652,9 @@ public class WorkOrderAdvancedSearchServiceTests await context.SaveChangesAsync(); var dataService = new WorkOrderAdvancedSearchDataService(context); - var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService( + dataService, + WorkOrderAccountTestHelpers.Resolver(context)); var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto { @@ -660,7 +662,7 @@ public class WorkOrderAdvancedSearchServiceTests DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)), DateTo = DateOnly.FromDateTime(today.AddMonths(1)), Types = new List { WorkOrderType.AddOn } - }, null); + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); Assert.Equal(2, result.TotalCount); Assert.Contains(result.Items, i => i.Id == 1 && i.WorkOrderType == WorkOrderType.AddOn); diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs index 9d82c8c..32a890c 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs @@ -430,11 +430,13 @@ public class WorkOrderBoardServiceTests await context.SaveChangesAsync(); - var boardService = new WorkOrderBoardService(new WorkOrderBoardDataService(context)); + var boardService = new WorkOrderBoardService( + new WorkOrderBoardDataService(context), + WorkOrderAccountTestHelpers.Resolver(context)); var response = await boardService.GetBoardAsync(new WorkOrderBoardQueryDto { WeekStart = weekStart - }, null); + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); Assert.Single(response.Scheduled); Assert.True(response.Scheduled[0].IsAddOn); From 17c2e968cd14cd341fe20a6cb8e9d72cbfb95d0b Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 13 Aug 2026 16:34:21 -0300 Subject: [PATCH 6/8] feat(work-orders): board search facets for SH-121/SH-196 --- .../20260813193000_SH121_BoardSearchFacets.cs | 29 +++ .../ApplicationDbContextModelSnapshot.cs | 3 + Data.SeaHavenIndustries/Models/WorkerOrder.cs | 2 + .../WorkOrderBoardSearchFacetFilters.cs | 166 ++++++++++++++++++ .../WorkOrderAdvancedSearchDataService.cs | 8 + .../WorkOrderAdvancedSearchModels.cs | 8 + .../DTOs/WorkOrderBoardRequestDTOs.cs | 8 + .../WorkOrderAdvancedSearchService.cs | 8 + .../WorkOrderBoardSearchTests.cs | 160 +++++++++++++++++ 9 files changed, 392 insertions(+) create mode 100644 Data.SeaHavenIndustries/Migrations/20260813193000_SH121_BoardSearchFacets.cs create mode 100644 SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs diff --git a/Data.SeaHavenIndustries/Migrations/20260813193000_SH121_BoardSearchFacets.cs b/Data.SeaHavenIndustries/Migrations/20260813193000_SH121_BoardSearchFacets.cs new file mode 100644 index 0000000..db23f42 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260813193000_SH121_BoardSearchFacets.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class SH121_BoardSearchFacets : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsAddOn", + table: "workOrders", + type: "bit", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsAddOn", + table: "workOrders"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 813853c..a126058 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -2393,6 +2393,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("CarriedOver") .HasColumnType("int"); + b.Property("IsAddOn") + .HasColumnType("bit"); + b.Property("CompletedDate") .HasColumnType("datetime2"); diff --git a/Data.SeaHavenIndustries/Models/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs index 59f2390..bf4817a 100644 --- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs +++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs @@ -80,6 +80,8 @@ namespace Data.SeaHavenIndustries // --- Analytics slice --- public int RescheduleCount { get; set; } public int CarriedOver { get; set; } + /// SH-126 add-on indicator (not a WO type); used by board/search facet filter. + public bool IsAddOn { get; set; } public OperationalFlags OperationalFlags { get; set; } // --- Completion slice --- diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs new file mode 100644 index 0000000..08b2c2d --- /dev/null +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs @@ -0,0 +1,166 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; + +namespace SeaHaven.DataServices.Helpers +{ + /// + /// SH-121/SH-196 advanced search facets (severity, indicators, flag, internal WO#, uplift). + /// + public static class WorkOrderBoardSearchFacetFilters + { + private static readonly WorkOrderType[] SeverityEligibleTypes = + { + WorkOrderType.Emergency, + WorkOrderType.Reactive, + }; + + private static readonly string[] RevokedUpliftStatuses = + { + "Withdrawn", + "Cancelled", + }; + + public static IQueryable ApplySeverityFilter( + IQueryable query, + IReadOnlyList? 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 ApplyRescheduledFilter( + IQueryable query, + bool rescheduled) + { + if (!rescheduled) + return query; + + return query.Where(w => w.RescheduleCount >= 2); + } + + public static IQueryable ApplyCarriedOverFilter( + IQueryable query, + bool carriedOver) + { + if (!carriedOver) + return query; + + return query.Where(w => w.CarriedOver >= 2); + } + + public static IQueryable ApplyAddOnFilter( + IQueryable query, + bool addOn) + { + if (!addOn) + return query; + + return query.Where(w => w.IsAddOn); + } + + public static IQueryable ApplyFlagColorFilter( + IQueryable query, + IReadOnlyList? 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 ApplyInternalOnlyFilter( + IQueryable query, + bool internalOnly) + { + if (!internalOnly) + return query; + + return query.Where(w => + w.InternalWONumber != null + && EF.Functions.Like(w.InternalWONumber, "SH%")); + } + + public static IQueryable ApplyUpliftFilter( + IQueryable query, + ApplicationDbContext context, + bool hasUplift, + IReadOnlyList? 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 MapUpliftStatuses(IReadOnlyList? upliftStatuses) + { + if (upliftStatuses == null || upliftStatuses.Count == 0) + return new List(); + + var mapped = new List(); + 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(); + } + } + +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs index de1f4d2..1d4892d 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs @@ -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) diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs index d1ef77f..b965e86 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs @@ -15,6 +15,14 @@ namespace SeaHaven.DataServices.Interfaces IReadOnlyList? PmTypes, IReadOnlyList? VendorIds, IReadOnlyList? DocStatuses, + IReadOnlyList? Severities, + bool Rescheduled, + bool CarriedOver, + bool AddOn, + IReadOnlyList? FlagColors, + bool InternalOnly, + bool HasUplift, + IReadOnlyList? UpliftStatuses, bool MyWorkOrders, string? CurrentUserId, int Page, diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index 3ca672c..930e014 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -63,6 +63,14 @@ namespace SeaHaven.Services.DTOs public List? PmTypes { get; set; } public List? VendorIds { get; set; } public List? DocStatuses { get; set; } + public List? Severities { get; set; } + public bool Rescheduled { get; set; } + public bool CarriedOver { get; set; } + public bool AddOn { get; set; } + public List? FlagColors { get; set; } + public bool InternalOnly { get; set; } + public bool HasUplift { get; set; } + public List? UpliftStatuses { get; set; } public bool MyWorkOrders { get; set; } public string? SortBy { get; set; } public string? SortDir { get; set; } diff --git a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs index a4d332d..f1e79cf 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs @@ -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, diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs index 780c25c..179d88f 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs @@ -605,4 +605,164 @@ public class WorkOrderAdvancedSearchServiceTests Assert.Contains("sortBy", ex.Message); } + + [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 { 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 { "pending" }, + }, + WorkOrderAccountTestHelpers.OrgWideAdmin(), + null); + + Assert.Equal(1, result.TotalCount); + Assert.Equal(1, result.Items.Single().Id); + } } From c9a80f5c799b4d3d5d31ce88d8d85a757e2c1a99 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Mon, 17 Aug 2026 10:36:03 -0300 Subject: [PATCH 7/8] fix(work-orders): restore SH-185 Schedule On Past Due and assert IsAddOn audit Past Due follows ScheduledDate so Due Date alone cannot set or clear it. Auto-schedule and reschedule tests now expect the third IsAddOn audit field. --- .../Helpers/WorkOrderBoardQueryFilters.cs | 8 +-- .../Helpers/WorkOrderBoardSearchFilter.cs | 4 +- .../Helpers/WorkOrderDerivedFields.cs | 18 +++---- .../Implementation/WorkOrderBoardService.cs | 2 +- .../WorkOrderBoardUpdateService.cs | 2 +- .../WorkOrderBoardConcurrencyTests.cs | 7 ++- .../WorkOrderBoardSearchTests.cs | 10 ++-- .../WorkOrderBoardUpdateServiceTests.cs | 50 ++++++++++--------- .../WorkOrderDerivedFieldsTests.cs | 45 +++++++++-------- .../WorkOrderPhase6Tests.cs | 4 +- .../WorkOrderWeekRolledTests.cs | 8 +-- 11 files changed, 83 insertions(+), 75 deletions(-) diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs index c3aca36..44f965b 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -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)) diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs index be14d8c..441802b 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs @@ -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)) diff --git a/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs b/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs index 9cc77b9..c7ac473 100644 --- a/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs +++ b/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs @@ -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 { /// - /// 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. /// 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; } /// - /// 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. /// 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; } /// diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index 0389f4c..a2bda0c 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -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, diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 1306245..fc81b2c 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -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."); diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs index 16f8e9e..d1014b6 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs @@ -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] diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs index 04aa6c0..9da4328 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs @@ -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); } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index ddab1d5..69847c5 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -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(() => - 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(() => + 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] diff --git a/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs b/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs index 49b5a61..2dd8c6b 100644 --- a/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs @@ -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 }; diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 044567b..1d7440e 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -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, diff --git a/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs b/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs index 539d4ab..c299e21 100644 --- a/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs @@ -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 }); From 4760f3fdd7f8ef805770864610f83f2c4485e99e Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 18 Aug 2026 09:19:27 -0300 Subject: [PATCH 8/8] fix(work-orders): name Schedule On in PastDueStatusBlocked and register SH-121 no-op The 422 still told dispatchers to update Due Date. Point the remedy at Schedule On and make the SH-121 successor visible to EF so G6 lineage is complete. --- .../Migrations/20260813193000_SH121_BoardSearchFacets.cs | 4 ++++ .../Implementation/WorkOrderBoardUpdateService.cs | 2 +- SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs | 4 ++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Data.SeaHavenIndustries/Migrations/20260813193000_SH121_BoardSearchFacets.cs b/Data.SeaHavenIndustries/Migrations/20260813193000_SH121_BoardSearchFacets.cs index 3405792..c5fe2cd 100644 --- a/Data.SeaHavenIndustries/Migrations/20260813193000_SH121_BoardSearchFacets.cs +++ b/Data.SeaHavenIndustries/Migrations/20260813193000_SH121_BoardSearchFacets.cs @@ -1,3 +1,5 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -10,6 +12,8 @@ namespace Data.SeaHavenIndustries.Migrations /// now live in 20260730150000_WoIsAddOn (SH-184). This migration is a no-op so /// both PRs can land without a duplicate column. /// + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260813193000_SH121_BoardSearchFacets")] public partial class SH121_BoardSearchFacets : Migration { /// diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index fc81b2c..126d01e 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -63,7 +63,7 @@ namespace SeaHaven.Services.Implementation 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."); + throw new WorkOrderBoardValidationException("PastDueStatusBlocked", "Status cannot be changed while work order is past due. Update Schedule On first."); _mutationData.SetExpectedWorkOrderVersion(workOrder, workOrderVersion); diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index 69847c5..c76632a 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -111,6 +111,8 @@ public class WorkOrderBoardUpdateServiceTests }, "actor-1")); Assert.Equal("PastDueStatusBlocked", ex.Code); + Assert.Contains("Schedule On", ex.Message, StringComparison.Ordinal); + Assert.DoesNotContain("due date", ex.Message, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -187,6 +189,8 @@ public class WorkOrderBoardUpdateServiceTests }, "actor-1")); Assert.Equal("PastDueStatusBlocked", ex.Code); + Assert.Contains("Schedule On", ex.Message, StringComparison.Ordinal); + Assert.DoesNotContain("due date", ex.Message, StringComparison.OrdinalIgnoreCase); } [Fact]