mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
Merge pull request #76 from Sea-Haven-Industries/fix/sh-183-vendor-patch-new-dispatch
fix(work-orders): create new dispatch when assigning vendor after cancel or refuse
This commit is contained in:
commit
7350da3e2f
2 changed files with 206 additions and 37 deletions
|
|
@ -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;
|
||||
|
|
@ -68,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)
|
||||
|
|
@ -75,30 +77,62 @@ 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;
|
||||
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);
|
||||
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>
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -119,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);
|
||||
|
|
@ -163,25 +192,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);
|
||||
|
||||
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)
|
||||
|
|
@ -197,9 +223,37 @@ namespace SeaHaven.Services.Implementation
|
|||
CreatedDate = DateTime.UtcNow
|
||||
};
|
||||
_mutationData.TrackNewDispatch(created);
|
||||
await _mutationData.SaveAsync(cancellationToken);
|
||||
await SaveTrackedOrThrowAsync(workOrder.Id, cancellationToken);
|
||||
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)
|
||||
|
|
@ -208,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<FieldChange> ApplyWoNumber(WorkOrder workOrder, string? value, string auditField)
|
||||
{
|
||||
if (!WorkOrderNumberNormalizer.TryNormalize(value, out var normalized, out var error))
|
||||
|
|
|
|||
|
|
@ -378,6 +378,111 @@ 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);
|
||||
|
||||
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]
|
||||
[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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue