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()
{