mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-05 18:52:11 +00:00
Merge pull request #197 from Sea-Haven-Industries/fix/ab/sh-407-cancel-wo-cancels-uplift
Cancelling a work order cancels its pending uplift
This commit is contained in:
commit
1077d489a8
27 changed files with 1242 additions and 184 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
97
SeaHaven.DataServices/Helpers/PendingUpliftCancellation.cs
Normal file
97
SeaHaven.DataServices/Helpers/PendingUpliftCancellation.cs
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Helpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The single rule and write for "a work order that becomes Canceled cancels its pending
|
||||||
|
/// uplifts in the same action". Every path that can cancel a work order (board update,
|
||||||
|
/// CRM webhook and reconciliation, legacy ingest) asks <see cref="Applies"/> and
|
||||||
|
/// stages the cancellation into its own unit of work, so the uplift change and its own
|
||||||
|
/// audit row commit, or roll back, with the work order's status change.
|
||||||
|
/// </summary>
|
||||||
|
public static class PendingUpliftCancellation
|
||||||
|
{
|
||||||
|
public const string AuditAction = "uplift_cancel";
|
||||||
|
public const string CancelledStatus = "Withdrawn";
|
||||||
|
|
||||||
|
private static readonly string[] PendingStatuses = { "Pending", "ChangesRequested" };
|
||||||
|
|
||||||
|
/// <summary>True when a lifecycle change moves a work order into Canceled.</summary>
|
||||||
|
public static bool Applies(LifecycleStatus? before, LifecycleStatus? after)
|
||||||
|
=> before != LifecycleStatus.Canceled && after == LifecycleStatus.Canceled;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when a status-text change moves a work order into Cancelled. The legacy ingest
|
||||||
|
/// sync writes only the status text, never the lifecycle status.
|
||||||
|
/// </summary>
|
||||||
|
public static bool AppliesToStatusText(string? before, string? 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)
|
||||||
|
=> string.Equals(status, "Cancelled", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(status, "Canceled", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Uplift requests that belong to a work order: on a live dispatch that the work
|
||||||
|
/// order owns or is linked to.
|
||||||
|
/// </summary>
|
||||||
|
internal static IQueryable<DispatchUpliftRequest> ForWorkOrder(ApplicationDbContext context, int workOrderId)
|
||||||
|
=> context.DispatchUpliftRequests.Where(u =>
|
||||||
|
(u.IsDeleted == null || u.IsDeleted == false)
|
||||||
|
&& u.Dispatch != null
|
||||||
|
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
|
||||||
|
&& (
|
||||||
|
u.Dispatch.WorkOrderId == workOrderId
|
||||||
|
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == workOrderId)));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stages (does not save) the cancellation of every pending uplift on the work order,
|
||||||
|
/// with one audit row per uplift. Returns how many uplifts were cancelled.
|
||||||
|
/// </summary>
|
||||||
|
internal static async Task<int> StageAsync(
|
||||||
|
ApplicationDbContext context,
|
||||||
|
int workOrderId,
|
||||||
|
string? actorId,
|
||||||
|
string actorType,
|
||||||
|
DateTime now,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var pending = await ForWorkOrder(context, workOrderId)
|
||||||
|
.Include(u => u.Dispatch)
|
||||||
|
.Where(u => PendingStatuses.Contains(u.Status))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var request in pending)
|
||||||
|
{
|
||||||
|
var previous = request.Status;
|
||||||
|
request.Status = CancelledStatus;
|
||||||
|
request.DecidedAt = now;
|
||||||
|
request.DecidedByUserId = actorId;
|
||||||
|
request.LastModificationTime = now;
|
||||||
|
|
||||||
|
context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||||
|
{
|
||||||
|
WorkOrderId = workOrderId,
|
||||||
|
UserId = actorId,
|
||||||
|
FieldName = $"Dispatch {request.Dispatch!.DispatchNumber} Uplift",
|
||||||
|
OldValue = previous,
|
||||||
|
NewValue = CancelledStatus,
|
||||||
|
Action = AuditAction,
|
||||||
|
ActorType = actorType,
|
||||||
|
CreatedAt = now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return pending.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
102
SeaHaven.DataServices/Helpers/WorkOrderMutationLock.cs
Normal file
102
SeaHaven.DataServices/Helpers/WorkOrderMutationLock.cs
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Helpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The per-work-order gate that serializes work-order-scoped invariants (uplift create,
|
||||||
|
/// decide, revoke, and cancelling a work order's pending uplifts): an in-process gate per
|
||||||
|
/// work order, a transaction, and an update lock on the work order row on SQL Server.
|
||||||
|
/// Every caller shares the same gates, so a cancel and a create on one work order never
|
||||||
|
/// interleave.
|
||||||
|
/// </summary>
|
||||||
|
public static class WorkOrderMutationLock
|
||||||
|
{
|
||||||
|
private static readonly ConcurrentDictionary<int, SemaphoreSlim> Gates = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs <paramref name="work"/> under the work order's gate and row lock. The
|
||||||
|
/// transaction commits when the work returns and <paramref name="commitWhen"/> (when
|
||||||
|
/// given) accepts its result; otherwise, or when the work throws, it rolls back.
|
||||||
|
/// </summary>
|
||||||
|
public static Task<T> RunAsync<T>(
|
||||||
|
ApplicationDbContext context,
|
||||||
|
int workOrderId,
|
||||||
|
Func<CancellationToken, Task<T>> work,
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
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 ids = workOrderIds.Distinct().OrderBy(id => id).ToList();
|
||||||
|
var held = new List<SemaphoreSlim>(ids.Count);
|
||||||
|
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 context.Database.BeginTransactionAsync(cancellationToken)
|
||||||
|
: null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var id in ids)
|
||||||
|
await LockRowAsync(context, id, cancellationToken);
|
||||||
|
|
||||||
|
var result = await work(cancellationToken);
|
||||||
|
if (transaction is not null)
|
||||||
|
{
|
||||||
|
if (commitWhen is null || commitWhen(result))
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
else
|
||||||
|
await transaction.RollbackAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (transaction is not null)
|
||||||
|
await transaction.RollbackAsync(cancellationToken);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
foreach (var gate in held)
|
||||||
|
gate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task LockRowAsync(
|
||||||
|
ApplicationDbContext context,
|
||||||
|
int workOrderId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (context.Database.ProviderName?.Contains("SqlServer", StringComparison.OrdinalIgnoreCase) != true)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await context.workOrders
|
||||||
|
.FromSqlRaw(
|
||||||
|
"SELECT * FROM [workOrders] WITH (UPDLOCK, ROWLOCK, HOLDLOCK) WHERE [Id] = {0}",
|
||||||
|
workOrderId)
|
||||||
|
.Select(workOrder => workOrder.Id)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Data.SeaHavenIndustries.Enums;
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
@ -10,8 +9,6 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
{
|
{
|
||||||
public class UpliftDataService : IUpliftDataService
|
public class UpliftDataService : IUpliftDataService
|
||||||
{
|
{
|
||||||
private static readonly ConcurrentDictionary<int, SemaphoreSlim> WorkOrderGates = new();
|
|
||||||
|
|
||||||
// The Rejected queue also surfaces the legacy "Denied" spelling, which reads as Rejected.
|
// The Rejected queue also surfaces the legacy "Denied" spelling, which reads as Rejected.
|
||||||
private static readonly string[] RejectedStatuses = { "Rejected", "Denied" };
|
private static readonly string[] RejectedStatuses = { "Rejected", "Denied" };
|
||||||
private readonly ApplicationDbContext _context;
|
private readonly ApplicationDbContext _context;
|
||||||
|
|
@ -455,14 +452,28 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
.SumAsync(cancellationToken);
|
.SumAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(
|
public Task<int> StageCancelPendingForWorkOrderAsync(
|
||||||
int workOrderId,
|
int workOrderId,
|
||||||
|
string? actorId,
|
||||||
|
DateTime now,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
=> PendingUpliftCancellation.StageAsync(
|
||||||
|
_context,
|
||||||
|
workOrderId,
|
||||||
|
actorId,
|
||||||
|
actorType: "internal",
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
public async Task<bool> IsWorkOrderCancelledAsync(int workOrderId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return ForWorkOrder(workOrderId)
|
var workOrder = await _context.workOrders
|
||||||
.Include(u => u.Dispatch)
|
.AsNoTracking()
|
||||||
.Where(u => u.Status == "Pending" || u.Status == "ChangesRequested")
|
.Where(w => w.Id == workOrderId)
|
||||||
.ToListAsync(cancellationToken);
|
.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)
|
||||||
|
|
@ -524,15 +535,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
private IQueryable<DispatchUpliftRequest> ForWorkOrder(int workOrderId)
|
private IQueryable<DispatchUpliftRequest> ForWorkOrder(int workOrderId)
|
||||||
{
|
=> PendingUpliftCancellation.ForWorkOrder(_context, workOrderId);
|
||||||
return _context.DispatchUpliftRequests.Where(u =>
|
|
||||||
(u.IsDeleted == null || u.IsDeleted == false)
|
|
||||||
&& u.Dispatch != null
|
|
||||||
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
|
|
||||||
&& (
|
|
||||||
u.Dispatch.WorkOrderId == workOrderId
|
|
||||||
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == workOrderId)));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken)
|
public async Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|
@ -595,50 +598,10 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
return message.Contains(activeDispatchIndex, StringComparison.OrdinalIgnoreCase);
|
return message.Contains(activeDispatchIndex, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<T> ExecuteWorkOrderMutationAsync<T>(
|
public Task<T> ExecuteWorkOrderMutationAsync<T>(
|
||||||
int workOrderId,
|
int workOrderId,
|
||||||
Func<CancellationToken, Task<T>> work,
|
Func<CancellationToken, Task<T>> work,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
=> WorkOrderMutationLock.RunAsync(_context, workOrderId, work, cancellationToken);
|
||||||
var gate = WorkOrderGates.GetOrAdd(workOrderId, _ => new SemaphoreSlim(1, 1));
|
|
||||||
await gate.WaitAsync(cancellationToken);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using var transaction = _context.Database.IsRelational()
|
|
||||||
? await _context.Database.BeginTransactionAsync(cancellationToken)
|
|
||||||
: null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await LockWorkOrderRowAsync(workOrderId, cancellationToken);
|
|
||||||
var result = await work(cancellationToken);
|
|
||||||
if (transaction is not null)
|
|
||||||
await transaction.CommitAsync(cancellationToken);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
if (transaction is not null)
|
|
||||||
await transaction.RollbackAsync(cancellationToken);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
gate.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LockWorkOrderRowAsync(int workOrderId, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (_context.Database.ProviderName?.Contains("SqlServer", StringComparison.OrdinalIgnoreCase) != true)
|
|
||||||
return;
|
|
||||||
|
|
||||||
await _context.workOrders
|
|
||||||
.FromSqlRaw(
|
|
||||||
"SELECT * FROM [workOrders] WITH (UPDLOCK, ROWLOCK, HOLDLOCK) WHERE [Id] = {0}",
|
|
||||||
workOrderId)
|
|
||||||
.Select(workOrder => workOrder.Id)
|
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Helpers;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
namespace SeaHaven.DataServices.Implementation
|
namespace SeaHaven.DataServices.Implementation
|
||||||
|
|
@ -31,25 +33,37 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ExecuteTransactionalAsync(Func<CancellationToken, Task> work, CancellationToken cancellationToken)
|
public Task<int> StageCancelPendingUpliftsAsync(int workOrderId, CancellationToken cancellationToken)
|
||||||
{
|
=> PendingUpliftCancellation.StageAsync(
|
||||||
await using var transaction = _context.Database.IsRelational()
|
_context,
|
||||||
? await _context.Database.BeginTransactionAsync(cancellationToken)
|
workOrderId,
|
||||||
: null;
|
actorId: null,
|
||||||
|
actorType: AuditActorType.Sync.ToString(),
|
||||||
|
DateTime.UtcNow,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
try
|
public async Task<IReadOnlyList<int>> GetWorkOrderIdsByExternalIdsAsync(
|
||||||
{
|
IReadOnlyCollection<string> externalWorkOrderIds,
|
||||||
await work(cancellationToken);
|
CancellationToken cancellationToken)
|
||||||
if (transaction is not null)
|
=> await _context.workOrders
|
||||||
await transaction.CommitAsync(cancellationToken);
|
.AsNoTracking()
|
||||||
}
|
.Where(w => w.ExternalWorkOrderId != null && externalWorkOrderIds.Contains(w.ExternalWorkOrderId))
|
||||||
catch
|
.Select(w => w.Id)
|
||||||
{
|
.ToListAsync(cancellationToken);
|
||||||
if (transaction is not null)
|
|
||||||
await transaction.RollbackAsync(cancellationToken);
|
public Task ExecuteTransactionalAsync(
|
||||||
throw;
|
IReadOnlyCollection<int> lockedWorkOrderIds,
|
||||||
}
|
Func<CancellationToken, Task> work,
|
||||||
}
|
CancellationToken cancellationToken)
|
||||||
|
=> WorkOrderMutationLock.RunManyAsync(
|
||||||
|
_context,
|
||||||
|
lockedWorkOrderIds,
|
||||||
|
async ct =>
|
||||||
|
{
|
||||||
|
await work(ct);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
public Task SaveAsync(CancellationToken cancellationToken)
|
public Task SaveAsync(CancellationToken cancellationToken)
|
||||||
=> _context.SaveChangesAsync(cancellationToken);
|
=> _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Storage;
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
|
using SeaHaven.DataServices.Helpers;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
namespace SeaHaven.DataServices.Implementation
|
namespace SeaHaven.DataServices.Implementation
|
||||||
|
|
@ -18,6 +20,32 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
public async Task<WorkOrderWebhookPersistenceResult> ApplyAsync(
|
public async Task<WorkOrderWebhookPersistenceResult> ApplyAsync(
|
||||||
WorkOrderWebhookMutation mutation,
|
WorkOrderWebhookMutation mutation,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// A mutation that cancels an existing work order also cancels its pending uplifts,
|
||||||
|
// so it runs under the same per-work-order lock as uplift creation: a create in
|
||||||
|
// flight either commits first (and is cancelled here) or sees the cancelled order.
|
||||||
|
var current = mutation.IsStateEvent && mutation.LifecycleStatus.HasValue
|
||||||
|
? await _context.workOrders
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(w => w.ExternalWorkOrderId == mutation.ExternalWorkOrderId)
|
||||||
|
.Select(w => new { w.Id, w.LifecycleStatus })
|
||||||
|
.SingleOrDefaultAsync(cancellationToken)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (current is null || !PendingUpliftCancellation.Applies(current.LifecycleStatus, mutation.LifecycleStatus))
|
||||||
|
return await ApplyUnlockedAsync(mutation, cancellationToken);
|
||||||
|
|
||||||
|
return await WorkOrderMutationLock.RunAsync(
|
||||||
|
_context,
|
||||||
|
current.Id,
|
||||||
|
ct => ApplyUnlockedAsync(mutation, ct),
|
||||||
|
cancellationToken,
|
||||||
|
commitWhen: result => result.Status == WorkOrderWebhookPersistenceStatus.Applied);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<WorkOrderWebhookPersistenceResult> ApplyUnlockedAsync(
|
||||||
|
WorkOrderWebhookMutation mutation,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var prior = await _context.WorkOrderWebhookDeliveries
|
var prior = await _context.WorkOrderWebhookDeliveries
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
|
@ -112,8 +140,22 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
workOrder.WorkerOrderTitle = mutation.Title ?? mutation.Description;
|
workOrder.WorkerOrderTitle = mutation.Title ?? mutation.Description;
|
||||||
workOrder.Description = mutation.Description;
|
workOrder.Description = mutation.Description;
|
||||||
workOrder.Status = mutation.IsCancelled ? "Cancelled" : mutation.Status;
|
workOrder.Status = mutation.IsCancelled ? "Cancelled" : mutation.Status;
|
||||||
|
var lifecycleBefore = workOrder.LifecycleStatus;
|
||||||
if (mutation.LifecycleStatus.HasValue)
|
if (mutation.LifecycleStatus.HasValue)
|
||||||
workOrder.LifecycleStatus = mutation.LifecycleStatus.Value;
|
workOrder.LifecycleStatus = mutation.LifecycleStatus.Value;
|
||||||
|
|
||||||
|
// A CRM cancellation cancels the work order's pending uplifts in this same save.
|
||||||
|
if (workOrder.Id > 0
|
||||||
|
&& PendingUpliftCancellation.Applies(lifecycleBefore, workOrder.LifecycleStatus))
|
||||||
|
{
|
||||||
|
await PendingUpliftCancellation.StageAsync(
|
||||||
|
_context,
|
||||||
|
workOrder.Id,
|
||||||
|
actorId: null,
|
||||||
|
actorType: AuditActorType.Sync.ToString(),
|
||||||
|
mutation.ProcessedAt.UtcDateTime,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
workOrder.Severity = mutation.Severity;
|
workOrder.Severity = mutation.Severity;
|
||||||
workOrder.Priority = mutation.Priority;
|
workOrder.Priority = mutation.Priority;
|
||||||
workOrder.ExternalAssignedTo = mutation.AssignedTo;
|
workOrder.ExternalAssignedTo = mutation.AssignedTo;
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,16 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
// SH-207: queue-wide pending exposure for the approvals header (sum of
|
// SH-207: queue-wide pending exposure for the approvals header (sum of
|
||||||
// RequestedNTE over non-deleted Pending requests on non-deleted dispatches).
|
// RequestedNTE over non-deleted Pending requests on non-deleted dispatches).
|
||||||
Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken);
|
Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken);
|
||||||
Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
// Stages (does not save) the cancellation of the work order's pending uplifts, each
|
||||||
|
// with its own audit row, for the caller's unit of work. See PendingUpliftCancellation.
|
||||||
|
Task<int> StageCancelPendingForWorkOrderAsync(
|
||||||
|
int workOrderId,
|
||||||
|
string? actorId,
|
||||||
|
DateTime now,
|
||||||
|
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);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,18 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
void TrackWorkOrder(WorkOrder workOrder);
|
void TrackWorkOrder(WorkOrder workOrder);
|
||||||
Task<Locations?> FindLocationAsync(string name, CancellationToken cancellationToken);
|
Task<Locations?> FindLocationAsync(string name, CancellationToken cancellationToken);
|
||||||
Task AddAndSaveLocationAsync(Locations location, CancellationToken cancellationToken);
|
Task AddAndSaveLocationAsync(Locations location, CancellationToken cancellationToken);
|
||||||
Task ExecuteTransactionalAsync(Func<CancellationToken, Task> work, CancellationToken cancellationToken);
|
// Stages (does not save) the sync-attributed cancellation of the work order's pending
|
||||||
|
// uplifts, each with its own audit row. See PendingUpliftCancellation.
|
||||||
|
Task<int> StageCancelPendingUpliftsAsync(int workOrderId, 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).
|
||||||
|
|
|
||||||
|
|
@ -16,17 +16,23 @@ namespace SeaHaven.Services.Implementation
|
||||||
private readonly IWorkOrderBoardDataService _boardDataService;
|
private readonly IWorkOrderBoardDataService _boardDataService;
|
||||||
private readonly IWorkOrderBoardMutationDataService _mutationData;
|
private readonly IWorkOrderBoardMutationDataService _mutationData;
|
||||||
private readonly IWorkOrderAuditService _auditService;
|
private readonly IWorkOrderAuditService _auditService;
|
||||||
|
private readonly IWorkOrderUpliftService _upliftService;
|
||||||
|
private readonly IUpliftDataService _upliftData;
|
||||||
private readonly IServicesRegistryService? _servicesRegistryService;
|
private readonly IServicesRegistryService? _servicesRegistryService;
|
||||||
|
|
||||||
public WorkOrderBoardUpdateService(
|
public WorkOrderBoardUpdateService(
|
||||||
IWorkOrderBoardDataService boardDataService,
|
IWorkOrderBoardDataService boardDataService,
|
||||||
IWorkOrderBoardMutationDataService mutationData,
|
IWorkOrderBoardMutationDataService mutationData,
|
||||||
IWorkOrderAuditService auditService,
|
IWorkOrderAuditService auditService,
|
||||||
|
IWorkOrderUpliftService upliftService,
|
||||||
|
IUpliftDataService upliftData,
|
||||||
IServicesRegistryService? servicesRegistryService = null)
|
IServicesRegistryService? servicesRegistryService = null)
|
||||||
{
|
{
|
||||||
_boardDataService = boardDataService;
|
_boardDataService = boardDataService;
|
||||||
_mutationData = mutationData;
|
_mutationData = mutationData;
|
||||||
_auditService = auditService;
|
_auditService = auditService;
|
||||||
|
_upliftService = upliftService;
|
||||||
|
_upliftData = upliftData;
|
||||||
_servicesRegistryService = servicesRegistryService;
|
_servicesRegistryService = servicesRegistryService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,7 +41,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
WorkOrderBoardPatchRequestDto request,
|
WorkOrderBoardPatchRequestDto request,
|
||||||
string? actorId)
|
string? actorId)
|
||||||
{
|
{
|
||||||
await _mutationData.ExecuteTransactionalAsync(async ct =>
|
Func<CancellationToken, Task> patch = async ct =>
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(request.Field))
|
if (string.IsNullOrWhiteSpace(request.Field))
|
||||||
throw new WorkOrderBoardValidationException("InvalidField", "Field is required.");
|
throw new WorkOrderBoardValidationException("InvalidField", "Field is required.");
|
||||||
|
|
@ -127,7 +133,16 @@ namespace SeaHaven.Services.Implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
var auditField = WorkOrderBoardFieldNames.ToAuditFieldName(canonicalField);
|
var auditField = WorkOrderBoardFieldNames.ToAuditFieldName(canonicalField);
|
||||||
|
var lifecycleBefore = workOrder.LifecycleStatus;
|
||||||
var changes = await ApplyFieldMutationAsync(canonicalField, workOrder, dispatch, request.Value, auditField, ct);
|
var changes = await ApplyFieldMutationAsync(canonicalField, workOrder, dispatch, request.Value, auditField, ct);
|
||||||
|
|
||||||
|
// Cancelling the work order cancels its pending uplift in the same action: the
|
||||||
|
// withdrawal and its own audit entry are staged here and committed by the same
|
||||||
|
// save as the status change.
|
||||||
|
if (PendingUpliftCancellation.Applies(lifecycleBefore, workOrder.LifecycleStatus))
|
||||||
|
{
|
||||||
|
await _upliftService.WithdrawPendingForWorkOrderAsync(workOrderId, actorId, ct);
|
||||||
|
}
|
||||||
if (resolved is { Created: true, Dispatch: var createdDispatch }
|
if (resolved is { Created: true, Dispatch: var createdDispatch }
|
||||||
&& canonicalField.Equals(WorkOrderBoardFieldNames.VendorId, StringComparison.OrdinalIgnoreCase))
|
&& canonicalField.Equals(WorkOrderBoardFieldNames.VendorId, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
|
|
@ -173,12 +188,40 @@ namespace SeaHaven.Services.Implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
await SaveTrackedOrThrowAsync(workOrderId, ct);
|
await SaveTrackedOrThrowAsync(workOrderId, ct);
|
||||||
}, CancellationToken.None);
|
};
|
||||||
|
|
||||||
|
// A cancel runs under the per-work-order gate uplift create uses, so a create that
|
||||||
|
// is in flight cannot commit a new pending uplift onto the work order being cancelled.
|
||||||
|
if (IsCancelRequest(request))
|
||||||
|
{
|
||||||
|
await _upliftData.ExecuteWorkOrderMutationAsync(
|
||||||
|
workOrderId,
|
||||||
|
async ct =>
|
||||||
|
{
|
||||||
|
await patch(ct);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _mutationData.ExecuteTransactionalAsync(patch, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
var row = await LoadBoardRowAsync(workOrderId);
|
var row = await LoadBoardRowAsync(workOrderId);
|
||||||
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsCancelRequest(WorkOrderBoardPatchRequestDto request)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Field))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var canonicalField = WorkOrderBoardFieldNames.Canonicalize(request.Field.Trim());
|
||||||
|
return string.Equals(canonicalField, WorkOrderBoardFieldNames.LifecycleStatus, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& LifecycleStatusMapper.ParseLifecycleStatus(request.Value) == LifecycleStatus.Canceled;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<List<FieldChange>> ApplyFieldMutationAsync(
|
private async Task<List<FieldChange>> ApplyFieldMutationAsync(
|
||||||
string field,
|
string field,
|
||||||
WorkOrder workOrder,
|
WorkOrder workOrder,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
|
using SeaHaven.DataServices.Helpers;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
using SeaHaven.Services.Helpers;
|
using SeaHaven.Services.Helpers;
|
||||||
|
|
@ -36,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);
|
||||||
|
|
||||||
|
|
@ -134,8 +148,15 @@ namespace SeaHaven.Services.Implementation
|
||||||
|
|
||||||
var mappedStatus = WorkOrderIngestFieldMapper.MapStatus(item.WoStatus);
|
var mappedStatus = WorkOrderIngestFieldMapper.MapStatus(item.WoStatus);
|
||||||
if (mappedStatus != null)
|
if (mappedStatus != null)
|
||||||
|
{
|
||||||
|
var statusBefore = existing.Status;
|
||||||
await _mergePolicy.TryApplyAsync(syncContext, "Status", mappedStatus);
|
await _mergePolicy.TryApplyAsync(syncContext, "Status", mappedStatus);
|
||||||
|
|
||||||
|
// A sync cancellation cancels the work order's pending uplifts in the same save.
|
||||||
|
if (PendingUpliftCancellation.AppliesToStatusText(statusBefore, existing.Status))
|
||||||
|
await _ingestData.StageCancelPendingUpliftsAsync(existing.Id, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
var priority = WorkOrderIngestFieldMapper.MapSeverityToPriority(item.Severity);
|
var priority = WorkOrderIngestFieldMapper.MapSeverityToPriority(item.Severity);
|
||||||
if (priority != null)
|
if (priority != null)
|
||||||
await _mergePolicy.TryApplyAsync(syncContext, "Priority", priority);
|
await _mergePolicy.TryApplyAsync(syncContext, "Priority", priority);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -350,37 +355,11 @@ namespace SeaHaven.Services.Implementation
|
||||||
string? actorId,
|
string? actorId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var pending = await _upliftData.GetPendingForWorkOrderAsync(workOrderId, cancellationToken);
|
await _upliftData.StageCancelPendingForWorkOrderAsync(
|
||||||
if (pending.Count == 0)
|
workOrderId,
|
||||||
return;
|
actorId,
|
||||||
|
_timeProvider.GetUtcNow().UtcDateTime,
|
||||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
cancellationToken);
|
||||||
foreach (var req in pending)
|
|
||||||
{
|
|
||||||
if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.Withdrawn))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var dispatch = req.Dispatch ?? await _dispatchData.GetByIdAsync(req.DispatchId);
|
|
||||||
if (dispatch == null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var previous = UpliftStatus.ToCanonical(req.Status);
|
|
||||||
req.Status = UpliftStatus.Withdrawn;
|
|
||||||
req.DecidedAt = now;
|
|
||||||
req.DecidedByUserId = actorId;
|
|
||||||
req.LastModificationTime = now;
|
|
||||||
|
|
||||||
await StageAuditAsync(
|
|
||||||
dispatch,
|
|
||||||
workOrderId,
|
|
||||||
actorId,
|
|
||||||
previous,
|
|
||||||
UpliftStatus.Withdrawn,
|
|
||||||
"uplift_cancel",
|
|
||||||
now,
|
|
||||||
cancellationToken,
|
|
||||||
isStatusTransition: true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<WorkOrderUpliftDto> PersistCreatedAsync(
|
private async Task<WorkOrderUpliftDto> PersistCreatedAsync(
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,31 @@ internal static class WorkOrderAccountTestHelpers
|
||||||
new AccountDataService(context),
|
new AccountDataService(context),
|
||||||
new LocationDataService(context));
|
new LocationDataService(context));
|
||||||
|
|
||||||
|
/// <summary>Board update service over one context, with the real uplift cascade wired.</summary>
|
||||||
|
public static WorkOrderBoardUpdateService BoardUpdateService(
|
||||||
|
ApplicationDbContext context,
|
||||||
|
IWorkOrderAuditService audit,
|
||||||
|
IServicesRegistryService? servicesRegistry = null)
|
||||||
|
=> new(
|
||||||
|
new WorkOrderBoardDataService(context),
|
||||||
|
new WorkOrderBoardMutationDataService(context),
|
||||||
|
audit,
|
||||||
|
UpliftService(context),
|
||||||
|
new UpliftDataService(context),
|
||||||
|
servicesRegistry);
|
||||||
|
|
||||||
|
public static WorkOrderUpliftService UpliftService(ApplicationDbContext context)
|
||||||
|
=> new(
|
||||||
|
new UpliftDataService(context),
|
||||||
|
new DispatchDataService(context),
|
||||||
|
new WorkOrderDetailDataService(context),
|
||||||
|
Resolver(context),
|
||||||
|
new UserDataService(context),
|
||||||
|
new TeamPermissionOverrideDataService(context),
|
||||||
|
new TeamPermissionPolicy(),
|
||||||
|
TimeProvider.System,
|
||||||
|
Microsoft.Extensions.Options.Options.Create(new SeaHaven.Services.Configuration.ApprovalsOptions()));
|
||||||
|
|
||||||
public static ClaimsPrincipal AccountUser(
|
public static ClaimsPrincipal AccountUser(
|
||||||
string userId = "actor-1",
|
string userId = "actor-1",
|
||||||
int accountId = 1,
|
int accountId = 1,
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ public class WorkOrderBoardCancelServiceTests
|
||||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||||
var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, new NoOpUpliftService(), new PassThroughUpliftData());
|
var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, new NoOpUpliftService(), new PassThroughUpliftData());
|
||||||
var update = new WorkOrderBoardUpdateService(boardData, mutationData, audit);
|
var update = WorkOrderAccountTestHelpers.BoardUpdateService(context, audit);
|
||||||
return (context, cancel, update);
|
return (context, cancel, update);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -461,7 +461,8 @@ public class WorkOrderBoardCancelServiceTests
|
||||||
public Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
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<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(int workOrderId, 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();
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ public class WorkOrderBoardConcurrencyTests
|
||||||
var mutationData = new WorkOrderBoardMutationDataService(context);
|
var mutationData = new WorkOrderBoardMutationDataService(context);
|
||||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||||
return new WorkOrderBoardUpdateService(boardData, mutationData, audit);
|
return WorkOrderAccountTestHelpers.BoardUpdateService(context, audit);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
255
SeaHavenIndustries.Tests/WorkOrderBoardPatchCancelUpliftTests.cs
Normal file
255
SeaHavenIndustries.Tests/WorkOrderBoardPatchCancelUpliftTests.cs
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cancelling a work order from the board (the lifecycle status PATCH the board and
|
||||||
|
/// slide-over use) cancels its pending uplift in the same action and commit, with an
|
||||||
|
/// audit entry of its own.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WorkOrderBoardPatchCancelUpliftTests
|
||||||
|
{
|
||||||
|
private static readonly byte[] Version = { 1, 0, 0, 0, 0, 0, 0, 1 };
|
||||||
|
|
||||||
|
private static async Task<WorkOrderBoardPatchRequestDto> CancelPatchAsync(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
var stored = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == 1);
|
||||||
|
return new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.LifecycleStatus,
|
||||||
|
Value = "Canceled",
|
||||||
|
WorkOrderVersion = Convert.ToBase64String(stored.RowVersion ?? Version),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkOrderBoardUpdateService NewUpdateService(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||||
|
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||||
|
return WorkOrderAccountTestHelpers.BoardUpdateService(context, audit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saved in stages: the work order and its primary dispatch reference each other, which a
|
||||||
|
// relational provider cannot insert in one statement batch.
|
||||||
|
internal static async Task SeedWorkOrderAsync(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
context.Accounts.Add(new Accounts { Id = 1, Name = "Acme Corp", IsDeleted = false });
|
||||||
|
context.Users.Add(new ApplicationUser { Id = "actor-1", UserName = "actor-1" });
|
||||||
|
context.Users.Add(new ApplicationUser { Id = "dispatcher-1", UserName = "dispatcher-1" });
|
||||||
|
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
|
||||||
|
var workOrder = new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
InternalWONumber = "10000000001",
|
||||||
|
ExternalWorkOrderId = "123",
|
||||||
|
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||||
|
Status = "Incomplete",
|
||||||
|
AccountId = 1,
|
||||||
|
RowVersion = Version,
|
||||||
|
};
|
||||||
|
context.workOrders.Add(workOrder);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
context.Dispatches.Add(new Dispatch
|
||||||
|
{
|
||||||
|
Id = 10,
|
||||||
|
VendorId = 1,
|
||||||
|
WorkOrderId = 1,
|
||||||
|
NTEAmount = 1900m,
|
||||||
|
DispatchNumber = "DIS-10",
|
||||||
|
Status = "Scheduled",
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
workOrder.PrimaryDispatchId = 10;
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static DispatchUpliftRequest Uplift(int id, string status, decimal amount) => new()
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
DispatchId = 10,
|
||||||
|
CurrentNTE = 1000m,
|
||||||
|
RequestedNTE = amount,
|
||||||
|
Status = status,
|
||||||
|
RequiredTier = status == "NoApprovalRequired" ? 0 : 1,
|
||||||
|
NotificationStatus = "Sent",
|
||||||
|
createdby = "dispatcher-1",
|
||||||
|
CreatedDate = DateTime.UtcNow.AddHours(-id),
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PatchToCanceled_CancelsPendingUpliftWithItsOwnAuditAndLeavesDecidedUpliftsAlone()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
await using (var seed = new ApplicationDbContext(options))
|
||||||
|
{
|
||||||
|
await SeedWorkOrderAsync(seed);
|
||||||
|
seed.DispatchUpliftRequests.AddRange(
|
||||||
|
Uplift(100, "Pending", 700m),
|
||||||
|
Uplift(101, "Approved", 600m),
|
||||||
|
Uplift(102, "NoApprovalRequired", 300m),
|
||||||
|
Uplift(103, "Rejected", 900m));
|
||||||
|
await seed.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var context = new ApplicationDbContext(options))
|
||||||
|
{
|
||||||
|
var row = await NewUpdateService(context).PatchFieldAsync(1, await CancelPatchAsync(context), "actor-1");
|
||||||
|
Assert.Equal(LifecycleStatus.Canceled, row.LifecycleStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var verify = new ApplicationDbContext(options);
|
||||||
|
Assert.Equal(LifecycleStatus.Canceled, verify.workOrders.Single().LifecycleStatus);
|
||||||
|
|
||||||
|
var cancelled = verify.DispatchUpliftRequests.Single(u => u.Id == 100);
|
||||||
|
Assert.Equal(UpliftStatus.Withdrawn, cancelled.Status);
|
||||||
|
Assert.Equal("actor-1", cancelled.DecidedByUserId);
|
||||||
|
Assert.NotNull(cancelled.DecidedAt);
|
||||||
|
|
||||||
|
Assert.Equal(UpliftStatus.Approved, verify.DispatchUpliftRequests.Single(u => u.Id == 101).Status);
|
||||||
|
Assert.Equal(UpliftStatus.NoApprovalRequired, verify.DispatchUpliftRequests.Single(u => u.Id == 102).Status);
|
||||||
|
Assert.Equal(UpliftStatus.Rejected, verify.DispatchUpliftRequests.Single(u => u.Id == 103).Status);
|
||||||
|
Assert.Equal(1900m, verify.Dispatches.Single().NTEAmount);
|
||||||
|
|
||||||
|
var upliftAudit = Assert.Single(verify.WorkOrderAuditLogs, log => log.Action == "uplift_cancel");
|
||||||
|
Assert.Equal(1, upliftAudit.WorkOrderId);
|
||||||
|
Assert.Equal("actor-1", upliftAudit.UserId);
|
||||||
|
Assert.Equal("Dispatch DIS-10 Uplift", upliftAudit.FieldName);
|
||||||
|
Assert.Equal(UpliftStatus.Pending, upliftAudit.OldValue);
|
||||||
|
Assert.Equal(UpliftStatus.Withdrawn, upliftAudit.NewValue);
|
||||||
|
|
||||||
|
var statusAudit = Assert.Single(verify.WorkOrderAuditLogs, log => log.Action == "StatusChanged");
|
||||||
|
Assert.Equal(LifecycleStatus.Canceled.ToString(), statusAudit.NewValue);
|
||||||
|
Assert.NotEqual(upliftAudit.Id, statusAudit.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PatchToOtherStatus_LeavesPendingUpliftPending()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
await using (var seed = new ApplicationDbContext(options))
|
||||||
|
{
|
||||||
|
await SeedWorkOrderAsync(seed);
|
||||||
|
seed.DispatchUpliftRequests.Add(Uplift(100, "Pending", 700m));
|
||||||
|
await seed.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var context = new ApplicationDbContext(options))
|
||||||
|
{
|
||||||
|
var patch = await CancelPatchAsync(context);
|
||||||
|
patch.Value = "Pending";
|
||||||
|
await NewUpdateService(context).PatchFieldAsync(1, patch, "actor-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var verify = new ApplicationDbContext(options);
|
||||||
|
Assert.Equal(LifecycleStatus.Pending, verify.workOrders.Single().LifecycleStatus);
|
||||||
|
Assert.Equal(UpliftStatus.Pending, verify.DispatchUpliftRequests.Single().Status);
|
||||||
|
Assert.DoesNotContain(verify.WorkOrderAuditLogs, log => log.Action == "uplift_cancel");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PatchToCanceled_FailureAfterTheWrites_RollsBackWorkOrderAndUpliftTogether()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("DataSource=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var failAfterSave = new FailAfterSaveInterceptor();
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.AddInterceptors(failAfterSave)
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
await using (var seed = new SqliteRowVersionDbContext(options))
|
||||||
|
{
|
||||||
|
await seed.Database.EnsureCreatedAsync();
|
||||||
|
await SeedWorkOrderAsync(seed);
|
||||||
|
seed.DispatchUpliftRequests.Add(Uplift(100, "Pending", 700m));
|
||||||
|
await seed.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
failAfterSave.Armed = true;
|
||||||
|
await using (var context = new SqliteRowVersionDbContext(options))
|
||||||
|
{
|
||||||
|
var patch = await CancelPatchAsync(context);
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => NewUpdateService(context).PatchFieldAsync(1, patch, "actor-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The failing save did write both the cancelled work order and the withdrawn
|
||||||
|
// uplift inside the transaction; the failure after it must undo both.
|
||||||
|
Assert.True(failAfterSave.SawWithdrawnUpliftInSave);
|
||||||
|
|
||||||
|
failAfterSave.Armed = false;
|
||||||
|
await using var verify = new SqliteRowVersionDbContext(options);
|
||||||
|
Assert.Equal(LifecycleStatus.Incomplete, (await verify.workOrders.AsNoTracking().SingleAsync()).LifecycleStatus);
|
||||||
|
Assert.Equal(UpliftStatus.Pending, (await verify.DispatchUpliftRequests.AsNoTracking().SingleAsync()).Status);
|
||||||
|
Assert.False(await verify.WorkOrderAuditLogs.AnyAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FailAfterSaveInterceptor : SaveChangesInterceptor
|
||||||
|
{
|
||||||
|
public bool Armed { get; set; }
|
||||||
|
public bool SawWithdrawnUpliftInSave { get; private set; }
|
||||||
|
|
||||||
|
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
|
||||||
|
DbContextEventData eventData,
|
||||||
|
InterceptionResult<int> result,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (Armed && eventData.Context is not null)
|
||||||
|
{
|
||||||
|
SawWithdrawnUpliftInSave = eventData.Context.ChangeTracker
|
||||||
|
.Entries<DispatchUpliftRequest>()
|
||||||
|
.Any(entry => entry.State == EntityState.Modified
|
||||||
|
&& entry.Entity.Status == UpliftStatus.Withdrawn);
|
||||||
|
}
|
||||||
|
|
||||||
|
return base.SavingChangesAsync(eventData, result, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ValueTask<int> SavedChangesAsync(
|
||||||
|
SaveChangesCompletedEventData eventData,
|
||||||
|
int result,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (Armed)
|
||||||
|
throw new InvalidOperationException("Simulated failure after the rows were written");
|
||||||
|
|
||||||
|
return base.SavedChangesAsync(eventData, result, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SqliteRowVersionDbContext : ApplicationDbContext
|
||||||
|
{
|
||||||
|
public SqliteRowVersionDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||||
|
: base(options)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
|
{
|
||||||
|
base.OnModelCreating(builder);
|
||||||
|
|
||||||
|
// SQLite has no rowversion type; store the seeded blobs as plain values.
|
||||||
|
foreach (var entityType in new[] { typeof(WorkOrder), typeof(Dispatch), typeof(DispatchUpliftRequest) })
|
||||||
|
{
|
||||||
|
var property = builder.Entity(entityType).Property("RowVersion").Metadata;
|
||||||
|
property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never;
|
||||||
|
property.IsConcurrencyToken = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,9 +22,8 @@ public class WorkOrderBoardPatchLifecycleRulesTests
|
||||||
var context = new ApplicationDbContext(options);
|
var context = new ApplicationDbContext(options);
|
||||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||||
var service = new WorkOrderBoardUpdateService(
|
var service = WorkOrderAccountTestHelpers.BoardUpdateService(
|
||||||
new WorkOrderBoardDataService(context),
|
context,
|
||||||
new WorkOrderBoardMutationDataService(context),
|
|
||||||
audit);
|
audit);
|
||||||
return (context, service);
|
return (context, service);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ public class WorkOrderBoardUpdateServiceTests
|
||||||
var mutationData = new WorkOrderBoardMutationDataService(context);
|
var mutationData = new WorkOrderBoardMutationDataService(context);
|
||||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||||
var service = new WorkOrderBoardUpdateService(boardData, mutationData, audit);
|
var service = WorkOrderAccountTestHelpers.BoardUpdateService(context, audit);
|
||||||
return (context, service);
|
return (context, service);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,8 @@ public class WorkOrderCompletedSelectiveLockTests
|
||||||
var context = new ApplicationDbContext(options);
|
var context = new ApplicationDbContext(options);
|
||||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||||
var service = new WorkOrderBoardUpdateService(
|
var service = WorkOrderAccountTestHelpers.BoardUpdateService(
|
||||||
new WorkOrderBoardDataService(context),
|
context,
|
||||||
new WorkOrderBoardMutationDataService(context),
|
|
||||||
audit);
|
audit);
|
||||||
return (context, service);
|
return (context, service);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -186,9 +186,8 @@ public class WorkOrderCompletionFreezeTests
|
||||||
private static WorkOrderBoardUpdateService CreateService(ApplicationDbContext context)
|
private static WorkOrderBoardUpdateService CreateService(ApplicationDbContext context)
|
||||||
{
|
{
|
||||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||||
return new WorkOrderBoardUpdateService(
|
return WorkOrderAccountTestHelpers.BoardUpdateService(
|
||||||
new WorkOrderBoardDataService(context),
|
context,
|
||||||
new WorkOrderBoardMutationDataService(context),
|
|
||||||
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks));
|
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
381
SeaHavenIndustries.Tests/WorkOrderCrmCancelUpliftTests.cs
Normal file
381
SeaHavenIndustries.Tests/WorkOrderCrmCancelUpliftTests.cs
Normal file
|
|
@ -0,0 +1,381 @@
|
||||||
|
using System.Data.Common;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A work order cancelled by the CRM (webhook or reconciliation, both persisted by the
|
||||||
|
/// webhook data service, or the legacy ingest) cancels its pending uplift in the same save,
|
||||||
|
/// with an audit entry of its own, exactly as a cancel from the board does.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WorkOrderCrmCancelUpliftTests
|
||||||
|
{
|
||||||
|
private static readonly DateTimeOffset ProcessedAt = new(2026, 7, 24, 12, 1, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
private static WorkOrderWebhookMutation CrmMutation(string eventType, bool cancelled) => new()
|
||||||
|
{
|
||||||
|
DeliveryId = $"delivery-{eventType}",
|
||||||
|
EventType = eventType,
|
||||||
|
OccurredAt = ProcessedAt.AddMinutes(-1),
|
||||||
|
UpdatedAt = ProcessedAt.AddMinutes(-1),
|
||||||
|
ProcessedAt = ProcessedAt,
|
||||||
|
BodySha256 = $"hash-{eventType}",
|
||||||
|
VersionHash = $"hash-{eventType}",
|
||||||
|
ExternalWorkOrderId = "123",
|
||||||
|
WorkerOrderNumber = "00000000123",
|
||||||
|
Source = "procurement",
|
||||||
|
IsStateEvent = true,
|
||||||
|
Customer = "Acme Corp",
|
||||||
|
Status = "Open",
|
||||||
|
IsCancelled = cancelled,
|
||||||
|
LifecycleStatus = cancelled ? LifecycleStatus.Canceled : LifecycleStatus.Pending,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static async Task<DbContextOptions<ApplicationDbContext>> SeedInMemoryAsync(
|
||||||
|
params DispatchUpliftRequest[] uplifts)
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
await using var seed = new ApplicationDbContext(options);
|
||||||
|
await WorkOrderBoardPatchCancelUpliftTests.SeedWorkOrderAsync(seed);
|
||||||
|
seed.DispatchUpliftRequests.AddRange(uplifts);
|
||||||
|
await seed.SaveChangesAsync();
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CrmCancel_CancelsPendingUpliftWithItsOwnSyncAuditAndLeavesDecidedUpliftsAlone()
|
||||||
|
{
|
||||||
|
var options = await SeedInMemoryAsync(
|
||||||
|
WorkOrderBoardPatchCancelUpliftTests.Uplift(100, "Pending", 700m),
|
||||||
|
WorkOrderBoardPatchCancelUpliftTests.Uplift(101, "Approved", 600m),
|
||||||
|
WorkOrderBoardPatchCancelUpliftTests.Uplift(102, "NoApprovalRequired", 300m),
|
||||||
|
WorkOrderBoardPatchCancelUpliftTests.Uplift(103, "Rejected", 900m));
|
||||||
|
|
||||||
|
await using (var context = new ApplicationDbContext(options))
|
||||||
|
{
|
||||||
|
var result = await new WorkOrderWebhookDataService(context)
|
||||||
|
.ApplyAsync(CrmMutation("work_order.cancelled", cancelled: true), CancellationToken.None);
|
||||||
|
Assert.Equal(WorkOrderWebhookPersistenceStatus.Applied, result.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var verify = new ApplicationDbContext(options);
|
||||||
|
Assert.Equal(LifecycleStatus.Canceled, verify.workOrders.Single().LifecycleStatus);
|
||||||
|
|
||||||
|
var cancelled = verify.DispatchUpliftRequests.Single(u => u.Id == 100);
|
||||||
|
Assert.Equal(UpliftStatus.Withdrawn, cancelled.Status);
|
||||||
|
Assert.Equal(ProcessedAt.UtcDateTime, cancelled.DecidedAt);
|
||||||
|
Assert.Null(cancelled.DecidedByUserId);
|
||||||
|
|
||||||
|
Assert.Equal(UpliftStatus.Approved, verify.DispatchUpliftRequests.Single(u => u.Id == 101).Status);
|
||||||
|
Assert.Equal(UpliftStatus.NoApprovalRequired, verify.DispatchUpliftRequests.Single(u => u.Id == 102).Status);
|
||||||
|
Assert.Equal(UpliftStatus.Rejected, verify.DispatchUpliftRequests.Single(u => u.Id == 103).Status);
|
||||||
|
Assert.Equal(1900m, verify.Dispatches.Single().NTEAmount);
|
||||||
|
|
||||||
|
var upliftAudit = Assert.Single(verify.WorkOrderAuditLogs);
|
||||||
|
Assert.Equal("uplift_cancel", upliftAudit.Action);
|
||||||
|
Assert.Equal(1, upliftAudit.WorkOrderId);
|
||||||
|
Assert.Equal(AuditActorType.Sync.ToString(), upliftAudit.ActorType);
|
||||||
|
Assert.Null(upliftAudit.UserId);
|
||||||
|
Assert.Equal("Dispatch DIS-10 Uplift", upliftAudit.FieldName);
|
||||||
|
Assert.Equal(UpliftStatus.Pending, upliftAudit.OldValue);
|
||||||
|
Assert.Equal(UpliftStatus.Withdrawn, upliftAudit.NewValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CrmCancel_WaitsForAnUpliftCreateInFlightAndCancelsWhatItCommitted()
|
||||||
|
{
|
||||||
|
var options = await SeedInMemoryAsync();
|
||||||
|
var createEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var releaseCreate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
// An uplift create holding the work order's lock, committing its Pending request
|
||||||
|
// only after the CRM cancel has started.
|
||||||
|
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 crmContext = new ApplicationDbContext(options);
|
||||||
|
var cancel = new WorkOrderWebhookDataService(crmContext)
|
||||||
|
.ApplyAsync(CrmMutation("work_order.cancelled", cancelled: true), CancellationToken.None);
|
||||||
|
|
||||||
|
var finishedFirst = await Task.WhenAny(cancel, Task.Delay(TimeSpan.FromSeconds(2)));
|
||||||
|
Assert.NotSame(cancel, finishedFirst);
|
||||||
|
|
||||||
|
releaseCreate.SetResult();
|
||||||
|
await create;
|
||||||
|
await cancel;
|
||||||
|
|
||||||
|
await using var verify = new ApplicationDbContext(options);
|
||||||
|
Assert.Equal(LifecycleStatus.Canceled, verify.workOrders.Single().LifecycleStatus);
|
||||||
|
Assert.Equal(UpliftStatus.Withdrawn, verify.DispatchUpliftRequests.Single(u => u.Id == 200).Status);
|
||||||
|
Assert.Single(verify.WorkOrderAuditLogs, log => log.Action == "uplift_cancel");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CrmUpdateThatDoesNotCancel_LeavesPendingUpliftPending()
|
||||||
|
{
|
||||||
|
var options = await SeedInMemoryAsync(
|
||||||
|
WorkOrderBoardPatchCancelUpliftTests.Uplift(100, "Pending", 700m));
|
||||||
|
|
||||||
|
await using (var context = new ApplicationDbContext(options))
|
||||||
|
{
|
||||||
|
await new WorkOrderWebhookDataService(context)
|
||||||
|
.ApplyAsync(CrmMutation("work_order.updated", cancelled: false), CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var verify = new ApplicationDbContext(options);
|
||||||
|
Assert.Equal(LifecycleStatus.Pending, verify.workOrders.Single().LifecycleStatus);
|
||||||
|
Assert.Equal(UpliftStatus.Pending, verify.DispatchUpliftRequests.Single().Status);
|
||||||
|
Assert.Empty(verify.WorkOrderAuditLogs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReconciliationCancel_CancelsPendingUplift()
|
||||||
|
{
|
||||||
|
var options = await SeedInMemoryAsync(
|
||||||
|
WorkOrderBoardPatchCancelUpliftTests.Uplift(100, "Pending", 700m));
|
||||||
|
|
||||||
|
await using (var context = new ApplicationDbContext(options))
|
||||||
|
{
|
||||||
|
var service = WorkOrderReconciliationTests.CreateService(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new ProcurementWorkOrder
|
||||||
|
{
|
||||||
|
WorkOrderId = "123",
|
||||||
|
WoStatus = "cancelled",
|
||||||
|
Customer = "Acme Corp",
|
||||||
|
UpdatedAt = ProcessedAt,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new WorkOrderWebhookDataService(context));
|
||||||
|
Assert.True(await service.RunPendingAsync(CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var verify = new ApplicationDbContext(options);
|
||||||
|
Assert.Equal(LifecycleStatus.Canceled, verify.workOrders.Single().LifecycleStatus);
|
||||||
|
Assert.Equal(UpliftStatus.Withdrawn, verify.DispatchUpliftRequests.Single().Status);
|
||||||
|
var upliftAudit = Assert.Single(verify.WorkOrderAuditLogs, log => log.Action == "uplift_cancel");
|
||||||
|
Assert.Equal(AuditActorType.Sync.ToString(), upliftAudit.ActorType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LegacyIngestCancel_CancelsPendingUpliftWithItsOwnSyncAuditAndLeavesDecidedUpliftsAlone()
|
||||||
|
{
|
||||||
|
var options = await SeedInMemoryAsync(
|
||||||
|
WorkOrderBoardPatchCancelUpliftTests.Uplift(100, "Pending", 700m),
|
||||||
|
WorkOrderBoardPatchCancelUpliftTests.Uplift(101, "Approved", 600m));
|
||||||
|
|
||||||
|
await using (var context = new ApplicationDbContext(options))
|
||||||
|
{
|
||||||
|
await NewIngestService(context).UpsertBatchAsync(new[]
|
||||||
|
{
|
||||||
|
new WorkOrderIngestPayloadDto { ExternalWorkOrderId = "123", WoStatus = "cancelled" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var verify = new ApplicationDbContext(options);
|
||||||
|
Assert.Equal("Cancelled", verify.workOrders.Single().Status);
|
||||||
|
Assert.Equal(UpliftStatus.Withdrawn, verify.DispatchUpliftRequests.Single(u => u.Id == 100).Status);
|
||||||
|
Assert.Equal(UpliftStatus.Approved, verify.DispatchUpliftRequests.Single(u => u.Id == 101).Status);
|
||||||
|
var upliftAudit = Assert.Single(verify.WorkOrderAuditLogs, log => log.Action == "uplift_cancel");
|
||||||
|
Assert.Equal(AuditActorType.Sync.ToString(), upliftAudit.ActorType);
|
||||||
|
Assert.Equal("Dispatch DIS-10 Uplift", upliftAudit.FieldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LegacyIngestUpdateThatDoesNotCancel_LeavesPendingUpliftPending()
|
||||||
|
{
|
||||||
|
var options = await SeedInMemoryAsync(
|
||||||
|
WorkOrderBoardPatchCancelUpliftTests.Uplift(100, "Pending", 700m));
|
||||||
|
|
||||||
|
await using (var context = new ApplicationDbContext(options))
|
||||||
|
{
|
||||||
|
await NewIngestService(context).UpsertBatchAsync(new[]
|
||||||
|
{
|
||||||
|
new WorkOrderIngestPayloadDto { ExternalWorkOrderId = "123", WoStatus = "in_progress" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var verify = new ApplicationDbContext(options);
|
||||||
|
Assert.Equal(UpliftStatus.Pending, verify.DispatchUpliftRequests.Single().Status);
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||||
|
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||||
|
return new WorkOrderIngestService(
|
||||||
|
new WorkOrderIngestDataService(context),
|
||||||
|
new SyncFieldMergePolicy(fieldLocks),
|
||||||
|
fieldLocks,
|
||||||
|
audit,
|
||||||
|
WorkOrderAccountTestHelpers.Resolver(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CrmCancel_FailureBeforeCommit_RollsBackWorkOrderAndUpliftTogether()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("DataSource=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var writes = new UpdateRecorder();
|
||||||
|
var failCommit = new FailCommitInterceptor();
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.AddInterceptors(writes, failCommit)
|
||||||
|
.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();
|
||||||
|
}
|
||||||
|
|
||||||
|
writes.Armed = true;
|
||||||
|
failCommit.Armed = true;
|
||||||
|
await using (var context = new WorkOrderBoardPatchCancelUpliftTests.SqliteRowVersionDbContext(options))
|
||||||
|
{
|
||||||
|
await Assert.ThrowsAnyAsync<InvalidOperationException>(() => new WorkOrderWebhookDataService(context)
|
||||||
|
.ApplyAsync(CrmMutation("work_order.cancelled", cancelled: true), CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The work order and uplift updates both ran inside the save's transaction before
|
||||||
|
// the commit failed; neither may survive it.
|
||||||
|
Assert.True(writes.UpdatedUplift);
|
||||||
|
Assert.True(writes.UpdatedWorkOrder);
|
||||||
|
|
||||||
|
writes.Armed = false;
|
||||||
|
failCommit.Armed = false;
|
||||||
|
await using var verify = new WorkOrderBoardPatchCancelUpliftTests.SqliteRowVersionDbContext(options);
|
||||||
|
Assert.Equal(LifecycleStatus.Incomplete, (await verify.workOrders.AsNoTracking().SingleAsync()).LifecycleStatus);
|
||||||
|
Assert.Equal(UpliftStatus.Pending, (await verify.DispatchUpliftRequests.AsNoTracking().SingleAsync()).Status);
|
||||||
|
Assert.False(await verify.WorkOrderAuditLogs.AnyAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class UpdateRecorder : DbCommandInterceptor
|
||||||
|
{
|
||||||
|
public bool Armed { get; set; }
|
||||||
|
public bool UpdatedUplift { get; private set; }
|
||||||
|
public bool UpdatedWorkOrder { get; private set; }
|
||||||
|
|
||||||
|
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
|
||||||
|
DbCommand command,
|
||||||
|
CommandEventData eventData,
|
||||||
|
InterceptionResult<DbDataReader> result,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (Armed && command.Transaction is not null)
|
||||||
|
{
|
||||||
|
UpdatedUplift |= command.CommandText.Contains("UPDATE \"DispatchUpliftRequests\"");
|
||||||
|
UpdatedWorkOrder |= command.CommandText.Contains("UPDATE \"workOrders\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FailCommitInterceptor : DbTransactionInterceptor
|
||||||
|
{
|
||||||
|
public bool Armed { get; set; }
|
||||||
|
|
||||||
|
public override ValueTask<InterceptionResult> TransactionCommittingAsync(
|
||||||
|
DbTransaction transaction,
|
||||||
|
TransactionEventData eventData,
|
||||||
|
InterceptionResult result,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (Armed)
|
||||||
|
throw new InvalidOperationException("Simulated failure before the commit");
|
||||||
|
|
||||||
|
return base.TransactionCommittingAsync(transaction, eventData, result, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -68,9 +68,8 @@ public class WorkOrderOverdueTypeTests
|
||||||
private static WorkOrderBoardUpdateService NewUpdateService(ApplicationDbContext context)
|
private static WorkOrderBoardUpdateService NewUpdateService(ApplicationDbContext context)
|
||||||
{
|
{
|
||||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||||
return new WorkOrderBoardUpdateService(
|
return WorkOrderAccountTestHelpers.BoardUpdateService(
|
||||||
new WorkOrderBoardDataService(context),
|
context,
|
||||||
new WorkOrderBoardMutationDataService(context),
|
|
||||||
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks),
|
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks),
|
||||||
new ServicesRegistryService(new ServicesRegistryDataService(context)));
|
new ServicesRegistryService(new ServicesRegistryDataService(context)));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ public class WorkOrderDocStatusPatchTests
|
||||||
var mutationData = new WorkOrderBoardMutationDataService(context);
|
var mutationData = new WorkOrderBoardMutationDataService(context);
|
||||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||||
var service = new WorkOrderBoardUpdateService(boardData, mutationData, audit);
|
var service = WorkOrderAccountTestHelpers.BoardUpdateService(context, audit);
|
||||||
return (context, service);
|
return (context, service);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -357,7 +357,7 @@ public class WorkOrderDetailServiceTests
|
||||||
var boardData = new WorkOrderBoardDataService(context);
|
var boardData = new WorkOrderBoardDataService(context);
|
||||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||||
var update = new WorkOrderBoardUpdateService(boardData, mutationData, audit);
|
var update = WorkOrderAccountTestHelpers.BoardUpdateService(context, audit);
|
||||||
|
|
||||||
await update.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
await update.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -595,9 +595,8 @@ public class WorkOrderPocServiceTests
|
||||||
private static WorkOrderBoardUpdateService CreateUpdateService(ApplicationDbContext context)
|
private static WorkOrderBoardUpdateService CreateUpdateService(ApplicationDbContext context)
|
||||||
{
|
{
|
||||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||||
return new WorkOrderBoardUpdateService(
|
return WorkOrderAccountTestHelpers.BoardUpdateService(
|
||||||
new WorkOrderBoardDataService(context),
|
context,
|
||||||
new WorkOrderBoardMutationDataService(context),
|
|
||||||
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks));
|
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,16 @@ public sealed class WorkOrderReconciliationTests
|
||||||
Assert.Equal("winner", (await context.workOrders.SingleAsync()).WorkerOrderTitle);
|
Assert.Equal("winner", (await context.workOrders.SingleAsync()).WorkerOrderTitle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static WorkOrderReconciliationService CreateService(
|
||||||
|
IReadOnlyList<ProcurementWorkOrder> workOrders,
|
||||||
|
IWorkOrderWebhookDataService data) =>
|
||||||
|
Create(
|
||||||
|
new MockClient(
|
||||||
|
new[] { new ProcurementPage<ProcurementWorkOrder>(workOrders, null) },
|
||||||
|
new()),
|
||||||
|
data,
|
||||||
|
new RecordingJobs());
|
||||||
|
|
||||||
private static WorkOrderReconciliationService Create(
|
private static WorkOrderReconciliationService Create(
|
||||||
IProcurementWorkOrderClient client,
|
IProcurementWorkOrderClient client,
|
||||||
IWorkOrderWebhookDataService workOrders,
|
IWorkOrderWebhookDataService workOrders,
|
||||||
|
|
|
||||||
|
|
@ -59,9 +59,8 @@ public sealed class WorkOrderUpliftDispatchOwnershipTests
|
||||||
private static WorkOrderBoardUpdateService NewBoardUpdateService(ApplicationDbContext context)
|
private static WorkOrderBoardUpdateService NewBoardUpdateService(ApplicationDbContext context)
|
||||||
{
|
{
|
||||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||||
return new WorkOrderBoardUpdateService(
|
return WorkOrderAccountTestHelpers.BoardUpdateService(
|
||||||
new WorkOrderBoardDataService(context),
|
context,
|
||||||
new WorkOrderBoardMutationDataService(context),
|
|
||||||
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks));
|
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -608,6 +608,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