diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs index e1cec9d..5109d8a 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs @@ -42,8 +42,14 @@ namespace SeaHaven.Services.Helpers return resolved == WorkOrderMediaCategory.Extra || resolved == WorkOrderMediaCategory.Aveta; } - public static bool ShouldBlockStatusChangeWhenPastDue(string field, bool isPastDue) - => field.Equals(WorkOrderBoardFieldNames.LifecycleStatus, StringComparison.OrdinalIgnoreCase) && isPastDue; + /// + /// Past Due locks lifecycle progress until Schedule On moves (SH-262). Cancel stays + /// available: it is its own action (SH-113), not a status update. + /// + public static bool ShouldBlockStatusChangeWhenPastDue(string field, bool isPastDue, LifecycleStatus? requestedStatus) + => field.Equals(WorkOrderBoardFieldNames.LifecycleStatus, StringComparison.OrdinalIgnoreCase) + && isPastDue + && requestedStatus != LifecycleStatus.Canceled; /// /// Concrete Schedule On: a calendar day is set and the row is not week-only. diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index f2c5bfb..7f5331b 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -92,7 +92,10 @@ namespace SeaHaven.Services.Implementation } var isPastDue = WorkOrderDerivedFields.IsPastDue(workOrder.ScheduledDate, workOrder.LifecycleStatus); - if (WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(canonicalField, isPastDue)) + if (WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue( + canonicalField, + isPastDue, + LifecycleStatusMapper.ParseLifecycleStatus(request.Value))) 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/WorkOrderBoardMutationRulesTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs index 548a50f..302ac26 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs @@ -19,11 +19,21 @@ public class WorkOrderBoardMutationRulesTests public void ShouldBlockStatusChangeWhenPastDue_BlocksOnlyLifecycleStatus() { Assert.True(WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue( - WorkOrderBoardFieldNames.LifecycleStatus, isPastDue: true)); + WorkOrderBoardFieldNames.LifecycleStatus, isPastDue: true, LifecycleStatus.InProgress)); Assert.False(WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue( - WorkOrderBoardFieldNames.ScheduledDate, isPastDue: true)); + WorkOrderBoardFieldNames.ScheduledDate, isPastDue: true, null)); Assert.False(WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue( - WorkOrderBoardFieldNames.DueDate, isPastDue: true)); + WorkOrderBoardFieldNames.DueDate, isPastDue: true, null)); + } + + [Theory] + [InlineData(LifecycleStatus.Canceled, false)] + [InlineData(LifecycleStatus.Completed, true)] + [InlineData(LifecycleStatus.EnRoute, true)] + public void ShouldBlockStatusChangeWhenPastDue_LetsOnlyCancelThrough(LifecycleStatus requested, bool expected) + { + Assert.Equal(expected, WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue( + WorkOrderBoardFieldNames.LifecycleStatus, isPastDue: true, requested)); } [Fact] diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index 28ecab4..10a10ad 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -524,6 +524,36 @@ public class WorkOrderBoardUpdateServiceTests Assert.DoesNotContain("due date", ex.Message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task PatchField_CancelsPastDueWorkOrderWithoutTouchingSchedule() + { + var (context, service) = CreateSut(); + var scheduledOn = DateTime.UtcNow.Date.AddDays(-3); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = scheduledOn, + RescheduleCount = 1, + 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.LifecycleStatus, + Value = "Canceled", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(LifecycleStatus.Canceled, result.LifecycleStatus); + var stored = await context.workOrders.SingleAsync(w => w.Id == 1); + Assert.Equal(LifecycleStatus.Canceled, stored.LifecycleStatus); + Assert.Equal(scheduledOn, stored.ScheduledDate); + Assert.Equal(1, stored.RescheduleCount); + } + [Fact] public async Task PatchField_DueDate_DoesNotChangeScheduledDate() {