diff --git a/SeaHaven.DataServices/Helpers/PendingUpliftCancellation.cs b/SeaHaven.DataServices/Helpers/PendingUpliftCancellation.cs
new file mode 100644
index 0000000..1a4e3c4
--- /dev/null
+++ b/SeaHaven.DataServices/Helpers/PendingUpliftCancellation.cs
@@ -0,0 +1,79 @@
+using Data.SeaHavenIndustries;
+using Data.SeaHavenIndustries.Enums;
+using Microsoft.EntityFrameworkCore;
+
+namespace SeaHaven.DataServices.Helpers
+{
+ ///
+ /// 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 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.
+ ///
+ public static class PendingUpliftCancellation
+ {
+ public const string AuditAction = "uplift_cancel";
+ public const string CancelledStatus = "Withdrawn";
+
+ private static readonly string[] PendingStatuses = { "Pending", "ChangesRequested" };
+
+ /// True when a lifecycle change moves a work order into Canceled.
+ public static bool Applies(LifecycleStatus? before, LifecycleStatus? after)
+ => before != LifecycleStatus.Canceled && after == LifecycleStatus.Canceled;
+
+ ///
+ /// Uplift requests that belong to a work order: on a live dispatch that the work
+ /// order owns or is linked to.
+ ///
+ internal static IQueryable 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)));
+
+ ///
+ /// 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.
+ ///
+ internal static async Task 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;
+ }
+ }
+}
diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs
index 0c3706c..3f9cf21 100644
--- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs
+++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs
@@ -455,15 +455,18 @@ namespace SeaHaven.DataServices.Implementation
.SumAsync(cancellationToken);
}
- public Task> GetPendingForWorkOrderAsync(
+ public Task 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 HasActiveAsync(int dispatchId, CancellationToken cancellationToken)
{
@@ -524,15 +527,7 @@ namespace SeaHaven.DataServices.Implementation
}
private IQueryable 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)
{
diff --git a/SeaHaven.DataServices/Implementation/WorkOrderWebhookDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderWebhookDataService.cs
index f32ebd0..49cdc7b 100644
--- a/SeaHaven.DataServices/Implementation/WorkOrderWebhookDataService.cs
+++ b/SeaHaven.DataServices/Implementation/WorkOrderWebhookDataService.cs
@@ -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;
diff --git a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs
index 4dfe871..1053202 100644
--- a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs
+++ b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs
@@ -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 GetPendingExposureTotalAsync(CancellationToken cancellationToken);
- Task> 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 StageCancelPendingForWorkOrderAsync(
+ int workOrderId,
+ string? actorId,
+ DateTime now,
+ CancellationToken cancellationToken);
// SH-101: active = Pending or ChangesRequested (the only states that block a new request).
Task HasActiveAsync(int dispatchId, CancellationToken cancellationToken);
Task GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken);
diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
index d34c6fb..3a194fc 100644
--- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
@@ -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);
}
diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs
index 0980e0d..79da4a2 100644
--- a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs
@@ -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 PersistCreatedAsync(
diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs
index 01e93df..76e521a 100644
--- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs
@@ -461,7 +461,7 @@ public class WorkOrderBoardCancelServiceTests
public Task SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task> GetApprovedExposureForWorkOrdersAsync(IReadOnlyCollection workOrderIds, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task GetPendingExposureTotalAsync(CancellationToken cancellationToken) => throw new NotSupportedException();
- public Task> GetPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
+ public Task StageCancelPendingForWorkOrderAsync(int workOrderId, string? actorId, DateTime now, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task HasActiveAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task GetByRequestKeyAsync(int dispatchId, string requestKey, CancellationToken cancellationToken) => throw new NotSupportedException();
diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardPatchCancelUpliftTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardPatchCancelUpliftTests.cs
index e2ad1f3..c2f68aa 100644
--- a/SeaHavenIndustries.Tests/WorkOrderBoardPatchCancelUpliftTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderBoardPatchCancelUpliftTests.cs
@@ -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 options)
: base(options)
diff --git a/SeaHavenIndustries.Tests/WorkOrderCrmCancelUpliftTests.cs b/SeaHavenIndustries.Tests/WorkOrderCrmCancelUpliftTests.cs
new file mode 100644
index 0000000..f8ae2a1
--- /dev/null
+++ b/SeaHavenIndustries.Tests/WorkOrderCrmCancelUpliftTests.cs
@@ -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;
+
+///
+/// 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.
+///
+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> SeedInMemoryAsync(
+ params DispatchUpliftRequest[] uplifts)
+ {
+ var options = new DbContextOptionsBuilder()
+ .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()
+ .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(() => 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> ReaderExecutingAsync(
+ DbCommand command,
+ CommandEventData eventData,
+ InterceptionResult 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 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);
+ }
+ }
+}
diff --git a/SeaHavenIndustries.Tests/WorkOrderReconciliationTests.cs b/SeaHavenIndustries.Tests/WorkOrderReconciliationTests.cs
index 331ab47..fd37400 100644
--- a/SeaHavenIndustries.Tests/WorkOrderReconciliationTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderReconciliationTests.cs
@@ -216,6 +216,16 @@ public sealed class WorkOrderReconciliationTests
Assert.Equal("winner", (await context.workOrders.SingleAsync()).WorkerOrderTitle);
}
+ internal static WorkOrderReconciliationService CreateService(
+ IReadOnlyList workOrders,
+ IWorkOrderWebhookDataService data) =>
+ Create(
+ new MockClient(
+ new[] { new ProcurementPage(workOrders, null) },
+ new()),
+ data,
+ new RecordingJobs());
+
private static WorkOrderReconciliationService Create(
IProcurementWorkOrderClient client,
IWorkOrderWebhookDataService workOrders,