mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-06 08:52:09 +00:00
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:
parent
58dd5ec6ae
commit
ca0404272e
3 changed files with 267 additions and 16 deletions
|
|
@ -25,12 +25,18 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
public Task<Dispatch?> GetTrackedDispatchAsync(int dispatchId, int workOrderId, CancellationToken cancellationToken)
|
public Task<Dispatch?> GetTrackedDispatchAsync(int dispatchId, int workOrderId, CancellationToken cancellationToken)
|
||||||
=> _context.Dispatches.FirstOrDefaultAsync(
|
=> _context.Dispatches
|
||||||
d => d.Id == dispatchId && d.WorkOrderId == workOrderId,
|
.Include(d => d.DispatchWorkOrders)
|
||||||
cancellationToken);
|
.FirstOrDefaultAsync(
|
||||||
|
d => d.Id == dispatchId
|
||||||
|
&& (d.WorkOrderId == workOrderId
|
||||||
|
|| d.DispatchWorkOrders!.Any(link => link.WorkOrderId == workOrderId)),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
public Task<Dispatch?> GetTrackedDispatchByIdAsync(int dispatchId, CancellationToken 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)
|
public void TrackNewWorkOrder(WorkOrder workOrder)
|
||||||
=> _context.workOrders.Add(workOrder);
|
=> _context.workOrders.Add(workOrder);
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
request.PrimaryDispatchId,
|
request.PrimaryDispatchId,
|
||||||
vendorIdHint,
|
vendorIdHint,
|
||||||
replaceInactivePrimary,
|
replaceInactivePrimary,
|
||||||
|
relaxInvalidDispatch: vendorIdHint.HasValue,
|
||||||
ct);
|
ct);
|
||||||
dispatch = resolved.Dispatch;
|
dispatch = resolved.Dispatch;
|
||||||
|
|
||||||
|
|
@ -206,12 +207,19 @@ namespace SeaHaven.Services.Implementation
|
||||||
int? primaryDispatchId,
|
int? primaryDispatchId,
|
||||||
int? vendorIdForCreate,
|
int? vendorIdForCreate,
|
||||||
bool replaceInactivePrimary,
|
bool replaceInactivePrimary,
|
||||||
|
bool relaxInvalidDispatch,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var existing = await LoadExistingDispatchAsync(workOrder, primaryDispatchId, cancellationToken);
|
var existing = await LoadExistingDispatchAsync(
|
||||||
if (existing != null
|
workOrder,
|
||||||
&& !(replaceInactivePrimary
|
primaryDispatchId,
|
||||||
&& WorkOrderBoardDispatchAssignment.IsInactivePrimaryDispatch(existing.Status)))
|
relaxInvalidDispatch,
|
||||||
|
cancellationToken);
|
||||||
|
var mustReplace = existing != null
|
||||||
|
&& replaceInactivePrimary
|
||||||
|
&& (WorkOrderBoardDispatchAssignment.IsInactivePrimaryDispatch(existing.Status)
|
||||||
|
|| IsSharedDispatch(existing, workOrder.Id));
|
||||||
|
if (existing != null && !mustReplace)
|
||||||
{
|
{
|
||||||
workOrder.PrimaryDispatchId = existing.Id;
|
workOrder.PrimaryDispatchId = existing.Id;
|
||||||
return new ResolvedDispatch(existing, Created: false, Previous: null);
|
return new ResolvedDispatch(existing, Created: false, Previous: null);
|
||||||
|
|
@ -238,29 +246,60 @@ namespace SeaHaven.Services.Implementation
|
||||||
private async Task<Dispatch?> LoadExistingDispatchAsync(
|
private async Task<Dispatch?> LoadExistingDispatchAsync(
|
||||||
WorkOrder workOrder,
|
WorkOrder workOrder,
|
||||||
int? primaryDispatchId,
|
int? primaryDispatchId,
|
||||||
|
bool relaxInvalidDispatch,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (primaryDispatchId.HasValue)
|
var clientId = NormalizeClientPrimaryDispatchId(primaryDispatchId);
|
||||||
|
if (clientId.HasValue)
|
||||||
{
|
{
|
||||||
var dispatch = await _mutationData.GetTrackedDispatchAsync(
|
var dispatch = await _mutationData.GetTrackedDispatchAsync(
|
||||||
primaryDispatchId.Value,
|
clientId.Value,
|
||||||
workOrder.Id,
|
workOrder.Id,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
if (dispatch == null)
|
if (dispatch != null)
|
||||||
|
return dispatch;
|
||||||
|
|
||||||
|
if (!relaxInvalidDispatch)
|
||||||
throw new WorkOrderBoardValidationException(
|
throw new WorkOrderBoardValidationException(
|
||||||
"InvalidDispatch",
|
"InvalidDispatch",
|
||||||
"Primary dispatch does not belong to this work order.");
|
"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 null;
|
||||||
|
|
||||||
return await _mutationData.GetTrackedDispatchByIdAsync(
|
var stored = await _mutationData.GetTrackedDispatchByIdAsync(
|
||||||
workOrder.PrimaryDispatchId.Value,
|
storedId.Value,
|
||||||
cancellationToken);
|
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)
|
private async Task EnsureVendorExistsAsync(int vendorId)
|
||||||
|
|
|
||||||
|
|
@ -483,6 +483,212 @@ public class WorkOrderBoardUpdateServiceTests
|
||||||
Assert.Equal(liveStatus, updated.Status);
|
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]
|
[Fact]
|
||||||
public async Task PatchField_CreatesFieldLockAndAuditPerField()
|
public async Task PatchField_CreatesFieldLockAndAuditPerField()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue