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.
This commit is contained in:
Arthur Bassi 2026-08-20 14:47:04 -03:00
parent 737c137a82
commit af593fe2d2
2 changed files with 166 additions and 28 deletions

View file

@ -1,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums; using Data.SeaHavenIndustries.Enums;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Constants; using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
@ -75,18 +76,34 @@ namespace SeaHaven.Services.Implementation
: null; : null;
var hadPrimaryDispatch = workOrder.PrimaryDispatchId.HasValue; var hadPrimaryDispatch = workOrder.PrimaryDispatchId.HasValue;
dispatch = await ResolveDispatchAsync(workOrder, request.PrimaryDispatchId, vendorIdHint, ct); var replaceInactivePrimary = vendorIdHint.HasValue;
var resolved = await ResolveDispatchAsync(
if (dispatchVersion != null && !RowVersionsMatch(dispatch.RowVersion, dispatchVersion)) workOrder,
{ request.PrimaryDispatchId,
var staleState = await LoadBoardRowAsync(workOrderId); vendorIdHint,
throw new WorkOrderBoardConcurrencyException(staleState); replaceInactivePrimary,
} ct);
dispatch = resolved.Dispatch;
var versionSource = resolved.Created ? resolved.Previous : dispatch;
if (dispatchVersion != null) if (dispatchVersion != null)
_mutationData.SetExpectedDispatchVersion(dispatch, dispatchVersion); {
else if (hadPrimaryDispatch && dispatch.RowVersion != null) if (versionSource != null
throw new WorkOrderBoardValidationException("DispatchVersionRequired", "dispatchVersion is required for dispatch fields."); && !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); var auditField = WorkOrderBoardFieldNames.ToAuditFieldName(canonicalField);
@ -163,25 +180,22 @@ namespace SeaHaven.Services.Implementation
}; };
} }
private async Task<Dispatch> ResolveDispatchAsync(WorkOrder workOrder, int? primaryDispatchId, int? vendorIdForCreate, CancellationToken cancellationToken) private sealed record ResolvedDispatch(Dispatch Dispatch, bool Created, Dispatch? Previous);
private async Task<ResolvedDispatch> 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); workOrder.PrimaryDispatchId = existing.Id;
return new ResolvedDispatch(existing, Created: false, Previous: null);
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;
} }
if (!vendorIdForCreate.HasValue || vendorIdForCreate.Value <= 0) if (!vendorIdForCreate.HasValue || vendorIdForCreate.Value <= 0)
@ -199,7 +213,35 @@ namespace SeaHaven.Services.Implementation
_mutationData.TrackNewDispatch(created); _mutationData.TrackNewDispatch(created);
await _mutationData.SaveAsync(cancellationToken); await _mutationData.SaveAsync(cancellationToken);
workOrder.PrimaryDispatchId = created.Id; workOrder.PrimaryDispatchId = created.Id;
return created; return new ResolvedDispatch(created, Created: true, Previous: existing);
}
private async Task<Dispatch?> 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) private async Task EnsureVendorExistsAsync(int vendorId)

View file

@ -333,6 +333,102 @@ public class WorkOrderBoardUpdateServiceTests
Assert.Equal(1, await context.Dispatches.CountAsync()); 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] [Fact]
public async Task PatchField_CreatesFieldLockAndAuditPerField() public async Task PatchField_CreatesFieldLockAndAuditPerField()
{ {