Cancel pending uplifts when the CRM cancels a work order

The webhook and reconciliation saves now stage the same pending-uplift
cancellation, with its own sync audit row, as the board cancel. The rule
and the write live in one data-layer helper so the paths cannot drift.
This commit is contained in:
Alexandre Brandizzi 2026-09-25 18:48:44 -03:00
parent 9bad363930
commit 99499c3281
10 changed files with 356 additions and 54 deletions

View file

@ -0,0 +1,79 @@
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,
/// the cancel endpoint, CRM webhook and reconciliation) 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>
/// 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;
}
}
}

View file

@ -455,15 +455,18 @@ namespace SeaHaven.DataServices.Implementation
.SumAsync(cancellationToken);
}
public Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(
public Task<int> StageCancelPendingForWorkOrderAsync(
int workOrderId,
string? actorId,
DateTime now,
CancellationToken cancellationToken)
{
return ForWorkOrder(workOrderId)
.Include(u => u.Dispatch)
.Where(u => u.Status == "Pending" || u.Status == "ChangesRequested")
.ToListAsync(cancellationToken);
}
=> PendingUpliftCancellation.StageAsync(
_context,
workOrderId,
actorId,
actorType: "internal",
now,
cancellationToken);
public async Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken)
{
@ -524,15 +527,7 @@ namespace SeaHaven.DataServices.Implementation
}
private IQueryable<DispatchUpliftRequest> ForWorkOrder(int 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)));
}
=> PendingUpliftCancellation.ForWorkOrder(_context, workOrderId);
public async Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken)
{

View file

@ -1,6 +1,8 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
@ -112,8 +114,22 @@ namespace SeaHaven.DataServices.Implementation
workOrder.WorkerOrderTitle = mutation.Title ?? mutation.Description;
workOrder.Description = mutation.Description;
workOrder.Status = mutation.IsCancelled ? "Cancelled" : mutation.Status;
var lifecycleBefore = workOrder.LifecycleStatus;
if (mutation.LifecycleStatus.HasValue)
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.Priority = mutation.Priority;
workOrder.ExternalAssignedTo = mutation.AssignedTo;

View file

@ -32,7 +32,13 @@ namespace SeaHaven.DataServices.Interfaces
// SH-207: queue-wide pending exposure for the approvals header (sum of
// RequestedNTE over non-deleted Pending requests on non-deleted dispatches).
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);
// SH-101: active = Pending or ChangesRequested (the only states that block a new request).
Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken);
Task<DispatchUpliftRequest?> GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken);

View file

@ -139,8 +139,7 @@ namespace SeaHaven.Services.Implementation
// 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 (lifecycleBefore != LifecycleStatus.Canceled
&& workOrder.LifecycleStatus == LifecycleStatus.Canceled)
if (PendingUpliftCancellation.Applies(lifecycleBefore, workOrder.LifecycleStatus))
{
await _upliftService.WithdrawPendingForWorkOrderAsync(workOrderId, actorId, ct);
}

View file

@ -345,37 +345,11 @@ namespace SeaHaven.Services.Implementation
string? actorId,
CancellationToken cancellationToken)
{
var pending = await _upliftData.GetPendingForWorkOrderAsync(workOrderId, cancellationToken);
if (pending.Count == 0)
return;
var now = _timeProvider.GetUtcNow().UtcDateTime;
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);
}
await _upliftData.StageCancelPendingForWorkOrderAsync(
workOrderId,
actorId,
_timeProvider.GetUtcNow().UtcDateTime,
cancellationToken);
}
private async Task<WorkOrderUpliftDto> PersistCreatedAsync(

View file

@ -461,7 +461,7 @@ public class WorkOrderBoardCancelServiceTests
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<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> HasActiveAsync(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();

View file

@ -40,7 +40,7 @@ public sealed class WorkOrderBoardPatchCancelUpliftTests
// Saved in stages: the work order and its primary dispatch reference each other, which a
// relational provider cannot insert in one statement batch.
private static async Task SeedWorkOrderAsync(ApplicationDbContext context)
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" });
@ -50,6 +50,7 @@ public sealed class WorkOrderBoardPatchCancelUpliftTests
{
Id = 1,
InternalWONumber = "10000000001",
ExternalWorkOrderId = "123",
LifecycleStatus = LifecycleStatus.Incomplete,
Status = "Incomplete",
AccountId = 1,
@ -73,7 +74,7 @@ public sealed class WorkOrderBoardPatchCancelUpliftTests
await context.SaveChangesAsync();
}
private static DispatchUpliftRequest Uplift(int id, string status, decimal amount) => new()
internal static DispatchUpliftRequest Uplift(int id, string status, decimal amount) => new()
{
Id = id,
DispatchId = 10,
@ -231,7 +232,7 @@ public sealed class WorkOrderBoardPatchCancelUpliftTests
}
}
private sealed class SqliteRowVersionDbContext : ApplicationDbContext
internal sealed class SqliteRowVersionDbContext : ApplicationDbContext
{
public SqliteRowVersionDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)

View file

@ -0,0 +1,222 @@
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.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) 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 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 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);
}
}
}

View file

@ -216,6 +216,16 @@ public sealed class WorkOrderReconciliationTests
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(
IProcurementWorkOrderClient client,
IWorkOrderWebhookDataService workOrders,