diff --git a/SeaHaven.DataServices/Implementation/WorkOrderBoardMutationDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderBoardMutationDataService.cs index dbab5d4..3ad3869 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderBoardMutationDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderBoardMutationDataService.cs @@ -25,12 +25,57 @@ namespace SeaHaven.DataServices.Implementation cancellationToken); public Task GetTrackedDispatchAsync(int dispatchId, int workOrderId, CancellationToken cancellationToken) - => _context.Dispatches.FirstOrDefaultAsync( - d => d.Id == dispatchId && d.WorkOrderId == workOrderId, - cancellationToken); + => _context.Dispatches + .Include(d => d.DispatchWorkOrders) + .FirstOrDefaultAsync( + d => d.Id == dispatchId + && (d.WorkOrderId == workOrderId + || d.DispatchWorkOrders!.Any(link => link.WorkOrderId == workOrderId)), + cancellationToken); public Task GetTrackedDispatchByIdAsync(int dispatchId, CancellationToken cancellationToken) - => _context.Dispatches.FirstOrDefaultAsync(d => d.Id == dispatchId, cancellationToken); + => _context.Dispatches + .Include(d => d.DispatchWorkOrders) + .FirstOrDefaultAsync(d => d.Id == dispatchId, cancellationToken); + + public void DetachWorkOrderFromDispatch(Dispatch dispatch, int workOrderId) + { + var links = dispatch.DispatchWorkOrders? + .Where(link => link.WorkOrderId == workOrderId) + .ToList(); + if (links == null || links.Count == 0) + { + links = _context.DispatchWorkOrders + .Where(link => link.DispatchId == dispatch.Id && link.WorkOrderId == workOrderId) + .ToList(); + } + + foreach (var link in links) + _context.DispatchWorkOrders.Remove(link); + + if (dispatch.WorkOrderId != workOrderId) + return; + + var remainingIds = (dispatch.DispatchWorkOrders ?? Enumerable.Empty()) + .Where(link => link.WorkOrderId != workOrderId) + .Select(link => link.WorkOrderId) + .Distinct() + .ToList(); + + if (remainingIds.Count == 0) + { + dispatch.WorkOrderId = null; + return; + } + + var preferredOwnerId = _context.workOrders + .Where(workOrder => remainingIds.Contains(workOrder.Id) + && workOrder.PrimaryDispatchId == dispatch.Id) + .Select(workOrder => (int?)workOrder.Id) + .FirstOrDefault(); + + dispatch.WorkOrderId = preferredOwnerId ?? remainingIds[0]; + } public void TrackNewWorkOrder(WorkOrder workOrder) => _context.workOrders.Add(workOrder); diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderBoardMutationDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderBoardMutationDataService.cs index 291728b..b1fbf15 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderBoardMutationDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderBoardMutationDataService.cs @@ -15,6 +15,7 @@ namespace SeaHaven.DataServices.Interfaces Task GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); Task GetTrackedDispatchAsync(int dispatchId, int workOrderId, CancellationToken cancellationToken); Task GetTrackedDispatchByIdAsync(int dispatchId, CancellationToken cancellationToken); + void DetachWorkOrderFromDispatch(Dispatch dispatch, int workOrderId); void TrackNewWorkOrder(WorkOrder workOrder); void TrackNewDispatch(Dispatch dispatch); void TrackWorkOrderContact(int workOrderId, int contactId, string? notes); diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 12e76ec..01ab004 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -88,6 +88,7 @@ namespace SeaHaven.Services.Implementation request.PrimaryDispatchId, vendorIdHint, replaceInactivePrimary, + relaxInvalidDispatch: vendorIdHint.HasValue, ct); dispatch = resolved.Dispatch; @@ -110,6 +111,9 @@ namespace SeaHaven.Services.Implementation "DispatchVersionRequired", "dispatchVersion is required for dispatch fields."); } + + if (resolved is { Created: true, Previous: { } previousDispatch }) + _mutationData.DetachWorkOrderFromDispatch(previousDispatch, workOrder.Id); } var auditField = WorkOrderBoardFieldNames.ToAuditFieldName(canonicalField); @@ -206,12 +210,22 @@ namespace SeaHaven.Services.Implementation int? primaryDispatchId, int? vendorIdForCreate, bool replaceInactivePrimary, + bool relaxInvalidDispatch, CancellationToken cancellationToken) { - var existing = await LoadExistingDispatchAsync(workOrder, primaryDispatchId, cancellationToken); - if (existing != null - && !(replaceInactivePrimary - && WorkOrderBoardDispatchAssignment.IsInactivePrimaryDispatch(existing.Status))) + var existing = await LoadExistingDispatchAsync( + workOrder, + primaryDispatchId, + relaxInvalidDispatch, + cancellationToken); + var vendorChanged = existing != null + && vendorIdForCreate.HasValue + && existing.VendorId != vendorIdForCreate.Value; + var mustReplace = existing != null + && replaceInactivePrimary + && (WorkOrderBoardDispatchAssignment.IsInactivePrimaryDispatch(existing.Status) + || (IsSharedDispatch(existing, workOrder.Id) && vendorChanged)); + if (existing != null && !mustReplace) { workOrder.PrimaryDispatchId = existing.Id; return new ResolvedDispatch(existing, Created: false, Previous: null); @@ -238,29 +252,60 @@ namespace SeaHaven.Services.Implementation private async Task LoadExistingDispatchAsync( WorkOrder workOrder, int? primaryDispatchId, + bool relaxInvalidDispatch, CancellationToken cancellationToken) { - if (primaryDispatchId.HasValue) + var clientId = NormalizeClientPrimaryDispatchId(primaryDispatchId); + if (clientId.HasValue) { var dispatch = await _mutationData.GetTrackedDispatchAsync( - primaryDispatchId.Value, + clientId.Value, workOrder.Id, cancellationToken); - if (dispatch == null) + if (dispatch != null) + return dispatch; + + if (!relaxInvalidDispatch) throw new WorkOrderBoardValidationException( "InvalidDispatch", "Primary dispatch does not belong to this work order."); - - return dispatch; } - if (!workOrder.PrimaryDispatchId.HasValue) + var storedId = NormalizeClientPrimaryDispatchId(workOrder.PrimaryDispatchId); + if (!storedId.HasValue) return null; - return await _mutationData.GetTrackedDispatchByIdAsync( - workOrder.PrimaryDispatchId.Value, + var stored = await _mutationData.GetTrackedDispatchByIdAsync( + storedId.Value, cancellationToken); + if (stored == null || !DispatchBelongsToWorkOrder(stored, workOrder.Id)) + return null; + + return stored; + } + + private static int? NormalizeClientPrimaryDispatchId(int? primaryDispatchId) + => primaryDispatchId is > 0 ? primaryDispatchId : null; + + private static bool DispatchBelongsToWorkOrder(Dispatch dispatch, int workOrderId) + => dispatch.WorkOrderId == workOrderId + || dispatch.DispatchWorkOrders?.Any(link => link.WorkOrderId == workOrderId) == true; + + private static bool IsSharedDispatch(Dispatch dispatch, int workOrderId) + { + if (dispatch.WorkOrderId.HasValue && dispatch.WorkOrderId.Value != workOrderId) + return true; + + var linkedIds = dispatch.DispatchWorkOrders? + .Select(link => link.WorkOrderId) + .Distinct() + .ToList(); + if (linkedIds == null || linkedIds.Count == 0) + return false; + if (linkedIds.Count > 1) + return true; + return dispatch.WorkOrderId.HasValue && linkedIds[0] != dispatch.WorkOrderId.Value; } private async Task EnsureVendorExistsAsync(int vendorId) diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index 5bbec79..f53f1ff 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -392,6 +392,9 @@ public class WorkOrderBoardCancelServiceTests public Task GetTrackedDispatchByIdAsync(int dispatchId, CancellationToken cancellationToken) => _inner.GetTrackedDispatchByIdAsync(dispatchId, cancellationToken); + public void DetachWorkOrderFromDispatch(Dispatch dispatch, int workOrderId) + => _inner.DetachWorkOrderFromDispatch(dispatch, workOrderId); + public void TrackNewWorkOrder(WorkOrder workOrder) => _inner.TrackNewWorkOrder(workOrder); public void TrackNewDispatch(Dispatch dispatch) => _inner.TrackNewDispatch(dispatch); diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index d4c9593..423fa8f 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -33,6 +33,27 @@ public class WorkOrderBoardUpdateServiceTests private static string ToVersion(Dispatch dispatch) => Convert.ToBase64String(dispatch.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }); + private static Task DispatchIdsForWorkOrderAsync(ApplicationDbContext context, int workOrderId) + => context.Dispatches + .Where(dispatch => + dispatch.WorkOrderId == workOrderId + || context.DispatchWorkOrders.Any(link => + link.DispatchId == dispatch.Id && link.WorkOrderId == workOrderId)) + .Select(dispatch => dispatch.Id) + .OrderBy(id => id) + .ToArrayAsync(); + + private static Task UpliftAssociatedAsync( + ApplicationDbContext context, + int workOrderId, + int dispatchId) + => context.DispatchUpliftRequests.AnyAsync(uplift => + uplift.DispatchId == dispatchId + && (context.Dispatches.Any(dispatch => + dispatch.Id == dispatchId && dispatch.WorkOrderId == workOrderId) + || context.DispatchWorkOrders.Any(link => + link.DispatchId == dispatchId && link.WorkOrderId == workOrderId))); + [Fact] public async Task PatchField_AutoSchedulesIncompleteToScheduled() { @@ -483,6 +504,353 @@ public class WorkOrderBoardUpdateServiceTests Assert.Equal(liveStatus, updated.Status); } + [Fact] + public async Task PatchField_VendorId_SucceedsWhenClientPrimaryDispatchIdDoesNotBelong() + { + var (context, service) = CreateSut(); + context.Vendors.AddRange( + new Vendor { Id = 1, CompanyName = "Old Co" }, + new Vendor { Id = 5, CompanyName = "New Co" }); + var ownDispatch = new Dispatch + { + Id = 10, + WorkOrderId = 1, + VendorId = 1, + Status = "Sent", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } + }; + var foreignDispatch = new Dispatch + { + Id = 99, + WorkOrderId = 2, + VendorId = 1, + Status = "Sent", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 3 } + }; + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + var otherWo = new WorkOrder + { + Id = 2, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 99, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 4 } + }; + context.Dispatches.AddRange(ownDispatch, foreignDispatch); + context.workOrders.AddRange(wo, otherWo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.VendorId, + Value = "5", + WorkOrderVersion = ToVersion(wo), + DispatchVersion = ToVersion(ownDispatch), + PrimaryDispatchId = 99 + }, "actor-1"); + + Assert.Equal(5, result.VendorId); + Assert.Equal(10, result.PrimaryDispatchId); + var leftoverForeign = await context.Dispatches.SingleAsync(d => d.Id == 99); + Assert.Equal(1, leftoverForeign.VendorId); + } + + [Fact] + public async Task PatchField_VendorId_TreatsZeroPrimaryDispatchIdAsOmitted() + { + var (context, service) = CreateSut(); + context.Vendors.AddRange( + new Vendor { Id = 1, CompanyName = "Old Co" }, + new Vendor { Id = 5, CompanyName = "New Co" }); + var dispatch = new Dispatch + { + Id = 10, + WorkOrderId = 1, + VendorId = 1, + Status = "Sent", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } + }; + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.Dispatches.Add(dispatch); + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.VendorId, + Value = "5", + WorkOrderVersion = ToVersion(wo), + DispatchVersion = ToVersion(dispatch), + PrimaryDispatchId = 0 + }, "actor-1"); + + Assert.Equal(5, result.VendorId); + Assert.Equal(10, result.PrimaryDispatchId); + Assert.Equal(1, await context.Dispatches.CountAsync()); + } + + [Fact] + public async Task PatchField_VendorId_SharedDispatch_CreatesWoOwnedDispatch() + { + var (context, service) = CreateSut(); + context.Vendors.AddRange( + new Vendor { Id = 1, CompanyName = "Old Co" }, + new Vendor { Id = 5, CompanyName = "New Co" }); + var shared = new Dispatch + { + Id = 10, + WorkOrderId = 1, + VendorId = 1, + Status = "Sent", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } + }; + var primaryWo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + var secondaryWo = new WorkOrder + { + Id = 2, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 5 } + }; + context.Dispatches.Add(shared); + context.workOrders.AddRange(primaryWo, secondaryWo); + context.DispatchWorkOrders.AddRange( + new DispatchWorkOrder { DispatchId = 10, WorkOrderId = 1 }, + new DispatchWorkOrder { DispatchId = 10, WorkOrderId = 2 }); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + Id = 200, + DispatchId = 10, + RequestedNTE = 900m, + Status = "Pending", + RequiredTier = 1, + NotificationStatus = "Pending" + }); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(2, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.VendorId, + Value = "5", + WorkOrderVersion = ToVersion(secondaryWo), + DispatchVersion = ToVersion(shared), + PrimaryDispatchId = 10 + }, "actor-1"); + + Assert.Equal(5, result.VendorId); + Assert.NotEqual(10, result.PrimaryDispatchId); + Assert.Equal("Pending", result.PrimaryDispatchStatus); + var original = await context.Dispatches.SingleAsync(d => d.Id == 10); + Assert.Equal(1, original.VendorId); + Assert.Equal("Sent", original.Status); + Assert.Equal(1, original.WorkOrderId); + var created = await context.Dispatches.SingleAsync(d => d.Id == result.PrimaryDispatchId); + Assert.Equal(2, created.WorkOrderId); + Assert.Equal(5, created.VendorId); + var primaryUnchanged = await context.workOrders.SingleAsync(w => w.Id == 1); + Assert.Equal(10, primaryUnchanged.PrimaryDispatchId); + Assert.False(await context.DispatchWorkOrders.AnyAsync(link => + link.DispatchId == 10 && link.WorkOrderId == 2)); + Assert.True(await context.DispatchWorkOrders.AnyAsync(link => + link.DispatchId == 10 && link.WorkOrderId == 1)); + var secondaryDispatchIds = await DispatchIdsForWorkOrderAsync(context, 2); + var primaryDispatchIds = await DispatchIdsForWorkOrderAsync(context, 1); + Assert.Equal(new int[] { result.PrimaryDispatchId!.Value }, secondaryDispatchIds); + Assert.Equal(new int[] { 10 }, primaryDispatchIds); + Assert.False(await UpliftAssociatedAsync(context, 2, 10)); + Assert.True(await UpliftAssociatedAsync(context, 1, 10)); + } + + [Fact] + public async Task PatchField_VendorId_SharedDispatch_OwnerChange_RehomesDirectOwnership() + { + var (context, service) = CreateSut(); + context.Vendors.AddRange( + new Vendor { Id = 1, CompanyName = "Old Co" }, + new Vendor { Id = 5, CompanyName = "New Co" }); + var shared = new Dispatch + { + Id = 10, + WorkOrderId = 1, + VendorId = 1, + Status = "Sent", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } + }; + var primaryWo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + var secondaryWo = new WorkOrder + { + Id = 2, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 5 } + }; + context.Dispatches.Add(shared); + context.workOrders.AddRange(primaryWo, secondaryWo); + context.DispatchWorkOrders.AddRange( + new DispatchWorkOrder { DispatchId = 10, WorkOrderId = 1 }, + new DispatchWorkOrder { DispatchId = 10, WorkOrderId = 2 }); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.VendorId, + Value = "5", + WorkOrderVersion = ToVersion(primaryWo), + DispatchVersion = ToVersion(shared), + PrimaryDispatchId = 10 + }, "actor-1"); + + Assert.Equal(5, result.VendorId); + Assert.NotEqual(10, result.PrimaryDispatchId); + var original = await context.Dispatches.SingleAsync(d => d.Id == 10); + Assert.Equal(1, original.VendorId); + Assert.Equal(2, original.WorkOrderId); + Assert.False(await context.DispatchWorkOrders.AnyAsync(link => + link.DispatchId == 10 && link.WorkOrderId == 1)); + Assert.True(await context.DispatchWorkOrders.AnyAsync(link => + link.DispatchId == 10 && link.WorkOrderId == 2)); + var secondaryUnchanged = await context.workOrders.SingleAsync(w => w.Id == 2); + Assert.Equal(10, secondaryUnchanged.PrimaryDispatchId); + var ownerDispatchIds = await DispatchIdsForWorkOrderAsync(context, 1); + var remainingDispatchIds = await DispatchIdsForWorkOrderAsync(context, 2); + Assert.Equal(new int[] { result.PrimaryDispatchId!.Value }, ownerDispatchIds); + Assert.Equal(new int[] { 10 }, remainingDispatchIds); + } + + [Fact] + public async Task PatchField_VendorId_SharedDispatch_SameVendor_IsIdempotent() + { + var (context, service) = CreateSut(); + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Old Co" }); + var appointment = new DateTime(2026, 6, 26); + var shared = new Dispatch + { + Id = 10, + WorkOrderId = 1, + VendorId = 1, + Status = "Sent", + ScheduledDate = appointment, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } + }; + var primaryWo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + var secondaryWo = new WorkOrder + { + Id = 2, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 5 } + }; + context.Dispatches.Add(shared); + context.workOrders.AddRange(primaryWo, secondaryWo); + context.DispatchWorkOrders.AddRange( + new DispatchWorkOrder { DispatchId = 10, WorkOrderId = 1 }, + new DispatchWorkOrder { DispatchId = 10, WorkOrderId = 2 }); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(2, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.VendorId, + Value = "1", + WorkOrderVersion = ToVersion(secondaryWo), + DispatchVersion = ToVersion(shared), + PrimaryDispatchId = 10 + }, "actor-1"); + + Assert.Equal(1, result.VendorId); + Assert.Equal(10, result.PrimaryDispatchId); + Assert.Equal("Sent", result.PrimaryDispatchStatus); + Assert.Equal(1, await context.Dispatches.CountAsync()); + var unchanged = await context.Dispatches.SingleAsync(d => d.Id == 10); + Assert.Equal(appointment, unchanged.ScheduledDate); + Assert.Equal("Sent", unchanged.Status); + Assert.Equal(1, unchanged.VendorId); + Assert.Equal(2, await context.DispatchWorkOrders.CountAsync()); + Assert.Empty(await context.WorkOrderAuditLogs + .Where(a => a.FieldName == "VendorId") + .ToListAsync()); + } + + [Fact] + public async Task PatchField_ApptDate_ClientIdNotBelonging_ThrowsInvalidDispatch() + { + var (context, service) = CreateSut(); + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" }); + var ownDispatch = new Dispatch + { + Id = 10, + WorkOrderId = 1, + VendorId = 1, + Status = "Sent", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } + }; + var foreignDispatch = new Dispatch + { + Id = 99, + WorkOrderId = 2, + VendorId = 1, + Status = "Sent", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 3 } + }; + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + var otherWo = new WorkOrder + { + Id = 2, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 99, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 4 } + }; + context.Dispatches.AddRange(ownDispatch, foreignDispatch); + context.workOrders.AddRange(wo, otherWo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ApptDate, + Value = "2026-06-26", + WorkOrderVersion = ToVersion(wo), + DispatchVersion = ToVersion(ownDispatch), + PrimaryDispatchId = 99 + }, "actor-1")); + + Assert.Equal("InvalidDispatch", ex.Code); + } + [Fact] public async Task PatchField_CreatesFieldLockAndAuditPerField() {