From af593fe2d2bffaeed396e1aaa370d8b019d2c6cd Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 14:47:04 -0300 Subject: [PATCH 1/2] 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() { From 14443aff18f85d3d2e32720aaa9e0f0c28355121 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 20 Aug 2026 15:09:34 -0300 Subject: [PATCH 2/2] fix(work-orders): audit and lock vendor when replacing an inactive primary Creating a Pending dispatch now stages VendorId FieldChanged from the previous assignment so field lock and concurrency checks run like a live vendor PATCH. --- .../WorkOrderBoardUpdateService.cs | 42 ++++++++++++++----- .../WorkOrderBoardUpdateServiceTests.cs | 9 ++++ 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 0da5f96..7e9fce2 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -69,6 +69,7 @@ namespace SeaHaven.Services.Implementation _mutationData.SetExpectedWorkOrderVersion(workOrder, workOrderVersion); Dispatch? dispatch = null; + ResolvedDispatch? resolved = null; if (WorkOrderBoardFieldNames.IsDispatchField(canonicalField)) { var vendorIdHint = canonicalField.Equals(WorkOrderBoardFieldNames.VendorId, StringComparison.OrdinalIgnoreCase) @@ -77,7 +78,7 @@ namespace SeaHaven.Services.Implementation var hadPrimaryDispatch = workOrder.PrimaryDispatchId.HasValue; var replaceInactivePrimary = vendorIdHint.HasValue; - var resolved = await ResolveDispatchAsync( + resolved = await ResolveDispatchAsync( workOrder, request.PrimaryDispatchId, vendorIdHint, @@ -108,14 +109,30 @@ namespace SeaHaven.Services.Implementation var auditField = WorkOrderBoardFieldNames.ToAuditFieldName(canonicalField); var changes = await ApplyFieldMutationAsync(canonicalField, workOrder, dispatch, request.Value, auditField); - changes = changes.Where(c => c.HasChanged).ToList(); + if (resolved is { Created: true, Dispatch: var createdDispatch } + && canonicalField.Equals(WorkOrderBoardFieldNames.VendorId, StringComparison.OrdinalIgnoreCase)) + { + var previousVendor = resolved.Previous?.VendorId.ToString() ?? ""; + changes = new List + { + FieldChange.ForField( + auditField, + previousVendor, + createdDispatch.VendorId.ToString(), + createdDispatch.Id) + }; + } + else + { + changes = changes.Where(c => c.HasChanged).ToList(); + } if (changes.Count == 0 && !_mutationData.HasPendingChanges()) return; if (changes.Count == 0) { - await _mutationData.SaveAsync(ct); + await SaveTrackedOrThrowAsync(workOrderId, ct); return; } @@ -136,12 +153,7 @@ namespace SeaHaven.Services.Implementation } } - var outcome = await _mutationData.SaveAsync(ct); - if (outcome == BoardSaveOutcome.ConcurrencyConflict) - { - var currentState = await LoadBoardRowAsync(workOrderId); - throw new WorkOrderBoardConcurrencyException(currentState); - } + await SaveTrackedOrThrowAsync(workOrderId, ct); }, CancellationToken.None); var row = await LoadBoardRowAsync(workOrderId); @@ -211,7 +223,7 @@ namespace SeaHaven.Services.Implementation CreatedDate = DateTime.UtcNow }; _mutationData.TrackNewDispatch(created); - await _mutationData.SaveAsync(cancellationToken); + await SaveTrackedOrThrowAsync(workOrder.Id, cancellationToken); workOrder.PrimaryDispatchId = created.Id; return new ResolvedDispatch(created, Created: true, Previous: existing); } @@ -250,6 +262,16 @@ namespace SeaHaven.Services.Implementation throw new WorkOrderBoardValidationException("VendorNotFound", "vendorId does not exist."); } + private async Task SaveTrackedOrThrowAsync(int workOrderId, CancellationToken cancellationToken) + { + var outcome = await _mutationData.SaveAsync(cancellationToken); + if (outcome != BoardSaveOutcome.ConcurrencyConflict) + return; + + var currentState = await LoadBoardRowAsync(workOrderId); + throw new WorkOrderBoardConcurrencyException(currentState); + } + private async Task ApplyWoNumber(WorkOrder workOrder, string? value, string auditField) { if (!WorkOrderNumberNormalizer.TryNormalize(value, out var normalized, out var error)) diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index 7bb6f9b..edf9487 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -380,6 +380,15 @@ public class WorkOrderBoardUpdateServiceTests var leftover = await context.Dispatches.SingleAsync(d => d.Id == 10); Assert.Equal(1, leftover.VendorId); Assert.Equal(inactiveStatus, leftover.Status); + + var vendorAudit = Assert.Single( + await context.WorkOrderAuditLogs + .Where(a => a.Action == AuditActionType.FieldChanged.ToString() && a.FieldName == "VendorId") + .ToListAsync()); + Assert.Equal("1", vendorAudit.OldValue); + Assert.Equal("5", vendorAudit.NewValue); + Assert.Equal(result.PrimaryDispatchId, vendorAudit.DispatchId); + Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l => l.FieldName == "VendorId")); } [Theory]