Merge pull request #100 from Sea-Haven-Industries/fix/sh-296-vendor-invalid-dispatch

fix(work-orders): accept linked primary dispatch on vendor board patch
This commit is contained in:
Arthur Bassi 2026-09-01 09:50:40 -03:00 • committed by GitHub
commit 5f6069547d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 478 additions and 16 deletions

View file

@ -25,12 +25,57 @@ 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 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<DispatchWorkOrder>())
.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);

View file

@ -15,6 +15,7 @@ namespace SeaHaven.DataServices.Interfaces
Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
Task<Dispatch?> GetTrackedDispatchAsync(int dispatchId, int workOrderId, CancellationToken cancellationToken);
Task<Dispatch?> 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);

View file

@ -88,6 +88,7 @@ namespace SeaHaven.Services.Implementation
request.PrimaryDispatchId,
vendorIdHint,
replaceInactivePrimary,
relaxInvalidDispatch: vendorIdHint.HasValue,
ct);
dispatch = resolved.Dispatch;
@ -110,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);
@ -206,12 +210,22 @@ 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 vendorChanged = existing != null
&& vendorIdForCreate.HasValue
&& existing.VendorId != vendorIdForCreate.Value;
var mustReplace = existing != null
&& replaceInactivePrimary
&& (WorkOrderBoardDispatchAssignment.IsInactivePrimaryDispatch(existing.Status)
|| (IsSharedDispatch(existing, workOrder.Id) && vendorChanged));
if (existing != null && !mustReplace)
{
workOrder.PrimaryDispatchId = existing.Id;
return new ResolvedDispatch(existing, Created: false, Previous: null);
@ -238,29 +252,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

@ -392,6 +392,9 @@ public class WorkOrderBoardCancelServiceTests
public Task<Dispatch?> 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);

View file

@ -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<int[]> 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<bool> 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()
{
@ -483,6 +504,353 @@ 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 });
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
{
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);
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]
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()
{