fix(work-orders): accept linked primary dispatch on vendor board patch

Vendor PATCH treated a GET-echoed id as foreign when belong-check used only WorkOrderId.
This commit is contained in:
Arthur Bassi 2026-08-31 15:47:53 -03:00
parent 58dd5ec6ae
commit ca0404272e
3 changed files with 267 additions and 16 deletions

View file

@ -25,12 +25,18 @@ namespace SeaHaven.DataServices.Implementation
cancellationToken);
public Task<Dispatch?> 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<Dispatch?> 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 TrackNewWorkOrder(WorkOrder workOrder)
=> _context.workOrders.Add(workOrder);

View file

@ -88,6 +88,7 @@ namespace SeaHaven.Services.Implementation
request.PrimaryDispatchId,
vendorIdHint,
replaceInactivePrimary,
relaxInvalidDispatch: vendorIdHint.HasValue,
ct);
dispatch = resolved.Dispatch;
@ -206,12 +207,19 @@ 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 mustReplace = existing != null
&& replaceInactivePrimary
&& (WorkOrderBoardDispatchAssignment.IsInactivePrimaryDispatch(existing.Status)
|| IsSharedDispatch(existing, workOrder.Id));
if (existing != null && !mustReplace)
{
workOrder.PrimaryDispatchId = existing.Id;
return new ResolvedDispatch(existing, Created: false, Previous: null);
@ -238,29 +246,60 @@ namespace SeaHaven.Services.Implementation
private async Task<Dispatch?> 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)

View file

@ -483,6 +483,212 @@ 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 });
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);
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);
}
[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<WorkOrderBoardValidationException>(() =>
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()
{