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/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index b94762d..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) diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs index 0da2c56..dd2fd10 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs @@ -742,6 +742,26 @@ public class WorkOrderBoardCreateServiceTests 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] public async Task Create_EmergencyWithSeverity_Persists() { diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index 967b115..7f29f3b 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -128,6 +128,9 @@ public class WorkOrderBoardUpdateServiceTests 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] @@ -154,6 +157,136 @@ public class WorkOrderBoardUpdateServiceTests 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] @@ -180,6 +313,30 @@ public class WorkOrderBoardUpdateServiceTests 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() {