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.
This commit is contained in:
Arthur Bassi 2026-07-21 09:31:27 -03:00
parent 76db2b5a37
commit 61b4c84337
6 changed files with 108 additions and 10 deletions

View file

@ -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);

View file

@ -0,0 +1,20 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.Helpers
{
/// <summary>
/// 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).
/// </summary>
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;
}
}

View file

@ -5,7 +5,7 @@ namespace SeaHaven.Services.Helpers
public static class WorkOrderBoardMutationRules
{
private static readonly HashSet<LifecycleStatus?> ReadOnlyStatuses =
LifecycleStatusSets.Terminal.ToHashSet();
LifecycleStatusSets.Terminal.Cast<LifecycleStatus?>().ToHashSet();
public static bool IsReadOnly(LifecycleStatus? status) => ReadOnlyStatuses.Contains(status);

View file

@ -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;

View file

@ -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<FieldChange> { ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly) },
WorkOrderBoardFieldNames.VendorId => new List<FieldChange> { await ApplyVendorIdAsync(dispatch!, value, auditField) },
WorkOrderBoardFieldNames.ApptDate => new List<FieldChange> { ApplyApptDate(dispatch!, value, auditField) },
WorkOrderBoardFieldNames.ApptTime => new List<FieldChange> { ApplyApptTime(workOrder, dispatch!, value, auditField) },
WorkOrderBoardFieldNames.ApptTime => ApplyApptTime(workOrder, dispatch!, value),
WorkOrderBoardFieldNames.DocStatus => new List<FieldChange> { 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<FieldChange> 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<FieldChange>();
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)

View file

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