diff --git a/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs index b34db11..9a305f5 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs @@ -206,6 +206,12 @@ namespace SeaHaven.DataServices.Implementation await _context.SaveChangesAsync(); } + public Task ExecuteWorkOrderMutationAsync( + int workOrderId, + Func> work, + CancellationToken cancellationToken) + => WorkOrderMutationLock.RunAsync(_context, workOrderId, work, cancellationToken); + public async Task DeleteAsync(int id) { var entity = await GetByIdAsync(id); diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs index d1fb67f..1211c60 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs @@ -39,6 +39,12 @@ namespace SeaHaven.DataServices.Interfaces Task AddAsync(WorkOrder workOrder); Task UpdateAsync(WorkOrder workOrder); Task DeleteAsync(int id); + // Runs a read-modify-write on one work order under the shared per-work-order gate, + // transaction and row lock (WorkOrderMutationLock). + Task ExecuteWorkOrderMutationAsync( + int workOrderId, + Func> work, + CancellationToken cancellationToken); // Related entities - Read Task> GetWorkOrderContactsAsync(int workOrderId); diff --git a/SeaHaven.Services/Implementation/WorkOrderService.cs b/SeaHaven.Services/Implementation/WorkOrderService.cs index 4852185..132cbe5 100644 --- a/SeaHaven.Services/Implementation/WorkOrderService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderService.cs @@ -99,39 +99,48 @@ namespace SeaHaven.Services.Implementation if (!validationResult.IsValid) throw new ValidationException(validationResult.Errors); - var existingWorkOrder = await _workOrderDataService.GetByIdAsync(id); - if (existingWorkOrder == null) - return false; + // The read, the schedule side effects (RescheduleCount, OriginalDate/Week, IsAddOn) + // and the audit entry run under the work order's mutation gate and row lock, so a + // concurrent reschedule cannot interleave with this read-modify-write. + return await _workOrderDataService.ExecuteWorkOrderMutationAsync( + id, + async _ => + { + var existingWorkOrder = await _workOrderDataService.GetByIdAsync(id); + if (existingWorkOrder == null) + return false; - var changes = new List(); - changes.AddRange(TrackChanges(existingWorkOrder, dto)); + var changes = new List(); + changes.AddRange(TrackChanges(existingWorkOrder, dto)); - var scheduledDateChanged = dto.ScheduledDate.HasValue - && dto.ScheduledDate.Value.Date != existingWorkOrder.ScheduledDate?.Date; - var previousIsAddOn = existingWorkOrder.IsAddOn; - UpdateWorkOrderDTO.UpdateEntity(dto, existingWorkOrder); - if (scheduledDateChanged) - { - var scheduleChanges = WorkOrderBoardFieldMutations.ApplyScheduledDate( - existingWorkOrder, - dto.ScheduledDate!.Value.Date, - WorkOrderBoardFieldNames.ScheduledDate); - changes.AddRange(scheduleChanges - .Where(c => c.HasChanged && c.FieldName != WorkOrderBoardFieldNames.ScheduledDate) - .Select(c => $"{c.FieldName}: {c.OldValue} → {c.NewValue}")); - existingWorkOrder.IsAddOn = WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule( - existingWorkOrder.CreatedDate ?? DateTime.UtcNow, - existingWorkOrder.ScheduledDate, - existingWorkOrder.TargetWeek); - if (existingWorkOrder.IsAddOn != previousIsAddOn) - changes.Add($"IsAddOn: {previousIsAddOn} → {existingWorkOrder.IsAddOn}"); - } - await _workOrderDataService.UpdateAsync(existingWorkOrder); + var scheduledDateChanged = dto.ScheduledDate.HasValue + && dto.ScheduledDate.Value.Date != existingWorkOrder.ScheduledDate?.Date; + var previousIsAddOn = existingWorkOrder.IsAddOn; + UpdateWorkOrderDTO.UpdateEntity(dto, existingWorkOrder); + if (scheduledDateChanged) + { + var scheduleChanges = WorkOrderBoardFieldMutations.ApplyScheduledDate( + existingWorkOrder, + dto.ScheduledDate!.Value.Date, + WorkOrderBoardFieldNames.ScheduledDate); + changes.AddRange(scheduleChanges + .Where(c => c.HasChanged && c.FieldName != WorkOrderBoardFieldNames.ScheduledDate) + .Select(c => $"{c.FieldName}: {c.OldValue} → {c.NewValue}")); + existingWorkOrder.IsAddOn = WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule( + existingWorkOrder.CreatedDate ?? DateTime.UtcNow, + existingWorkOrder.ScheduledDate, + existingWorkOrder.TargetWeek); + if (existingWorkOrder.IsAddOn != previousIsAddOn) + changes.Add($"IsAddOn: {previousIsAddOn} → {existingWorkOrder.IsAddOn}"); + } + await _workOrderDataService.UpdateAsync(existingWorkOrder); - if (changes.Any()) - await LogAuditAsync(id, userId, AuditAction.Edit, string.Join(", ", changes)); + if (changes.Any()) + await LogAuditAsync(id, userId, AuditAction.Edit, string.Join(", ", changes)); - return true; + return true; + }, + CancellationToken.None); } public async Task DeleteWorkOrderAsync(int id, string userId) diff --git a/SeaHavenIndustries.Tests/WorkOrderEditAddonRescheduleTests.cs b/SeaHavenIndustries.Tests/WorkOrderEditAddonRescheduleTests.cs index 3646f0b..328e4f2 100644 --- a/SeaHavenIndustries.Tests/WorkOrderEditAddonRescheduleTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderEditAddonRescheduleTests.cs @@ -75,10 +75,72 @@ public sealed class WorkOrderEditAddonRescheduleTests Assert.Equal(LifecycleStatus.Scheduled, persisted.LifecycleStatus); } - private static ApplicationDbContext CreateContext() + [Fact] + public async Task EditWorkorder_Reschedule_WaitsForWorkOrderMutationGateAndReadsCommittedSchedule() + { + const int workOrderId = 41201; + var databaseName = Guid.NewGuid().ToString(); + await using (var seed = CreateContext(databaseName)) + { + seed.workOrders.Add(new WorkOrder + { + Id = workOrderId, + CreatedDate = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc), + ScheduledDate = new DateTime(2026, 7, 6), + OriginalDate = new DateOnly(2026, 7, 6), + IsAddOn = false + }); + await seed.SaveChangesAsync(); + } + + // A concurrent reschedule holds the work order's mutation gate and commits its own + // date while the edit is waiting. + await using var holderContext = CreateContext(databaseName); + var held = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var holder = new UpliftDataService(holderContext).ExecuteWorkOrderMutationAsync( + workOrderId, + async ct => + { + held.SetResult(); + await release.Task; + var workOrder = await holderContext.workOrders.SingleAsync(x => x.Id == workOrderId, ct); + workOrder.ScheduledDate = new DateTime(2026, 6, 29); + workOrder.RescheduleCount++; + await holderContext.SaveChangesAsync(ct); + return true; + }, + CancellationToken.None); + await held.Task; + + await using var editContext = CreateContext(databaseName); + var edit = CreateController(editContext).Editworkorder(new EditWorkorder_DTO + { + Id = workOrderId, + ScheduledDate = new DateTime(2026, 6, 25) + }); + await Task.Delay(200); + + Assert.False(edit.IsCompleted); + + release.SetResult(); + await holder; + Assert.IsType(await edit); + + await using var verify = CreateContext(databaseName); + var persisted = await verify.workOrders.SingleAsync(x => x.Id == workOrderId); + Assert.Equal(new DateTime(2026, 6, 25), persisted.ScheduledDate); + Assert.Equal(2, persisted.RescheduleCount); + Assert.True(persisted.IsAddOn); + var audit = await verify.WorkOrderAuditLogs.SingleAsync(x => x.WorkOrderId == workOrderId); + Assert.Contains("Schedule On: 2026-06-29 → 2026-06-25", audit.FieldName); + Assert.Contains("RescheduleCount: 1 → 2", audit.FieldName); + } + + private static ApplicationDbContext CreateContext(string? databaseName = null) { var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .UseInMemoryDatabase(databaseName ?? Guid.NewGuid().ToString()) .Options; return new ApplicationDbContext(options); }