mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
Serialize sync cancels and vendor uplift requests on the work order lock
The legacy ingest batch holds the per-work-order lock, taken in id order, for every work order it may cancel until the batch commits. A vendor uplift request now runs under the same lock. Uplift creation on both routes refuses a work order cancelled by either lifecycle or status text, so a request can neither slip past a cancel nor land after one.
This commit is contained in:
parent
77dc3d85c3
commit
a32471d4c0
13 changed files with 323 additions and 72 deletions
|
|
@ -230,6 +230,11 @@ public sealed class UpliftWorkflowTests
|
||||||
.Returns(Task.CompletedTask);
|
.Returns(Task.CompletedTask);
|
||||||
upliftData.Setup(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
upliftData.Setup(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException());
|
.ThrowsAsync(new SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException());
|
||||||
|
upliftData.Setup(x => x.ExecuteWorkOrderMutationAsync(
|
||||||
|
It.IsAny<int>(),
|
||||||
|
It.IsAny<Func<CancellationToken, Task<DispatchUpliftRequest>>>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.Returns((int _, Func<CancellationToken, Task<DispatchUpliftRequest>> work, CancellationToken ct) => work(ct));
|
||||||
|
|
||||||
var service = NewPortalService(context, new FakeEmailSender(deliver: true), upliftData: upliftData.Object);
|
var service = NewPortalService(context, new FakeEmailSender(deliver: true), upliftData: upliftData.Object);
|
||||||
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
|
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
|
||||||
|
|
@ -1041,4 +1046,69 @@ public sealed class UpliftWorkflowTests
|
||||||
|
|
||||||
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound);
|
result.Outcome.Should().Be(VendorDocumentDownloadOutcome.NotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- A cancelled work order takes no new uplift request ---
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true, "Canceled")]
|
||||||
|
[InlineData(false, "Cancelled")]
|
||||||
|
public async Task RequestUplift_OnCancelledWorkOrder_IsRefusedAndCreatesNothing(bool lifecycleCanceled, string statusText)
|
||||||
|
{
|
||||||
|
using var context = NewContext();
|
||||||
|
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||||
|
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
|
||||||
|
workOrder.Status = statusText;
|
||||||
|
if (lifecycleCanceled)
|
||||||
|
workOrder.LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Canceled;
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
|
||||||
|
|
||||||
|
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
|
||||||
|
var act = () => service.RequestUpliftAsync(session!, dispatch.Id, 3500m, "reason", null, 1, CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>().WithMessage("*cancelled work order*");
|
||||||
|
context.DispatchUpliftRequests.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestUplift_WaitsForAWorkOrderCancelInFlightAndIsThenRefused()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
using var context = new ApplicationDbContext(options);
|
||||||
|
var (vendor, workOrder, dispatch) = await SeedAsync(context, nte: 1000m);
|
||||||
|
context.VendorCompletionDocuments.Add(EvidenceDocument(vendor.Id, dispatch.Id, workOrder.Id));
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
var service = NewPortalService(context, new FakeEmailSender(deliver: true));
|
||||||
|
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
|
||||||
|
|
||||||
|
// A work order cancel holding the work order's lock, committing only after the
|
||||||
|
// vendor's request has started.
|
||||||
|
var cancelEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var releaseCancel = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
using var cancelContext = new ApplicationDbContext(options);
|
||||||
|
var cancel = new UpliftDataService(cancelContext).ExecuteWorkOrderMutationAsync(workOrder.Id, async ct =>
|
||||||
|
{
|
||||||
|
cancelEntered.SetResult();
|
||||||
|
await releaseCancel.Task;
|
||||||
|
var tracked = await cancelContext.workOrders.SingleAsync(w => w.Id == workOrder.Id, ct);
|
||||||
|
tracked.LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Canceled;
|
||||||
|
await cancelContext.SaveChangesAsync(ct);
|
||||||
|
return true;
|
||||||
|
}, CancellationToken.None);
|
||||||
|
await cancelEntered.Task;
|
||||||
|
|
||||||
|
var request = service.RequestUpliftAsync(session!, dispatch.Id, 3500m, "reason", null, 1, CancellationToken.None);
|
||||||
|
var finishedFirst = await Task.WhenAny(request, Task.Delay(TimeSpan.FromSeconds(2)));
|
||||||
|
finishedFirst.Should().NotBeSameAs(request, "the request must wait for the cancel holding the work order lock");
|
||||||
|
|
||||||
|
releaseCancel.SetResult();
|
||||||
|
await cancel;
|
||||||
|
|
||||||
|
await FluentActions.Awaiting(() => request).Should().ThrowAsync<InvalidOperationException>()
|
||||||
|
.WithMessage("*cancelled work order*");
|
||||||
|
using var verify = new ApplicationDbContext(options);
|
||||||
|
verify.DispatchUpliftRequests.Should().BeEmpty();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,13 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
public static bool AppliesToStatusText(string? before, string? after)
|
public static bool AppliesToStatusText(string? before, string? after)
|
||||||
=> !IsCancelledText(before) && IsCancelledText(after);
|
=> !IsCancelledText(before) && IsCancelledText(after);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when a work order is cancelled by either signal: its lifecycle status, or the
|
||||||
|
/// status text a sync cancel writes. No new uplift may be requested on it.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsCancelled(LifecycleStatus? lifecycle, string? statusText)
|
||||||
|
=> lifecycle == LifecycleStatus.Canceled || IsCancelledText(statusText);
|
||||||
|
|
||||||
private static bool IsCancelledText(string? status)
|
private static bool IsCancelledText(string? status)
|
||||||
=> string.Equals(status, "Cancelled", StringComparison.OrdinalIgnoreCase)
|
=> string.Equals(status, "Cancelled", StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(status, "Canceled", StringComparison.OrdinalIgnoreCase);
|
|| string.Equals(status, "Canceled", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
|
||||||
|
|
@ -20,23 +20,45 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
/// transaction commits when the work returns and <paramref name="commitWhen"/> (when
|
/// transaction commits when the work returns and <paramref name="commitWhen"/> (when
|
||||||
/// given) accepts its result; otherwise, or when the work throws, it rolls back.
|
/// given) accepts its result; otherwise, or when the work throws, it rolls back.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static async Task<T> RunAsync<T>(
|
public static Task<T> RunAsync<T>(
|
||||||
ApplicationDbContext context,
|
ApplicationDbContext context,
|
||||||
int workOrderId,
|
int workOrderId,
|
||||||
Func<CancellationToken, Task<T>> work,
|
Func<CancellationToken, Task<T>> work,
|
||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
Func<T, bool>? commitWhen = null)
|
Func<T, bool>? commitWhen = null)
|
||||||
|
=> RunManyAsync(context, new[] { workOrderId }, work, cancellationToken, commitWhen);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="RunAsync{T}"/> for a unit of work that spans several work orders (a sync
|
||||||
|
/// batch). Gates and row locks are taken in ascending id order, so two batches never
|
||||||
|
/// wait on each other in a cycle; an empty set runs the work in a plain transaction.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<T> RunManyAsync<T>(
|
||||||
|
ApplicationDbContext context,
|
||||||
|
IEnumerable<int> workOrderIds,
|
||||||
|
Func<CancellationToken, Task<T>> work,
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
Func<T, bool>? commitWhen = null)
|
||||||
{
|
{
|
||||||
var gate = Gates.GetOrAdd(workOrderId, _ => new SemaphoreSlim(1, 1));
|
var ids = workOrderIds.Distinct().OrderBy(id => id).ToList();
|
||||||
await gate.WaitAsync(cancellationToken);
|
var held = new List<SemaphoreSlim>(ids.Count);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
foreach (var id in ids)
|
||||||
|
{
|
||||||
|
var gate = Gates.GetOrAdd(id, _ => new SemaphoreSlim(1, 1));
|
||||||
|
await gate.WaitAsync(cancellationToken);
|
||||||
|
held.Add(gate);
|
||||||
|
}
|
||||||
|
|
||||||
await using var transaction = context.Database.IsRelational()
|
await using var transaction = context.Database.IsRelational()
|
||||||
? await context.Database.BeginTransactionAsync(cancellationToken)
|
? await context.Database.BeginTransactionAsync(cancellationToken)
|
||||||
: null;
|
: null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await LockRowAsync(context, workOrderId, cancellationToken);
|
foreach (var id in ids)
|
||||||
|
await LockRowAsync(context, id, cancellationToken);
|
||||||
|
|
||||||
var result = await work(cancellationToken);
|
var result = await work(cancellationToken);
|
||||||
if (transaction is not null)
|
if (transaction is not null)
|
||||||
{
|
{
|
||||||
|
|
@ -56,7 +78,8 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
gate.Release();
|
foreach (var gate in held)
|
||||||
|
gate.Release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -465,6 +465,17 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
now,
|
now,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
|
public async Task<bool> IsWorkOrderCancelledAsync(int workOrderId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var workOrder = await _context.workOrders
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(w => w.Id == workOrderId)
|
||||||
|
.Select(w => new { w.LifecycleStatus, w.Status })
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
return workOrder is not null
|
||||||
|
&& PendingUpliftCancellation.IsCancelled(workOrder.LifecycleStatus, workOrder.Status);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken)
|
public async Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return await _context.DispatchUpliftRequests
|
return await _context.DispatchUpliftRequests
|
||||||
|
|
|
||||||
|
|
@ -42,25 +42,28 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
DateTime.UtcNow,
|
DateTime.UtcNow,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
public async Task ExecuteTransactionalAsync(Func<CancellationToken, Task> work, CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<int>> GetWorkOrderIdsByExternalIdsAsync(
|
||||||
{
|
IReadOnlyCollection<string> externalWorkOrderIds,
|
||||||
await using var transaction = _context.Database.IsRelational()
|
CancellationToken cancellationToken)
|
||||||
? await _context.Database.BeginTransactionAsync(cancellationToken)
|
=> await _context.workOrders
|
||||||
: null;
|
.AsNoTracking()
|
||||||
|
.Where(w => w.ExternalWorkOrderId != null && externalWorkOrderIds.Contains(w.ExternalWorkOrderId))
|
||||||
|
.Select(w => w.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
try
|
public Task ExecuteTransactionalAsync(
|
||||||
{
|
IReadOnlyCollection<int> lockedWorkOrderIds,
|
||||||
await work(cancellationToken);
|
Func<CancellationToken, Task> work,
|
||||||
if (transaction is not null)
|
CancellationToken cancellationToken)
|
||||||
await transaction.CommitAsync(cancellationToken);
|
=> WorkOrderMutationLock.RunManyAsync(
|
||||||
}
|
_context,
|
||||||
catch
|
lockedWorkOrderIds,
|
||||||
{
|
async ct =>
|
||||||
if (transaction is not null)
|
{
|
||||||
await transaction.RollbackAsync(cancellationToken);
|
await work(ct);
|
||||||
throw;
|
return true;
|
||||||
}
|
},
|
||||||
}
|
cancellationToken);
|
||||||
|
|
||||||
public Task SaveAsync(CancellationToken cancellationToken)
|
public Task SaveAsync(CancellationToken cancellationToken)
|
||||||
=> _context.SaveChangesAsync(cancellationToken);
|
=> _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,9 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
string? actorId,
|
string? actorId,
|
||||||
DateTime now,
|
DateTime now,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
// True when the work order is cancelled (PendingUpliftCancellation.IsCancelled), so no
|
||||||
|
// new uplift may be requested on it.
|
||||||
|
Task<bool> IsWorkOrderCancelledAsync(int workOrderId, CancellationToken cancellationToken);
|
||||||
// SH-101: active = Pending or ChangesRequested (the only states that block a new request).
|
// SH-101: active = Pending or ChangesRequested (the only states that block a new request).
|
||||||
Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken);
|
Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken);
|
||||||
Task<DispatchUpliftRequest?> GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken);
|
Task<DispatchUpliftRequest?> GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,15 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
// Stages (does not save) the sync-attributed cancellation of the work order's pending
|
// Stages (does not save) the sync-attributed cancellation of the work order's pending
|
||||||
// uplifts, each with its own audit row. See PendingUpliftCancellation.
|
// uplifts, each with its own audit row. See PendingUpliftCancellation.
|
||||||
Task<int> StageCancelPendingUpliftsAsync(int workOrderId, CancellationToken cancellationToken);
|
Task<int> StageCancelPendingUpliftsAsync(int workOrderId, CancellationToken cancellationToken);
|
||||||
Task ExecuteTransactionalAsync(Func<CancellationToken, Task> work, CancellationToken cancellationToken);
|
Task<IReadOnlyList<int>> GetWorkOrderIdsByExternalIdsAsync(
|
||||||
|
IReadOnlyCollection<string> externalWorkOrderIds,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
// Runs the batch in one transaction, holding the per-work-order lock on
|
||||||
|
// lockedWorkOrderIds (see WorkOrderMutationLock) until it commits or rolls back.
|
||||||
|
Task ExecuteTransactionalAsync(
|
||||||
|
IReadOnlyCollection<int> lockedWorkOrderIds,
|
||||||
|
Func<CancellationToken, Task> work,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
Task SaveAsync(CancellationToken cancellationToken);
|
Task SaveAsync(CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -670,54 +670,75 @@ namespace SeaHaven.Services.Implementation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// At most one active (Pending or ChangesRequested) request per dispatch.
|
var vendorReason = reason.Trim();
|
||||||
var activeExists = await _upliftData.HasActiveAsync(id, cancellationToken);
|
var evidenceId = evidence.Id;
|
||||||
if (activeExists)
|
|
||||||
|
// The active-request check and the insert run under the per-work-order lock a work
|
||||||
|
// order cancel takes: a cancel either sees this request and cancels it, or this
|
||||||
|
// request sees the cancelled work order and is refused.
|
||||||
|
async Task<DispatchUpliftRequest> PersistRequestAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("An active uplift request already exists for this dispatch");
|
if (dispatch.WorkOrderId is int workOrderId
|
||||||
|
&& await _upliftData.IsWorkOrderCancelledAsync(workOrderId, ct))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Cannot request uplift on a cancelled work order");
|
||||||
|
}
|
||||||
|
|
||||||
|
// At most one active (Pending or ChangesRequested) request per dispatch.
|
||||||
|
var activeExists = await _upliftData.HasActiveAsync(id, ct);
|
||||||
|
if (activeExists)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("An active uplift request already exists for this dispatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
var tier1Max = _approvalsOptions.UpliftTier1MaxUsd ?? 2500m;
|
||||||
|
var delta = requestedNTE - current;
|
||||||
|
var requiredTier = delta > tier1Max ? 2 : 1;
|
||||||
|
var expiresAt = now + _approvalsOptions.EffectiveExpiration;
|
||||||
|
|
||||||
|
var request = new DispatchUpliftRequest
|
||||||
|
{
|
||||||
|
DispatchId = id,
|
||||||
|
CurrentNTE = current,
|
||||||
|
RequestedNTE = requestedNTE,
|
||||||
|
VendorReason = vendorReason,
|
||||||
|
Status = UpliftStatus.Pending,
|
||||||
|
RequiredTier = requiredTier,
|
||||||
|
RequestedByVendorName = session.CompanyName,
|
||||||
|
EvidenceDocumentId = evidenceId,
|
||||||
|
RequestKey = string.IsNullOrWhiteSpace(requestKey) ? null : requestKey.Trim(),
|
||||||
|
ExpiresAt = expiresAt,
|
||||||
|
NotificationStatus = UpliftNotificationStatus.Pending,
|
||||||
|
CreatedDate = now
|
||||||
|
};
|
||||||
|
await _upliftData.StageAsync(request, ct);
|
||||||
|
|
||||||
|
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
||||||
|
{
|
||||||
|
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||||
|
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
|
||||||
|
OldValue = $"${current:F2}",
|
||||||
|
NewValue = $"${requestedNTE:F2}",
|
||||||
|
Action = "uplift_requested",
|
||||||
|
ActorType = "vendor",
|
||||||
|
CreatedAt = now
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _upliftData.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException)
|
||||||
|
{
|
||||||
|
throw new UpliftConflictException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
var tier1Max = _approvalsOptions.UpliftTier1MaxUsd ?? 2500m;
|
var req = dispatch.WorkOrderId is int lockedWorkOrderId
|
||||||
var delta = requestedNTE - current;
|
? await _upliftData.ExecuteWorkOrderMutationAsync(lockedWorkOrderId, PersistRequestAsync, cancellationToken)
|
||||||
var requiredTier = delta > tier1Max ? 2 : 1;
|
: await PersistRequestAsync(cancellationToken);
|
||||||
var expiresAt = now + _approvalsOptions.EffectiveExpiration;
|
|
||||||
|
|
||||||
var req = new DispatchUpliftRequest
|
|
||||||
{
|
|
||||||
DispatchId = id,
|
|
||||||
CurrentNTE = current,
|
|
||||||
RequestedNTE = requestedNTE,
|
|
||||||
VendorReason = reason.Trim(),
|
|
||||||
Status = UpliftStatus.Pending,
|
|
||||||
RequiredTier = requiredTier,
|
|
||||||
RequestedByVendorName = session.CompanyName,
|
|
||||||
EvidenceDocumentId = evidence.Id,
|
|
||||||
RequestKey = string.IsNullOrWhiteSpace(requestKey) ? null : requestKey.Trim(),
|
|
||||||
ExpiresAt = expiresAt,
|
|
||||||
NotificationStatus = UpliftNotificationStatus.Pending,
|
|
||||||
CreatedDate = now
|
|
||||||
};
|
|
||||||
await _upliftData.StageAsync(req, cancellationToken);
|
|
||||||
|
|
||||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
|
||||||
{
|
|
||||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
|
||||||
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
|
|
||||||
OldValue = $"${current:F2}",
|
|
||||||
NewValue = $"${requestedNTE:F2}",
|
|
||||||
Action = "uplift_requested",
|
|
||||||
ActorType = "vendor",
|
|
||||||
CreatedAt = now
|
|
||||||
}, cancellationToken);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _upliftData.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
catch (SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException)
|
|
||||||
{
|
|
||||||
throw new UpliftConflictException();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notification is best-effort and persisted separately from workflow state: a failure
|
// Notification is best-effort and persisted separately from workflow state: a failure
|
||||||
// never destroys the actionable request (the lifecycle sweep retries by sentinel).
|
// never destroys the actionable request (the lifecycle sweep retries by sentinel).
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,20 @@ namespace SeaHaven.Services.Implementation
|
||||||
if (items.Count == 0)
|
if (items.Count == 0)
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
await _ingestData.ExecuteTransactionalAsync(async cancellationToken =>
|
// Existing work orders this batch may cancel hold the per-work-order lock until the
|
||||||
|
// batch commits, so an uplift create on one of them either commits first (and is
|
||||||
|
// cancelled below) or waits and then sees the work order cancelled.
|
||||||
|
var cancellingExternalIds = items
|
||||||
|
.Where(item => !string.IsNullOrWhiteSpace(item.ExternalWorkOrderId)
|
||||||
|
&& PendingUpliftCancellation.IsCancelled(null, WorkOrderIngestFieldMapper.MapStatus(item.WoStatus)))
|
||||||
|
.Select(item => item.ExternalWorkOrderId)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
var lockedWorkOrderIds = cancellingExternalIds.Count == 0
|
||||||
|
? Array.Empty<int>()
|
||||||
|
: await _ingestData.GetWorkOrderIdsByExternalIdsAsync(cancellingExternalIds, cancellationToken);
|
||||||
|
|
||||||
|
await _ingestData.ExecuteTransactionalAsync(lockedWorkOrderIds, async cancellationToken =>
|
||||||
{
|
{
|
||||||
var nextSeed = await AllocateNextWoSeedAsync(cancellationToken);
|
var nextSeed = await AllocateNextWoSeedAsync(cancellationToken);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ using System.Security.Claims;
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Data.SeaHavenIndustries.Enums;
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using SeaHaven.DataServices.Helpers;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.Services.Configuration;
|
using SeaHaven.Services.Configuration;
|
||||||
using SeaHaven.Services.Constants;
|
using SeaHaven.Services.Constants;
|
||||||
|
|
@ -139,10 +140,14 @@ namespace SeaHaven.Services.Implementation
|
||||||
if (workOrder == null)
|
if (workOrder == null)
|
||||||
throw new InvalidOperationException("Work order has no primary dispatch for uplift requests");
|
throw new InvalidOperationException("Work order has no primary dispatch for uplift requests");
|
||||||
|
|
||||||
if (workOrder.LifecycleStatus is LifecycleStatus.Completed or LifecycleStatus.Canceled)
|
if (workOrder.LifecycleStatus == LifecycleStatus.Completed
|
||||||
|
|| PendingUpliftCancellation.IsCancelled(workOrder.LifecycleStatus, workOrder.Status))
|
||||||
{
|
{
|
||||||
|
var closedAs = workOrder.LifecycleStatus == LifecycleStatus.Completed
|
||||||
|
? LifecycleStatus.Completed
|
||||||
|
: LifecycleStatus.Canceled;
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Cannot create an uplift on a '{workOrder.LifecycleStatus}' work order");
|
$"Cannot create an uplift on a '{closedAs}' work order");
|
||||||
}
|
}
|
||||||
|
|
||||||
// SH-393: write to a dispatch the work order's uplift reads resolve back to it.
|
// SH-393: write to a dispatch the work order's uplift reads resolve back to it.
|
||||||
|
|
|
||||||
|
|
@ -462,6 +462,7 @@ public class WorkOrderBoardCancelServiceTests
|
||||||
public Task<IReadOnlyList<WorkOrderUpliftExposureData>> GetApprovedExposureForWorkOrdersAsync(IReadOnlyCollection<int> workOrderIds, CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<IReadOnlyList<WorkOrderUpliftExposureData>> GetApprovedExposureForWorkOrdersAsync(IReadOnlyCollection<int> workOrderIds, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
public Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
public Task<int> StageCancelPendingForWorkOrderAsync(int workOrderId, string? actorId, DateTime now, CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<int> StageCancelPendingForWorkOrderAsync(int workOrderId, string? actorId, DateTime now, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
|
public Task<bool> IsWorkOrderCancelledAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
public Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
public Task<DispatchUpliftRequest?> GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<DispatchUpliftRequest?> GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
public Task<DispatchUpliftRequest?> GetByRequestKeyAsync(int dispatchId, string requestKey, CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<DispatchUpliftRequest?> GetByRequestKeyAsync(int dispatchId, string requestKey, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,72 @@ public sealed class WorkOrderCrmCancelUpliftTests
|
||||||
Assert.DoesNotContain(verify.WorkOrderAuditLogs, log => log.Action == "uplift_cancel");
|
Assert.DoesNotContain(verify.WorkOrderAuditLogs, log => log.Action == "uplift_cancel");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LegacyIngestCancel_WaitsForAnUpliftCreateInFlightAndCancelsWhatItCommitted()
|
||||||
|
{
|
||||||
|
var options = await SeedInMemoryAsync();
|
||||||
|
var createEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var releaseCreate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
await using var createContext = new ApplicationDbContext(options);
|
||||||
|
var create = new UpliftDataService(createContext).ExecuteWorkOrderMutationAsync(1, async ct =>
|
||||||
|
{
|
||||||
|
createEntered.SetResult();
|
||||||
|
await releaseCreate.Task;
|
||||||
|
createContext.DispatchUpliftRequests.Add(WorkOrderBoardPatchCancelUpliftTests.Uplift(200, "Pending", 800m));
|
||||||
|
await createContext.SaveChangesAsync(ct);
|
||||||
|
return true;
|
||||||
|
}, CancellationToken.None);
|
||||||
|
await createEntered.Task;
|
||||||
|
|
||||||
|
await using var ingestContext = new ApplicationDbContext(options);
|
||||||
|
var ingest = NewIngestService(ingestContext).UpsertBatchAsync(new[]
|
||||||
|
{
|
||||||
|
new WorkOrderIngestPayloadDto { ExternalWorkOrderId = "123", WoStatus = "cancelled" }
|
||||||
|
});
|
||||||
|
|
||||||
|
var finishedFirst = await Task.WhenAny(ingest, Task.Delay(TimeSpan.FromSeconds(2)));
|
||||||
|
Assert.NotSame(ingest, finishedFirst);
|
||||||
|
|
||||||
|
releaseCreate.SetResult();
|
||||||
|
await create;
|
||||||
|
await ingest;
|
||||||
|
|
||||||
|
await using var verify = new ApplicationDbContext(options);
|
||||||
|
Assert.Equal("Cancelled", verify.workOrders.Single().Status);
|
||||||
|
Assert.Equal(UpliftStatus.Withdrawn, verify.DispatchUpliftRequests.Single(u => u.Id == 200).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LegacyIngestCancel_OnARelationalStore_CancelsPendingUpliftInTheBatchTransaction()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("DataSource=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
await using (var seed = new WorkOrderBoardPatchCancelUpliftTests.SqliteRowVersionDbContext(options))
|
||||||
|
{
|
||||||
|
await seed.Database.EnsureCreatedAsync();
|
||||||
|
await WorkOrderBoardPatchCancelUpliftTests.SeedWorkOrderAsync(seed);
|
||||||
|
seed.DispatchUpliftRequests.Add(WorkOrderBoardPatchCancelUpliftTests.Uplift(100, "Pending", 700m));
|
||||||
|
await seed.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var context = new WorkOrderBoardPatchCancelUpliftTests.SqliteRowVersionDbContext(options))
|
||||||
|
{
|
||||||
|
await NewIngestService(context).UpsertBatchAsync(new[]
|
||||||
|
{
|
||||||
|
new WorkOrderIngestPayloadDto { ExternalWorkOrderId = "123", WoStatus = "cancelled" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var verify = new WorkOrderBoardPatchCancelUpliftTests.SqliteRowVersionDbContext(options);
|
||||||
|
Assert.Equal("Cancelled", (await verify.workOrders.AsNoTracking().SingleAsync()).Status);
|
||||||
|
Assert.Equal(UpliftStatus.Withdrawn, (await verify.DispatchUpliftRequests.AsNoTracking().SingleAsync()).Status);
|
||||||
|
Assert.Single(await verify.WorkOrderAuditLogs.Where(log => log.Action == "uplift_cancel").ToListAsync());
|
||||||
|
}
|
||||||
|
|
||||||
private static WorkOrderIngestService NewIngestService(ApplicationDbContext context)
|
private static WorkOrderIngestService NewIngestService(ApplicationDbContext context)
|
||||||
{
|
{
|
||||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||||
|
|
|
||||||
|
|
@ -603,6 +603,26 @@ public sealed class WorkOrderUpliftServiceTests
|
||||||
Assert.Equal(1500m, context.Dispatches.Single(d => d.Id == 10).NTEAmount);
|
Assert.Equal(1500m, context.Dispatches.Single(d => d.Id == 10).NTEAmount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_WorkOrderCancelledBySyncStatusText_IsRefusedAndCreatesNothing()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var (workOrder, _) = await SeedWorkOrderAsync(context);
|
||||||
|
// The legacy ingest cancels a work order by its status text alone.
|
||||||
|
workOrder.Status = "Cancelled";
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
var service = NewService(context);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => service.CreateAsync(
|
||||||
|
workOrder.Id,
|
||||||
|
new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "After cancel" },
|
||||||
|
Dispatcher(),
|
||||||
|
CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.Empty(context.DispatchUpliftRequests);
|
||||||
|
Assert.Equal(1000m, context.Dispatches.Single(d => d.Id == 10).NTEAmount);
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(LifecycleStatus.Completed)]
|
[InlineData(LifecycleStatus.Completed)]
|
||||||
[InlineData(LifecycleStatus.Canceled)]
|
[InlineData(LifecycleStatus.Canceled)]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue