diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardCreateLifecycleRules.cs b/SeaHaven.Services/Helpers/WorkOrderBoardCreateLifecycleRules.cs index 0f06f3a..f16fde2 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardCreateLifecycleRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardCreateLifecycleRules.cs @@ -33,7 +33,7 @@ namespace SeaHaven.Services.Helpers } if (requested == LifecycleStatus.Scheduled - && (!scheduledDate.HasValue || scheduleWeekOnly == true)) + && !WorkOrderBoardMutationRules.HasConcreteSchedule(scheduledDate, scheduleWeekOnly)) { throw new WorkOrderBoardValidationException( "ScheduledRequiresDate", diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs b/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs index c3a59a0..b9f785f 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs @@ -62,21 +62,7 @@ namespace SeaHaven.Services.Helpers } public static List ApplyAutoScheduleSideEffects(WorkOrder workOrder) - { - var changes = new List(); - if (!WorkOrderBoardMutationRules.ShouldAutoSchedule( - workOrder.LifecycleStatus, - workOrder.ScheduledDate, - workOrder.AssignTo, - workOrder.ScheduleWeekOnly)) - return changes; - - var old = workOrder.LifecycleStatus?.ToString(); - workOrder.LifecycleStatus = LifecycleStatus.Scheduled; - workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Scheduled); - changes.Add(BoardFieldChange.StatusChanged("LifecycleStatus", old, LifecycleStatus.Scheduled.ToString())); - return changes; - } + => WorkOrderDerivedFields.ApplyLifecycleFromSchedule(workOrder); public static BoardFieldChange ApplyApptTime(WorkOrder workOrder, Dispatch dispatch, string? value) { diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs index dde4bfb..e1cec9d 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs @@ -46,7 +46,13 @@ namespace SeaHaven.Services.Helpers => field.Equals(WorkOrderBoardFieldNames.LifecycleStatus, StringComparison.OrdinalIgnoreCase) && isPastDue; /// - /// SHOC rule: Incomplete + specific scheduled date + assignee → Scheduled. + /// Concrete Schedule On: a calendar day is set and the row is not week-only. + /// + public static bool HasConcreteSchedule(DateTime? scheduledDate, bool? scheduleWeekOnly) + => scheduledDate.HasValue && scheduleWeekOnly != true; + + /// + /// Incomplete or Pending + concrete scheduled date → Scheduled. Assignee is not required. /// Week-only targets do not auto-schedule (same as ). /// public static bool ShouldAutoSchedule( @@ -54,10 +60,27 @@ namespace SeaHaven.Services.Helpers DateTime? scheduledDate, string? assignTo, bool? scheduleWeekOnly = null) - => status == LifecycleStatus.Incomplete - && scheduleWeekOnly != true - && scheduledDate.HasValue - && !string.IsNullOrWhiteSpace(assignTo); + { + _ = assignTo; + return ShouldPromoteToScheduled(status, scheduledDate, scheduleWeekOnly); + } + + public static bool ShouldPromoteToScheduled( + LifecycleStatus? status, + DateTime? scheduledDate, + bool? scheduleWeekOnly = null) + => (status == LifecycleStatus.Incomplete || status == LifecycleStatus.Pending) + && HasConcreteSchedule(scheduledDate, scheduleWeekOnly); + + /// + /// Scheduled without a concrete date (cleared or week-only) → Pending. Later statuses are ignored. + /// + public static bool ShouldDemoteFromScheduled( + LifecycleStatus? status, + DateTime? scheduledDate, + bool? scheduleWeekOnly = null) + => status == LifecycleStatus.Scheduled + && !HasConcreteSchedule(scheduledDate, scheduleWeekOnly); public static bool IsReschedule(DateTime? previousDate, DateTime? newDate) => previousDate.HasValue diff --git a/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs b/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs index c7ac473..9ef534c 100644 --- a/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs +++ b/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs @@ -50,25 +50,50 @@ namespace SeaHaven.Services.Helpers } /// - /// SHOC rule: specific scheduled date + assignee + Incomplete → Scheduled. - /// Week-only targets do not auto-schedule. + /// Promote Incomplete/Pending to Scheduled, or demote Scheduled to Pending, from Schedule On. + /// Assignee is not required. Week-only is not a concrete date. + /// + public static List ApplyLifecycleFromSchedule(WorkOrder workOrder) + { + var changes = new List(); + LifecycleStatus? next = null; + if (WorkOrderBoardMutationRules.ShouldPromoteToScheduled( + workOrder.LifecycleStatus, + workOrder.ScheduledDate, + workOrder.ScheduleWeekOnly)) + { + next = LifecycleStatus.Scheduled; + } + else if (WorkOrderBoardMutationRules.ShouldDemoteFromScheduled( + workOrder.LifecycleStatus, + workOrder.ScheduledDate, + workOrder.ScheduleWeekOnly)) + { + next = LifecycleStatus.Pending; + } + + if (next == null || workOrder.LifecycleStatus == next) + return changes; + + var old = workOrder.LifecycleStatus?.ToString(); + workOrder.LifecycleStatus = next; + workOrder.Status = GetLifecycleStatusLabel(next); + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.StatusChanged( + "LifecycleStatus", + old, + next.Value.ToString())); + return changes; + } + + /// + /// Applies schedule-driven lifecycle (promote or demote). True when the row becomes Scheduled. /// public static bool ApplyAutoScheduleIfEligible(WorkOrder workOrder) { - if (workOrder.LifecycleStatus != LifecycleStatus.Incomplete) - return false; - - if (workOrder.ScheduleWeekOnly == true) - return false; - - if (workOrder.ScheduledDate == null) - return false; - - if (string.IsNullOrWhiteSpace(workOrder.AssignTo)) - return false; - - workOrder.LifecycleStatus = LifecycleStatus.Scheduled; - return true; + var before = workOrder.LifecycleStatus; + ApplyLifecycleFromSchedule(workOrder); + return before != LifecycleStatus.Scheduled + && workOrder.LifecycleStatus == LifecycleStatus.Scheduled; } /// diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index 03e4062..0305e1e 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -175,6 +175,8 @@ namespace SeaHaven.Services.Implementation if (request.ScheduledDate.HasValue) changes.AddRange(WorkOrderBoardFieldMutations.ApplyScheduledDate(workOrder, request.ScheduledDate.Value.Date)); + changes.AddRange(WorkOrderBoardFieldMutations.ApplyAutoScheduleSideEffects(workOrder)); + Dispatch? dispatch = null; if (hasVendorDispatch) { diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 631b2c0..49a63cb 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -183,7 +183,7 @@ namespace SeaHaven.Services.Implementation WorkOrderBoardFieldNames.WorkOrderType => ApplyWorkOrderType(workOrder, value, auditField), WorkOrderBoardFieldNames.Severity => new List { ApplySeverity(workOrder, value, auditField) }, WorkOrderBoardFieldNames.SiteCode => new List { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) }, - WorkOrderBoardFieldNames.LifecycleStatus => new List { ApplyLifecycleStatus(workOrder, value, auditField) }, + WorkOrderBoardFieldNames.LifecycleStatus => ApplyLifecycleStatus(workOrder, value, auditField), 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), @@ -503,7 +503,7 @@ namespace SeaHaven.Services.Implementation return normalized; } - private static FieldChange ApplyLifecycleStatus(WorkOrder workOrder, string? value, string auditField) + private static List ApplyLifecycleStatus(WorkOrder workOrder, string? value, string auditField) { var parsed = LifecycleStatusMapper.ParseLifecycleStatus(value); if (parsed == null) @@ -516,16 +516,31 @@ namespace SeaHaven.Services.Implementation "Replace the provisional SH work order number before marking the work order Completed."); } + if (parsed == LifecycleStatus.Scheduled + && !WorkOrderBoardMutationRules.HasConcreteSchedule( + workOrder.ScheduledDate, + workOrder.ScheduleWeekOnly)) + { + throw new WorkOrderBoardValidationException( + "ScheduledRequiresDate", + "Scheduled requires a concrete scheduledDate when scheduleWeekOnly is not true."); + } + + var changes = new List(); var old = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status; - if (workOrder.LifecycleStatus == parsed) - return FieldChange.Unchanged(auditField); + if (workOrder.LifecycleStatus != parsed) + { + workOrder.LifecycleStatus = parsed; + workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(parsed); + if (workOrder.LegacyStatus == null && workOrder.Status != null) + workOrder.LegacyStatus = workOrder.Status; - workOrder.LifecycleStatus = parsed; - workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(parsed); - if (workOrder.LegacyStatus == null && workOrder.Status != null) - workOrder.LegacyStatus = workOrder.Status; + changes.Add(FieldChange.StatusChanged(auditField, old, parsed.ToString())); + } - return FieldChange.StatusChanged(auditField, old, parsed.ToString()); + // why: Incomplete/Pending patched while a concrete date already exists must still promote. + changes.AddRange(ApplyAutoScheduleSideEffects(workOrder)); + return changes; } private static List ApplyAssignTo(WorkOrder workOrder, string? value, string auditField) @@ -637,6 +652,7 @@ namespace SeaHaven.Services.Implementation var changes = new List { change }; if (change.HasChanged) AppendIsAddOnRecalculation(workOrder, changes); + changes.AddRange(ApplyAutoScheduleSideEffects(workOrder)); return changes; } @@ -785,21 +801,12 @@ namespace SeaHaven.Services.Implementation } private static List ApplyAutoScheduleSideEffects(WorkOrder workOrder) - { - var changes = new List(); - if (!WorkOrderBoardMutationRules.ShouldAutoSchedule( - workOrder.LifecycleStatus, - workOrder.ScheduledDate, - workOrder.AssignTo, - workOrder.ScheduleWeekOnly)) - return changes; + => WorkOrderBoardFieldMutations.ApplyAutoScheduleSideEffects(workOrder) + .Select(ToFieldChange) + .ToList(); - var old = workOrder.LifecycleStatus?.ToString(); - workOrder.LifecycleStatus = LifecycleStatus.Scheduled; - workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Scheduled); - changes.Add(FieldChange.StatusChanged("LifecycleStatus", old, LifecycleStatus.Scheduled.ToString())); - return changes; - } + private static FieldChange ToFieldChange(WorkOrderBoardFieldMutations.BoardFieldChange change) + => new(change.FieldName, change.OldValue, change.NewValue, change.Action, change.DispatchId, change.HasChanged); private static bool RowVersionsMatch(byte[]? current, byte[] expected) => current != null && current.AsSpan().SequenceEqual(expected); diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs index d1014b6..0aa2a17 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs @@ -46,6 +46,7 @@ public class WorkOrderBoardConcurrencyTests { Id = 1, LifecycleStatus = LifecycleStatus.Incomplete, + ScheduledDate = new DateTime(2027, 6, 25), PrimaryDispatchId = 10, RowVersion = new byte[] { 8, 0, 0, 0, 0, 0, 0, 8 } }); diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs index cdf7559..dd2fd10 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs @@ -106,6 +106,40 @@ public class WorkOrderBoardCreateServiceTests Assert.Equal("DuplicateWoNumber", ex.Code); } + [Fact] + public async Task Create_AutoSchedulesWhenDateWithoutAssignee() + { + var (_, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + LocationId = 1, + ScheduledDate = new DateTime(2026, 6, 25) + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); + + Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus); + Assert.Equal(new DateTime(2026, 6, 25), result.ScheduledDate); + } + + [Fact] + public async Task Create_PendingWithDate_PromotesToScheduled() + { + var (_, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + LocationId = 1, + LifecycleStatus = LifecycleStatus.Pending, + ScheduledDate = new DateTime(2026, 6, 25) + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); + + Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus); + } + [Fact] public async Task Create_AutoSchedulesWhenDateAndAssignee() { @@ -140,6 +174,7 @@ public class WorkOrderBoardCreateServiceTests Assert.True(result.ScheduleWeekOnly); Assert.Equal(new DateOnly(2026, 6, 22), result.TargetWeek); + Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus); } [Fact] @@ -695,7 +730,7 @@ public class WorkOrderBoardCreateServiceTests { var (_, service) = CreateSut(); - await Assert.ThrowsAsync(() => + var ex = await Assert.ThrowsAsync(() => service.CreateAsync(new WorkOrderBoardCreateRequestDto { WorkOrderType = WorkOrderType.PM, @@ -703,6 +738,28 @@ public class WorkOrderBoardCreateServiceTests LocationId = 1, LifecycleStatus = LifecycleStatus.Scheduled, }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); + + Assert.Equal("ScheduledRequiresDate", ex.Code); + } + + [Fact] + public async Task Create_WithScheduledLifecycleWeekOnly_Throws() + { + var (_, service) = CreateSut(); + + var ex = await Assert.ThrowsAsync(() => + service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + LocationId = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 7, 15), + ScheduleWeekOnly = true, + TargetWeek = new DateOnly(2026, 7, 13), + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); + + Assert.Equal("ScheduledRequiresDate", ex.Code); } [Fact] diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardFieldMutationsTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardFieldMutationsTests.cs index aa83201..61eab70 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardFieldMutationsTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardFieldMutationsTests.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using SeaHaven.Services.Helpers; namespace SeaHavenIndustries.Tests; @@ -50,4 +51,33 @@ public class WorkOrderBoardFieldMutationsTests Assert.Equal(new DateTime(2026, 6, 25, 9, 0, 0), workOrder.ScheduledStart); Assert.Equal(new DateTime(2026, 6, 25, 11, 0, 0), workOrder.ScheduledEnd); } + + [Fact] + public void ApplyScheduledDate_IncompleteWithoutAssignee_PromotesToScheduled() + { + var workOrder = new WorkOrder { LifecycleStatus = LifecycleStatus.Incomplete }; + + var changes = WorkOrderBoardFieldMutations.ApplyScheduledDate( + workOrder, + new DateTime(2026, 6, 25)); + + Assert.Equal(LifecycleStatus.Scheduled, workOrder.LifecycleStatus); + Assert.Contains(changes, c => c.Action == AuditActionType.StatusChanged); + } + + [Fact] + public void ApplyScheduledDate_ClearsScheduled_DemotesToPending() + { + var workOrder = new WorkOrder + { + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 6, 25) + }; + + var changes = WorkOrderBoardFieldMutations.ApplyScheduledDate(workOrder, parsed: null); + + Assert.Equal(LifecycleStatus.Pending, workOrder.LifecycleStatus); + Assert.Contains(changes, c => c.Action == AuditActionType.StatusChanged + && c.NewValue == LifecycleStatus.Pending.ToString()); + } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs index 6494004..548a50f 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs @@ -27,12 +27,21 @@ public class WorkOrderBoardMutationRulesTests } [Fact] - public void ShouldAutoSchedule_WhenIncompleteWithDateAndAssignee() + public void ShouldAutoSchedule_WhenIncompleteWithDate_DoesNotRequireAssignee() { Assert.True(WorkOrderBoardMutationRules.ShouldAutoSchedule( LifecycleStatus.Incomplete, new DateTime(2026, 6, 25), - "dispatcher-1")); + assignTo: null)); + } + + [Fact] + public void ShouldAutoSchedule_WhenPendingWithConcreteDate() + { + Assert.True(WorkOrderBoardMutationRules.ShouldAutoSchedule( + LifecycleStatus.Pending, + new DateTime(2026, 6, 25), + assignTo: null)); } [Fact] @@ -41,7 +50,7 @@ public class WorkOrderBoardMutationRulesTests Assert.False(WorkOrderBoardMutationRules.ShouldAutoSchedule( LifecycleStatus.Scheduled, new DateTime(2026, 6, 25), - "dispatcher-1")); + assignTo: null)); } [Fact] @@ -50,10 +59,52 @@ public class WorkOrderBoardMutationRulesTests Assert.False(WorkOrderBoardMutationRules.ShouldAutoSchedule( LifecycleStatus.Incomplete, new DateTime(2026, 6, 25), - "dispatcher-1", + assignTo: null, scheduleWeekOnly: true)); } + [Fact] + public void ShouldAutoSchedule_FalseWhenInProgress() + { + Assert.False(WorkOrderBoardMutationRules.ShouldAutoSchedule( + LifecycleStatus.InProgress, + new DateTime(2026, 6, 25), + assignTo: null)); + } + + [Fact] + public void ShouldDemoteFromScheduled_WhenDateCleared() + { + Assert.True(WorkOrderBoardMutationRules.ShouldDemoteFromScheduled( + LifecycleStatus.Scheduled, + scheduledDate: null)); + } + + [Fact] + public void ShouldDemoteFromScheduled_WhenWeekOnly() + { + Assert.True(WorkOrderBoardMutationRules.ShouldDemoteFromScheduled( + LifecycleStatus.Scheduled, + new DateTime(2026, 6, 25), + scheduleWeekOnly: true)); + } + + [Fact] + public void ShouldDemoteFromScheduled_FalseWhenConcreteDateRemains() + { + Assert.False(WorkOrderBoardMutationRules.ShouldDemoteFromScheduled( + LifecycleStatus.Scheduled, + new DateTime(2026, 6, 25))); + } + + [Fact] + public void ShouldDemoteFromScheduled_FalseWhenInProgress() + { + Assert.False(WorkOrderBoardMutationRules.ShouldDemoteFromScheduled( + LifecycleStatus.InProgress, + scheduledDate: null)); + } + [Fact] public void CanPatchBoardField_AllowsOnlyFlagColorWhenCompleted() { diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index f37686e..7f29f3b 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -62,7 +62,6 @@ public class WorkOrderBoardUpdateServiceTests { Id = 1, LifecycleStatus = LifecycleStatus.Incomplete, - AssignTo = "user-a", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }; context.workOrders.Add(wo); @@ -83,6 +82,261 @@ public class WorkOrderBoardUpdateServiceTests Assert.Contains(audits, a => a.Action == AuditActionType.StatusChanged.ToString()); } + [Fact] + public async Task PatchField_PendingWithDate_PromotesToScheduled() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Pending, + 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(LifecycleStatus.Scheduled, result.LifecycleStatus); + } + + [Fact] + public async Task PatchField_ClearsScheduledDate_DemotesToPending() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 6, 25), + 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 = null, + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(LifecycleStatus.Pending, result.LifecycleStatus); + Assert.Null(result.ScheduledDate); + var audits = await context.WorkOrderAuditLogs.ToListAsync(); + Assert.Contains(audits, a => a.Action == AuditActionType.StatusChanged.ToString()); + Assert.Contains(audits, a => a.FieldName == "LifecycleStatus" && a.NewValue == LifecycleStatus.Pending.ToString()); + } + + [Fact] + public async Task PatchField_ScheduleWeekOnly_DemotesScheduledToPending() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 6, 25), + ScheduleWeekOnly = 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.ScheduleWeekOnly, + Value = "true", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(LifecycleStatus.Pending, result.LifecycleStatus); + Assert.True(result.ScheduleWeekOnly); + var audits = await context.WorkOrderAuditLogs.ToListAsync(); + Assert.Contains(audits, a => a.Action == AuditActionType.StatusChanged.ToString()); + } + + [Fact] + public async Task PatchField_ScheduleWeekOnlyFalse_PromotesPendingToScheduled() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Pending, + ScheduledDate = new DateTime(2026, 12, 15), + ScheduleWeekOnly = 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.ScheduleWeekOnly, + Value = "false", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus); + Assert.False(result.ScheduleWeekOnly); + } + + [Fact] + public async Task PatchField_LifecycleStatusScheduledWithoutDate_Throws() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.LifecycleStatus, + Value = "Scheduled", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("ScheduledRequiresDate", ex.Code); + Assert.Equal(LifecycleStatus.Incomplete, (await context.workOrders.SingleAsync()).LifecycleStatus); + } + + [Fact] + public async Task PatchField_LifecycleStatusScheduledWithWeekOnly_Throws() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Pending, + ScheduledDate = DateTime.UtcNow.Date.AddDays(14), + ScheduleWeekOnly = true, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.LifecycleStatus, + Value = "Scheduled", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("ScheduledRequiresDate", ex.Code); + Assert.Equal(LifecycleStatus.Pending, (await context.workOrders.SingleAsync()).LifecycleStatus); + } + + [Fact] + public async Task PatchField_LifecycleStatusScheduledWithConcreteDate_Persists() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + ScheduledDate = DateTime.UtcNow.Date.AddDays(14), + ScheduleWeekOnly = 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.LifecycleStatus, + Value = "Scheduled", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus); + } + + [Fact] + public async Task PatchField_LifecycleStatusPendingWithConcreteDate_PromotesToScheduled() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + ScheduledDate = DateTime.UtcNow.Date.AddDays(14), + ScheduleWeekOnly = 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.LifecycleStatus, + Value = "Pending", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus); + } + + [Fact] + public async Task PatchField_InProgress_ClearDate_DoesNotChangeStatus() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.InProgress, + ScheduledDate = new DateTime(2026, 6, 25), + 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 = null, + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(LifecycleStatus.InProgress, result.LifecycleStatus); + } + + [Fact] + public async Task PatchField_EnRoute_ClearDate_DoesNotChangeStatus() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.EnRoute, + ScheduledDate = new DateTime(2026, 6, 25), + 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 = null, + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(LifecycleStatus.EnRoute, result.LifecycleStatus); + } + [Fact] public async Task PatchField_IncrementsRescheduleCount() { diff --git a/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs b/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs index 2dd8c6b..eadc960 100644 --- a/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs @@ -83,19 +83,48 @@ public class WorkOrderDerivedFieldsTests } [Fact] - public void ApplyAutoSchedule_WhenDateAndAssignee_PromotesToScheduled() + public void ApplyAutoSchedule_WhenDateWithoutAssignee_PromotesToScheduled() { var wo = new WorkOrder { LifecycleStatus = LifecycleStatus.Incomplete, - ScheduledDate = new DateTime(2026, 6, 25), - AssignTo = "dispatcher-1" + ScheduledDate = new DateTime(2026, 6, 25) }; Assert.True(WorkOrderDerivedFields.ApplyAutoScheduleIfEligible(wo)); Assert.Equal(LifecycleStatus.Scheduled, wo.LifecycleStatus); } + [Fact] + public void ApplyLifecycleFromSchedule_PendingWithDate_PromotesToScheduled() + { + var wo = new WorkOrder + { + LifecycleStatus = LifecycleStatus.Pending, + ScheduledDate = new DateTime(2026, 6, 25) + }; + + var changes = WorkOrderDerivedFields.ApplyLifecycleFromSchedule(wo); + Assert.Equal(LifecycleStatus.Scheduled, wo.LifecycleStatus); + Assert.Single(changes); + Assert.Equal(AuditActionType.StatusChanged, changes[0].Action); + } + + [Fact] + public void ApplyLifecycleFromSchedule_ScheduledWithoutDate_DemotesToPending() + { + var wo = new WorkOrder + { + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = null + }; + + var changes = WorkOrderDerivedFields.ApplyLifecycleFromSchedule(wo); + Assert.Equal(LifecycleStatus.Pending, wo.LifecycleStatus); + Assert.Single(changes); + Assert.Equal(LifecycleStatus.Pending.ToString(), changes[0].NewValue); + } + [Fact] public void ApplyAutoSchedule_WeekOnly_DoesNotPromote() { @@ -103,7 +132,6 @@ public class WorkOrderDerivedFieldsTests { LifecycleStatus = LifecycleStatus.Incomplete, ScheduledDate = new DateTime(2026, 6, 25), - AssignTo = "dispatcher-1", ScheduleWeekOnly = true, TargetWeek = new DateOnly(2026, 6, 22) }; @@ -112,13 +140,25 @@ public class WorkOrderDerivedFieldsTests Assert.Equal(LifecycleStatus.Incomplete, wo.LifecycleStatus); } + [Fact] + public void ApplyLifecycleFromSchedule_InProgress_UnchangedWhenDateCleared() + { + var wo = new WorkOrder + { + LifecycleStatus = LifecycleStatus.InProgress, + ScheduledDate = null + }; + + Assert.Empty(WorkOrderDerivedFields.ApplyLifecycleFromSchedule(wo)); + Assert.Equal(LifecycleStatus.InProgress, wo.LifecycleStatus); + } + [Fact] public void SetInitialLifecycleStatus_NewWo_StartsIncompleteThenAutoSchedules() { var wo = new WorkOrder { - ScheduledDate = new DateTime(2026, 6, 25), - AssignTo = "dispatcher-1" + ScheduledDate = new DateTime(2026, 6, 25) }; WorkOrderDerivedFields.SetInitialLifecycleStatus(wo);