From 61b4c8433710d17c4641e409c114556d479a60ad Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 21 Jul 2026 09:31:27 -0300 Subject: [PATCH] fix(work-orders): lock ScheduledStart/End when patching apptTime Align board apptTime field locks with SyncFieldMergePolicy field names so sync cannot overwrite a manual appointment edit. Also add shared LifecycleStatusSets so the Phase 2 branch compiles. --- .../Constants/WorkOrderFieldNames.cs | 1 + .../Helpers/LifecycleStatusSets.cs | 20 ++++++ .../Helpers/WorkOrderBoardMutationRules.cs | 2 +- .../Implementation/SyncFieldMergePolicy.cs | 5 ++ .../WorkOrderBoardUpdateService.cs | 24 ++++--- .../WorkOrderBoardUpdateServiceTests.cs | 66 +++++++++++++++++++ 6 files changed, 108 insertions(+), 10 deletions(-) create mode 100644 SeaHaven.Services/Helpers/LifecycleStatusSets.cs diff --git a/SeaHaven.Services/Constants/WorkOrderFieldNames.cs b/SeaHaven.Services/Constants/WorkOrderFieldNames.cs index 967e102..f97a394 100644 --- a/SeaHaven.Services/Constants/WorkOrderFieldNames.cs +++ b/SeaHaven.Services/Constants/WorkOrderFieldNames.cs @@ -5,6 +5,7 @@ namespace SeaHaven.Services.Constants public const string LifecycleStatus = nameof(LifecycleStatus); public const string AssignTo = nameof(AssignTo); public const string ScheduledDate = nameof(ScheduledDate); + public const string ScheduledStart = nameof(ScheduledStart); public const string ScheduledEnd = nameof(ScheduledEnd); public const string TargetWeek = nameof(TargetWeek); public const string ScheduleWeekOnly = nameof(ScheduleWeekOnly); diff --git a/SeaHaven.Services/Helpers/LifecycleStatusSets.cs b/SeaHaven.Services/Helpers/LifecycleStatusSets.cs new file mode 100644 index 0000000..5427ea6 --- /dev/null +++ b/SeaHaven.Services/Helpers/LifecycleStatusSets.cs @@ -0,0 +1,20 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.Helpers +{ + /// + /// Shared terminal lifecycle statuses for board filters, read-only checks, and jobs. + /// Keep this as the single source of truth (EF expressions should copy these values inline). + /// + public static class LifecycleStatusSets + { + public static readonly LifecycleStatus[] Terminal = + { + LifecycleStatus.Completed, + LifecycleStatus.Canceled + }; + + public static bool IsTerminal(LifecycleStatus? status) + => status is LifecycleStatus.Completed or LifecycleStatus.Canceled; + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs index 456ff91..3067947 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs @@ -5,7 +5,7 @@ namespace SeaHaven.Services.Helpers public static class WorkOrderBoardMutationRules { private static readonly HashSet ReadOnlyStatuses = - LifecycleStatusSets.Terminal.ToHashSet(); + LifecycleStatusSets.Terminal.Cast().ToHashSet(); public static bool IsReadOnly(LifecycleStatus? status) => ReadOnlyStatuses.Contains(status); diff --git a/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs b/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs index eefdcbd..a14eed8 100644 --- a/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs +++ b/SeaHaven.Services/Implementation/SyncFieldMergePolicy.cs @@ -12,6 +12,7 @@ namespace SeaHaven.Services.Implementation WorkOrderFieldNames.LifecycleStatus, WorkOrderFieldNames.AssignTo, WorkOrderFieldNames.ScheduledDate, + WorkOrderFieldNames.ScheduledStart, WorkOrderFieldNames.ScheduledEnd, WorkOrderFieldNames.TargetWeek, WorkOrderFieldNames.ScheduleWeekOnly, @@ -68,6 +69,7 @@ namespace SeaHaven.Services.Implementation WorkOrderFieldNames.Status => wo.Status, WorkOrderFieldNames.DueDate => wo.DueDate?.ToString("O"), WorkOrderFieldNames.ScheduledDate => wo.ScheduledDate?.ToString("O"), + WorkOrderFieldNames.ScheduledStart => wo.ScheduledStart?.ToString("O"), WorkOrderFieldNames.ScheduledEnd => wo.ScheduledEnd?.ToString("O"), WorkOrderFieldNames.TargetWeek => wo.TargetWeek?.ToString("O"), WorkOrderFieldNames.ScheduleWeekOnly => wo.ScheduleWeekOnly?.ToString(), @@ -105,6 +107,9 @@ namespace SeaHaven.Services.Implementation case WorkOrderFieldNames.ScheduledDate when TryParseDateTime(incomingValue, out var scheduledDate): wo.ScheduledDate = scheduledDate; return true; + case WorkOrderFieldNames.ScheduledStart when TryParseDateTime(incomingValue, out var scheduledStart): + wo.ScheduledStart = scheduledStart; + return true; case WorkOrderFieldNames.ScheduledEnd when TryParseDateTime(incomingValue, out var scheduledEnd): wo.ScheduledEnd = scheduledEnd; return true; diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index c1409b6..98002a2 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -2,6 +2,7 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Constants; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; using SeaHaven.Services.Helpers; @@ -183,7 +184,7 @@ namespace SeaHaven.Services.Implementation WorkOrderBoardFieldNames.ScheduleWeekOnly => new List { ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly) }, WorkOrderBoardFieldNames.VendorId => new List { await ApplyVendorIdAsync(dispatch!, value, auditField) }, WorkOrderBoardFieldNames.ApptDate => new List { ApplyApptDate(dispatch!, value, auditField) }, - WorkOrderBoardFieldNames.ApptTime => new List { ApplyApptTime(workOrder, dispatch!, value, auditField) }, + WorkOrderBoardFieldNames.ApptTime => ApplyApptTime(workOrder, dispatch!, value), WorkOrderBoardFieldNames.DocStatus => new List { ApplyDocStatus(workOrder, value, auditField) }, _ => throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable.") }; @@ -456,9 +457,9 @@ namespace SeaHaven.Services.Implementation return FieldChange.ForField(auditField, old, newVal, ResolveDispatchIdForAudit(dispatch)); } - // apptTime is stored on WorkOrder (ScheduledStart/End) but requires a primary dispatch - // for concurrency (dispatchVersion) and audit context; apptDate lives on Dispatch.ScheduledDate. - private static FieldChange ApplyApptTime(WorkOrder workOrder, Dispatch dispatch, string? value, string auditField) + // apptTime is stored on WorkOrder (ScheduledStart/End). Lock those sync field names so + // SyncFieldMergePolicy honors the manual edit (not a synthetic "ApptTime" lock). + private static List ApplyApptTime(WorkOrder workOrder, Dispatch dispatch, string? value) { if (!WorkOrderBoardApptTimeParser.TryParse(value, out var start, out var end, out var error)) throw new WorkOrderBoardValidationException("InvalidValue", error ?? "Invalid apptTime."); @@ -466,19 +467,24 @@ namespace SeaHaven.Services.Implementation var apptDate = dispatch.ScheduledDate ?? workOrder.ScheduledDate; var oldStart = FormatDateTime(workOrder.ScheduledStart); var oldEnd = FormatDateTime(workOrder.ScheduledEnd); + var dispatchId = ResolveDispatchIdForAudit(dispatch); workOrder.ScheduledStart = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, start); workOrder.ScheduledEnd = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, end); var newStart = FormatDateTime(workOrder.ScheduledStart); var newEnd = FormatDateTime(workOrder.ScheduledEnd); - var oldCombined = FormatApptRange(oldStart, oldEnd); - var newCombined = FormatApptRange(newStart, newEnd); + var changes = new List(); - if (oldCombined == newCombined) - return FieldChange.Unchanged(auditField, ResolveDispatchIdForAudit(dispatch)); + if (oldStart != newStart) + changes.Add(FieldChange.ForField(WorkOrderFieldNames.ScheduledStart, oldStart, newStart, dispatchId)); + if (oldEnd != newEnd) + changes.Add(FieldChange.ForField(WorkOrderFieldNames.ScheduledEnd, oldEnd, newEnd, dispatchId)); - return FieldChange.ForField(auditField, oldCombined, newCombined, ResolveDispatchIdForAudit(dispatch)); + if (changes.Count == 0) + changes.Add(FieldChange.Unchanged(WorkOrderFieldNames.ScheduledStart, dispatchId)); + + return changes; } private static FieldChange ApplyDocStatus(WorkOrder workOrder, string? value, string auditField) diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index a04425c..f0b4047 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -2,10 +2,12 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Constants; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; namespace SeaHavenIndustries.Tests; @@ -382,6 +384,70 @@ public class WorkOrderBoardUpdateServiceTests Assert.Equal(new DateTime(2026, 6, 25).Add(TimeSpan.FromDays(30)), reloaded.ScheduledStart); } + [Fact] + public async Task PatchField_ApptTime_LocksScheduledStartAndEnd_ForSync() + { + var (context, service) = CreateSut(); + var dispatch = new Dispatch + { + Id = 10, + WorkOrderId = 1, + VendorId = 1, + ScheduledDate = new DateTime(2026, 6, 25), + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } + }; + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + ScheduledDate = new DateTime(2026, 6, 25), + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" }); + context.Dispatches.Add(dispatch); + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ApptTime, + Value = "09:00-10:00", + WorkOrderVersion = ToVersion(wo), + DispatchVersion = ToVersion(dispatch), + PrimaryDispatchId = 10 + }, "actor-1"); + + var lockNames = await context.WorkOrderFieldLocks + .Where(l => l.WorkOrderId == 1) + .Select(l => l.FieldName) + .ToListAsync(); + Assert.Contains(WorkOrderFieldNames.ScheduledStart, lockNames); + Assert.Contains(WorkOrderFieldNames.ScheduledEnd, lockNames); + Assert.DoesNotContain("ApptTime", lockNames); + + var locks = new WorkOrderFieldLockService(context); + var audit = new WorkOrderAuditService(context, locks); + var policy = new SyncFieldMergePolicy(locks); + var reloaded = await context.workOrders.SingleAsync(w => w.Id == 1); + var syncContext = new WorkOrderSyncContext + { + WorkOrder = reloaded, + FieldLocks = locks, + Audit = audit + }; + + var appliedStart = await policy.TryApplyAsync( + syncContext, WorkOrderFieldNames.ScheduledStart, "2026-06-25T08:00:00"); + var appliedEnd = await policy.TryApplyAsync( + syncContext, WorkOrderFieldNames.ScheduledEnd, "2026-06-25T11:00:00"); + + Assert.False(appliedStart); + Assert.False(appliedEnd); + Assert.Equal(new DateTime(2026, 6, 25, 9, 0, 0), reloaded.ScheduledStart); + Assert.Equal(new DateTime(2026, 6, 25, 10, 0, 0), reloaded.ScheduledEnd); + } + [Fact] public async Task PatchField_DispatchFieldWithoutVersion_ThrowsDispatchVersionRequired() {