diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 9c73034..82795a3 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -100,6 +100,7 @@ namespace Api.SeaHavenIndustries.Controllers LocationId = model.LocationId, AssignTo = model.AssignTo, DueDate = model.DueDate, + ScheduledDate = model.ScheduledDate, CompletedDate = model.CompletedDate, ContactIds = model.ContactIds, CategoryIds = model.CategoryIds, 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/DTOs/WorkOrderDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderDTOs.cs index f4f6003..41b5518 100644 --- a/SeaHaven.Services/DTOs/WorkOrderDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderDTOs.cs @@ -111,6 +111,7 @@ namespace SeaHaven.Services.DTOs public int? LocationId { get; set; } public string? AssignTo { get; set; } public DateTime? DueDate { get; set; } + public DateTime? ScheduledDate { get; set; } public DateTime? CompletedDate { get; set; } public static void UpdateEntity(UpdateWorkOrderDTO dto, WorkOrder entity) diff --git a/SeaHaven.Services/DTOs/WorkOrderServiceDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderServiceDTOs.cs index 7665da4..fb59789 100644 --- a/SeaHaven.Services/DTOs/WorkOrderServiceDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderServiceDTOs.cs @@ -32,6 +32,7 @@ namespace SeaHaven.Services.DTOs public int? LocationId { get; set; } public string? AssignTo { get; set; } public DateTime? DueDate { get; set; } + public DateTime? ScheduledDate { get; set; } public DateTime? CompletedDate { get; set; } public List? ContactIds { get; set; } public List? CategoryIds { get; set; } diff --git a/SeaHaven.Services/Implementation/WorkOrderService.cs b/SeaHaven.Services/Implementation/WorkOrderService.cs index 6cfa57f..132cbe5 100644 --- a/SeaHaven.Services/Implementation/WorkOrderService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderService.cs @@ -6,6 +6,7 @@ using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Models; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; using SeaHaven.Services.Validation; @@ -98,20 +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)); - UpdateWorkOrderDTO.UpdateEntity(dto, existingWorkOrder); - 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) @@ -405,6 +434,7 @@ namespace SeaHaven.Services.Implementation LocationId = input.LocationId, AssignTo = input.AssignTo, DueDate = input.DueDate, + ScheduledDate = input.ScheduledDate, CompletedDate = input.CompletedDate }; @@ -630,6 +660,8 @@ namespace SeaHaven.Services.Implementation changes.Add($"Assigned To: {existing.AssignTo} → {dto.AssignTo}"); if (dto.DueDate.HasValue && dto.DueDate != existing.DueDate) changes.Add($"Due Date: {existing.DueDate:yyyy-MM-dd} → {dto.DueDate:yyyy-MM-dd}"); + if (dto.ScheduledDate.HasValue && dto.ScheduledDate.Value.Date != existing.ScheduledDate?.Date) + changes.Add($"Schedule On: {existing.ScheduledDate:yyyy-MM-dd} → {dto.ScheduledDate:yyyy-MM-dd}"); return changes; } diff --git a/SeaHavenIndustries.Tests/WorkOrderEditAddonRescheduleTests.cs b/SeaHavenIndustries.Tests/WorkOrderEditAddonRescheduleTests.cs new file mode 100644 index 0000000..328e4f2 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderEditAddonRescheduleTests.cs @@ -0,0 +1,176 @@ +using System.Security.Claims; +using Api.SeaHavenIndustries.Controllers; +using Api.SeaHavenIndustries.DTOs; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.Models; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Validation; + +namespace SeaHavenIndustries.Tests; + +public sealed class WorkOrderEditAddonRescheduleTests +{ + [Fact] + public async Task EditWorkorder_RescheduleIntoEarlierWeek_EnablesAndAuditsAddOn() + { + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + CreatedDate = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc), + ScheduledDate = new DateTime(2026, 6, 29), + IsAddOn = false + }); + await context.SaveChangesAsync(); + + var result = await CreateController(context).Editworkorder(new EditWorkorder_DTO + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 25) + }); + + Assert.IsType(result); + var persisted = await context.workOrders.SingleAsync(x => x.Id == 1); + Assert.Equal(new DateTime(2026, 6, 25), persisted.ScheduledDate); + Assert.True(persisted.IsAddOn); + Assert.Equal(1, persisted.RescheduleCount); + var audit = await context.WorkOrderAuditLogs + .Where(x => x.WorkOrderId == 1) + .ToListAsync(); + Assert.Single(audit); + Assert.Contains(audit, x => x.FieldName?.Contains("Schedule On", StringComparison.Ordinal) == true); + Assert.Contains(audit, x => x.FieldName?.Contains("IsAddOn", StringComparison.Ordinal) == true); + Assert.Contains(audit, x => x.FieldName?.Contains("RescheduleCount: 0 → 1", StringComparison.Ordinal) == true); + } + + [Fact] + public async Task EditWorkorder_FirstSchedule_SetsOriginalWeekAndPromotesIncomplete() + { + await using var context = CreateContext(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + CreatedDate = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc), + LifecycleStatus = LifecycleStatus.Incomplete + }); + await context.SaveChangesAsync(); + + var result = await CreateController(context).Editworkorder(new EditWorkorder_DTO + { + Id = 1, + ScheduledDate = new DateTime(2026, 7, 1) + }); + + Assert.IsType(result); + var persisted = await context.workOrders.SingleAsync(x => x.Id == 1); + Assert.Equal(new DateOnly(2026, 7, 1), persisted.OriginalDate); + Assert.Equal(new DateOnly(2026, 6, 29), persisted.OriginalWeek); + Assert.Equal(0, persisted.RescheduleCount); + Assert.Equal(LifecycleStatus.Scheduled, persisted.LifecycleStatus); + } + + [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(databaseName ?? Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static WorkOrderController CreateController(ApplicationDbContext context) + { + var accountResolver = WorkOrderAccountTestHelpers.Resolver(context); + var workOrderService = new WorkOrderService( + new WorkOrderDataService(context), + new CommentDataService(context), + new UserDataService(context), + new QuotesDataService(context), + fileStorage: null!, + new CreateWorkOrderValidation(), + new UpdateWorkOrderValidation(), + accountResolver); + return new WorkOrderController( + workOrderService, + accountResolver, + NullLogger.Instance) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.NameIdentifier, "dispatcher-1") }, + authenticationType: "test")) + } + } + }; + } +}