From af593fe2d2bffaeed396e1aaa370d8b019d2c6cd Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 14:47:04 -0300 Subject: [PATCH] fix(work-orders): create a new dispatch when patching vendor on an inactive primary Cancelled, Canceled, and Refused primaries are not live company assignments. VendorId PATCH now inserts a Pending dispatch instead of mutating the refused row. --- .../WorkOrderBoardUpdateService.cs | 98 +++++++++++++------ .../WorkOrderBoardUpdateServiceTests.cs | 96 ++++++++++++++++++ 2 files changed, 166 insertions(+), 28 deletions(-) diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index d0ea7f7..0da5f96 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Constants; using SeaHaven.Services.DTOs; @@ -75,18 +76,34 @@ namespace SeaHaven.Services.Implementation : null; var hadPrimaryDispatch = workOrder.PrimaryDispatchId.HasValue; - dispatch = await ResolveDispatchAsync(workOrder, request.PrimaryDispatchId, vendorIdHint, ct); - - if (dispatchVersion != null && !RowVersionsMatch(dispatch.RowVersion, dispatchVersion)) - { - var staleState = await LoadBoardRowAsync(workOrderId); - throw new WorkOrderBoardConcurrencyException(staleState); - } + var replaceInactivePrimary = vendorIdHint.HasValue; + var resolved = await ResolveDispatchAsync( + workOrder, + request.PrimaryDispatchId, + vendorIdHint, + replaceInactivePrimary, + ct); + dispatch = resolved.Dispatch; + var versionSource = resolved.Created ? resolved.Previous : dispatch; if (dispatchVersion != null) - _mutationData.SetExpectedDispatchVersion(dispatch, dispatchVersion); - else if (hadPrimaryDispatch && dispatch.RowVersion != null) - throw new WorkOrderBoardValidationException("DispatchVersionRequired", "dispatchVersion is required for dispatch fields."); + { + if (versionSource != null + && !RowVersionsMatch(versionSource.RowVersion, dispatchVersion)) + { + var staleState = await LoadBoardRowAsync(workOrderId); + throw new WorkOrderBoardConcurrencyException(staleState); + } + + if (!resolved.Created) + _mutationData.SetExpectedDispatchVersion(dispatch, dispatchVersion); + } + else if (hadPrimaryDispatch && versionSource?.RowVersion != null) + { + throw new WorkOrderBoardValidationException( + "DispatchVersionRequired", + "dispatchVersion is required for dispatch fields."); + } } var auditField = WorkOrderBoardFieldNames.ToAuditFieldName(canonicalField); @@ -163,25 +180,22 @@ namespace SeaHaven.Services.Implementation }; } - private async Task ResolveDispatchAsync(WorkOrder workOrder, int? primaryDispatchId, int? vendorIdForCreate, CancellationToken cancellationToken) + private sealed record ResolvedDispatch(Dispatch Dispatch, bool Created, Dispatch? Previous); + + private async Task ResolveDispatchAsync( + WorkOrder workOrder, + int? primaryDispatchId, + int? vendorIdForCreate, + bool replaceInactivePrimary, + CancellationToken cancellationToken) { - if (primaryDispatchId.HasValue) + var existing = await LoadExistingDispatchAsync(workOrder, primaryDispatchId, cancellationToken); + if (existing != null + && !(replaceInactivePrimary + && WorkOrderBoardDispatchAssignment.IsInactivePrimaryDispatch(existing.Status))) { - var dispatch = await _mutationData.GetTrackedDispatchAsync(primaryDispatchId.Value, workOrder.Id, cancellationToken); - - if (dispatch == null) - throw new WorkOrderBoardValidationException("InvalidDispatch", "Primary dispatch does not belong to this work order."); - - workOrder.PrimaryDispatchId = dispatch.Id; - return dispatch; - } - - if (workOrder.PrimaryDispatchId.HasValue) - { - var existing = await _mutationData.GetTrackedDispatchByIdAsync(workOrder.PrimaryDispatchId.Value, cancellationToken); - - if (existing != null) - return existing; + workOrder.PrimaryDispatchId = existing.Id; + return new ResolvedDispatch(existing, Created: false, Previous: null); } if (!vendorIdForCreate.HasValue || vendorIdForCreate.Value <= 0) @@ -199,7 +213,35 @@ namespace SeaHaven.Services.Implementation _mutationData.TrackNewDispatch(created); await _mutationData.SaveAsync(cancellationToken); workOrder.PrimaryDispatchId = created.Id; - return created; + return new ResolvedDispatch(created, Created: true, Previous: existing); + } + + private async Task LoadExistingDispatchAsync( + WorkOrder workOrder, + int? primaryDispatchId, + CancellationToken cancellationToken) + { + if (primaryDispatchId.HasValue) + { + var dispatch = await _mutationData.GetTrackedDispatchAsync( + primaryDispatchId.Value, + workOrder.Id, + cancellationToken); + + if (dispatch == null) + throw new WorkOrderBoardValidationException( + "InvalidDispatch", + "Primary dispatch does not belong to this work order."); + + return dispatch; + } + + if (!workOrder.PrimaryDispatchId.HasValue) + return null; + + return await _mutationData.GetTrackedDispatchByIdAsync( + workOrder.PrimaryDispatchId.Value, + cancellationToken); } private async Task EnsureVendorExistsAsync(int vendorId) diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index 0c61a84..7bb6f9b 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -333,6 +333,102 @@ public class WorkOrderBoardUpdateServiceTests Assert.Equal(1, await context.Dispatches.CountAsync()); } + [Theory] + [InlineData("Cancelled")] + [InlineData("Canceled")] + [InlineData("Refused")] + [InlineData(" refused ")] + public async Task PatchField_VendorId_CreatesNewDispatchWhenPrimaryIsInactive(string inactiveStatus) + { + 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 = inactiveStatus, + 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 = 10 + }, "actor-1"); + + Assert.Equal(5, result.VendorId); + Assert.Equal("New Co", result.VendorName); + Assert.Equal("Pending", result.PrimaryDispatchStatus); + Assert.NotEqual(10, result.PrimaryDispatchId); + Assert.Equal(2, await context.Dispatches.CountAsync()); + var leftover = await context.Dispatches.SingleAsync(d => d.Id == 10); + Assert.Equal(1, leftover.VendorId); + Assert.Equal(inactiveStatus, leftover.Status); + } + + [Theory] + [InlineData("Sent")] + [InlineData("Verified")] + [InlineData("Completed")] + public async Task PatchField_VendorId_UpdatesExistingLivePrimaryDispatch(string liveStatus) + { + 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 = liveStatus, + 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 = 10 + }, "actor-1"); + + Assert.Equal(5, result.VendorId); + Assert.Equal(10, result.PrimaryDispatchId); + Assert.Equal(liveStatus, result.PrimaryDispatchStatus); + Assert.Equal(1, await context.Dispatches.CountAsync()); + var updated = await context.Dispatches.SingleAsync(); + Assert.Equal(5, updated.VendorId); + Assert.Equal(liveStatus, updated.Status); + } + [Fact] public async Task PatchField_CreatesFieldLockAndAuditPerField() {