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