From 03c069d10df722e8aa18597276535c81ae2dd9ca Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 1 Sep 2026 09:42:41 -0300 Subject: [PATCH] fix(work-orders): detach shared dispatch on vendor fork Keep same-vendor saves idempotent and drop stale DispatchWorkOrders so listing and uplift follow the new primary. --- .../WorkOrderBoardMutationDataService.cs | 39 +++++ .../IWorkOrderBoardMutationDataService.cs | 1 + .../WorkOrderBoardUpdateService.cs | 8 +- .../WorkOrderBoardCancelServiceTests.cs | 3 + .../WorkOrderBoardUpdateServiceTests.cs | 162 ++++++++++++++++++ 5 files changed, 212 insertions(+), 1 deletion(-) diff --git a/SeaHaven.DataServices/Implementation/WorkOrderBoardMutationDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderBoardMutationDataService.cs index 10945ae..3ad3869 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderBoardMutationDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderBoardMutationDataService.cs @@ -38,6 +38,45 @@ namespace SeaHaven.DataServices.Implementation .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 eb7824c..db3154d 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -111,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); @@ -215,10 +218,13 @@ namespace SeaHaven.Services.Implementation 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)); + || (IsSharedDispatch(existing, workOrder.Id) && vendorChanged)); if (existing != null && !mustReplace) { workOrder.PrimaryDispatchId = existing.Id; 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 b6e0923..6671395 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() { @@ -613,6 +634,15 @@ public class WorkOrderBoardUpdateServiceTests 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 @@ -630,11 +660,143 @@ public class WorkOrderBoardUpdateServiceTests 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]