From 9bad363930a0cfdb972c1d83437c27e29fbdd137 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 18:37:56 -0300 Subject: [PATCH 1/5] fix(work-orders): cancelling from the board cancels the pending uplift The board and slide-over cancel a work order through the lifecycle status patch, which set Canceled without touching uplifts, so a pending uplift stayed in the approval queue. A patch to Canceled now withdraws pending uplifts in the same save, each with its own uplift_cancel audit entry, and runs under the per-work-order gate uplift create uses. --- .../WorkOrderBoardUpdateService.cs | 48 +++- .../WorkOrderAccountTestHelpers.cs | 25 ++ .../WorkOrderBoardCancelServiceTests.cs | 2 +- .../WorkOrderBoardConcurrencyTests.cs | 2 +- .../WorkOrderBoardPatchCancelUpliftTests.cs | 254 ++++++++++++++++++ .../WorkOrderBoardPatchLifecycleRulesTests.cs | 5 +- .../WorkOrderBoardUpdateServiceTests.cs | 2 +- .../WorkOrderCompletedSelectiveLockTests.cs | 5 +- .../WorkOrderCompletionFreezeTests.cs | 5 +- .../WorkOrderOverdueTypeTests.cs | 5 +- .../WorkOrderPhase6Tests.cs | 4 +- .../WorkOrderPocServiceTests.cs | 5 +- .../WorkOrderUpliftDispatchOwnershipTests.cs | 5 +- 13 files changed, 342 insertions(+), 25 deletions(-) create mode 100644 SeaHavenIndustries.Tests/WorkOrderBoardPatchCancelUpliftTests.cs diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index fcd8721..d34c6fb 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -16,17 +16,23 @@ namespace SeaHaven.Services.Implementation private readonly IWorkOrderBoardDataService _boardDataService; private readonly IWorkOrderBoardMutationDataService _mutationData; private readonly IWorkOrderAuditService _auditService; + private readonly IWorkOrderUpliftService _upliftService; + private readonly IUpliftDataService _upliftData; private readonly IServicesRegistryService? _servicesRegistryService; public WorkOrderBoardUpdateService( IWorkOrderBoardDataService boardDataService, IWorkOrderBoardMutationDataService mutationData, IWorkOrderAuditService auditService, + IWorkOrderUpliftService upliftService, + IUpliftDataService upliftData, IServicesRegistryService? servicesRegistryService = null) { _boardDataService = boardDataService; _mutationData = mutationData; _auditService = auditService; + _upliftService = upliftService; + _upliftData = upliftData; _servicesRegistryService = servicesRegistryService; } @@ -35,7 +41,7 @@ namespace SeaHaven.Services.Implementation WorkOrderBoardPatchRequestDto request, string? actorId) { - await _mutationData.ExecuteTransactionalAsync(async ct => + Func patch = async ct => { if (string.IsNullOrWhiteSpace(request.Field)) throw new WorkOrderBoardValidationException("InvalidField", "Field is required."); @@ -127,7 +133,17 @@ namespace SeaHaven.Services.Implementation } var auditField = WorkOrderBoardFieldNames.ToAuditFieldName(canonicalField); + var lifecycleBefore = workOrder.LifecycleStatus; 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 (lifecycleBefore != LifecycleStatus.Canceled + && workOrder.LifecycleStatus == LifecycleStatus.Canceled) + { + await _upliftService.WithdrawPendingForWorkOrderAsync(workOrderId, actorId, ct); + } if (resolved is { Created: true, Dispatch: var createdDispatch } && canonicalField.Equals(WorkOrderBoardFieldNames.VendorId, StringComparison.OrdinalIgnoreCase)) { @@ -173,12 +189,40 @@ namespace SeaHaven.Services.Implementation } 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); 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> ApplyFieldMutationAsync( string field, WorkOrder workOrder, diff --git a/SeaHavenIndustries.Tests/WorkOrderAccountTestHelpers.cs b/SeaHavenIndustries.Tests/WorkOrderAccountTestHelpers.cs index cb7c9f9..040e235 100644 --- a/SeaHavenIndustries.Tests/WorkOrderAccountTestHelpers.cs +++ b/SeaHavenIndustries.Tests/WorkOrderAccountTestHelpers.cs @@ -15,6 +15,31 @@ internal static class WorkOrderAccountTestHelpers new AccountDataService(context), new LocationDataService(context)); + /// Board update service over one context, with the real uplift cascade wired. + 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( string userId = "actor-1", int accountId = 1, diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index 46e86fc..01e93df 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -28,7 +28,7 @@ public class WorkOrderBoardCancelServiceTests var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); 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); } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs index 0aa2a17..9dd9a2a 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs @@ -25,7 +25,7 @@ public class WorkOrderBoardConcurrencyTests var mutationData = new WorkOrderBoardMutationDataService(context); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); - return new WorkOrderBoardUpdateService(boardData, mutationData, audit); + return WorkOrderAccountTestHelpers.BoardUpdateService(context, audit); } [Fact] diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardPatchCancelUpliftTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardPatchCancelUpliftTests.cs new file mode 100644 index 0000000..e2ad1f3 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardPatchCancelUpliftTests.cs @@ -0,0 +1,254 @@ +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; + +/// +/// 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. +/// +public sealed class WorkOrderBoardPatchCancelUpliftTests +{ + private static readonly byte[] Version = { 1, 0, 0, 0, 0, 0, 0, 1 }; + + private static async Task 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. + private 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", + 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(); + } + + private 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() + .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() + .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() + .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( + () => 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> SavingChangesAsync( + DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + if (Armed && eventData.Context is not null) + { + SawWithdrawnUpliftInSave = eventData.Context.ChangeTracker + .Entries() + .Any(entry => entry.State == EntityState.Modified + && entry.Entity.Status == UpliftStatus.Withdrawn); + } + + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + public override ValueTask 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); + } + } + + private sealed class SqliteRowVersionDbContext : ApplicationDbContext + { + public SqliteRowVersionDbContext(DbContextOptions 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; + } + } + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardPatchLifecycleRulesTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardPatchLifecycleRulesTests.cs index 050b78b..34a4b9d 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardPatchLifecycleRulesTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardPatchLifecycleRulesTests.cs @@ -22,9 +22,8 @@ public class WorkOrderBoardPatchLifecycleRulesTests var context = new ApplicationDbContext(options); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); - var service = new WorkOrderBoardUpdateService( - new WorkOrderBoardDataService(context), - new WorkOrderBoardMutationDataService(context), + var service = WorkOrderAccountTestHelpers.BoardUpdateService( + context, audit); return (context, service); } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index fcf096e..ae19c33 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -23,7 +23,7 @@ public class WorkOrderBoardUpdateServiceTests var mutationData = new WorkOrderBoardMutationDataService(context); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); - var service = new WorkOrderBoardUpdateService(boardData, mutationData, audit); + var service = WorkOrderAccountTestHelpers.BoardUpdateService(context, audit); return (context, service); } diff --git a/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs b/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs index cc765ed..7cce8be 100644 --- a/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs @@ -22,9 +22,8 @@ public class WorkOrderCompletedSelectiveLockTests var context = new ApplicationDbContext(options); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); - var service = new WorkOrderBoardUpdateService( - new WorkOrderBoardDataService(context), - new WorkOrderBoardMutationDataService(context), + var service = WorkOrderAccountTestHelpers.BoardUpdateService( + context, audit); return (context, service); } diff --git a/SeaHavenIndustries.Tests/WorkOrderCompletionFreezeTests.cs b/SeaHavenIndustries.Tests/WorkOrderCompletionFreezeTests.cs index 6589f9f..11f7ab4 100644 --- a/SeaHavenIndustries.Tests/WorkOrderCompletionFreezeTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderCompletionFreezeTests.cs @@ -186,9 +186,8 @@ public class WorkOrderCompletionFreezeTests private static WorkOrderBoardUpdateService CreateService(ApplicationDbContext context) { var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); - return new WorkOrderBoardUpdateService( - new WorkOrderBoardDataService(context), - new WorkOrderBoardMutationDataService(context), + return WorkOrderAccountTestHelpers.BoardUpdateService( + context, new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks)); } diff --git a/SeaHavenIndustries.Tests/WorkOrderOverdueTypeTests.cs b/SeaHavenIndustries.Tests/WorkOrderOverdueTypeTests.cs index d08ac91..ea30df3 100644 --- a/SeaHavenIndustries.Tests/WorkOrderOverdueTypeTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderOverdueTypeTests.cs @@ -68,9 +68,8 @@ public class WorkOrderOverdueTypeTests private static WorkOrderBoardUpdateService NewUpdateService(ApplicationDbContext context) { var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); - return new WorkOrderBoardUpdateService( - new WorkOrderBoardDataService(context), - new WorkOrderBoardMutationDataService(context), + return WorkOrderAccountTestHelpers.BoardUpdateService( + context, new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks), new ServicesRegistryService(new ServicesRegistryDataService(context))); } diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index ea77fb4..abca125 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -28,7 +28,7 @@ public class WorkOrderDocStatusPatchTests var mutationData = new WorkOrderBoardMutationDataService(context); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); - var service = new WorkOrderBoardUpdateService(boardData, mutationData, audit); + var service = WorkOrderAccountTestHelpers.BoardUpdateService(context, audit); return (context, service); } @@ -357,7 +357,7 @@ public class WorkOrderDetailServiceTests var boardData = new WorkOrderBoardDataService(context); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); 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 { diff --git a/SeaHavenIndustries.Tests/WorkOrderPocServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderPocServiceTests.cs index ad1ffa2..9e40739 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPocServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPocServiceTests.cs @@ -595,9 +595,8 @@ public class WorkOrderPocServiceTests private static WorkOrderBoardUpdateService CreateUpdateService(ApplicationDbContext context) { var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); - return new WorkOrderBoardUpdateService( - new WorkOrderBoardDataService(context), - new WorkOrderBoardMutationDataService(context), + return WorkOrderAccountTestHelpers.BoardUpdateService( + context, new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks)); } diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftDispatchOwnershipTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftDispatchOwnershipTests.cs index adba1a7..40ff0fd 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftDispatchOwnershipTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftDispatchOwnershipTests.cs @@ -59,9 +59,8 @@ public sealed class WorkOrderUpliftDispatchOwnershipTests private static WorkOrderBoardUpdateService NewBoardUpdateService(ApplicationDbContext context) { var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); - return new WorkOrderBoardUpdateService( - new WorkOrderBoardDataService(context), - new WorkOrderBoardMutationDataService(context), + return WorkOrderAccountTestHelpers.BoardUpdateService( + context, new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks)); } From 99499c3281d4c7eba3bbd55e1b82cb0942e80e99 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 18:48:44 -0300 Subject: [PATCH 2/5] 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. --- .../Helpers/PendingUpliftCancellation.cs | 79 +++++++ .../Implementation/UpliftDataService.cs | 27 +-- .../WorkOrderWebhookDataService.cs | 16 ++ .../Interfaces/IUpliftDataService.cs | 8 +- .../WorkOrderBoardUpdateService.cs | 3 +- .../Implementation/WorkOrderUpliftService.cs | 36 +-- .../WorkOrderBoardCancelServiceTests.cs | 2 +- .../WorkOrderBoardPatchCancelUpliftTests.cs | 7 +- .../WorkOrderCrmCancelUpliftTests.cs | 222 ++++++++++++++++++ .../WorkOrderReconciliationTests.cs | 10 + 10 files changed, 356 insertions(+), 54 deletions(-) create mode 100644 SeaHaven.DataServices/Helpers/PendingUpliftCancellation.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderCrmCancelUpliftTests.cs 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, From 306ab159cccc7a0448703f73d0fc182b3f883212 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 19:01:37 -0300 Subject: [PATCH 3/5] Cancel pending uplifts when the legacy ingest cancels a work order The ingest writes only the status text, so the shared helper gains a status-text form of the same rule and the ingest stages the same sync-attributed cancellation in its batch save. --- .../Helpers/PendingUpliftCancellation.cs | 13 +++- .../WorkOrderIngestDataService.cs | 11 ++++ .../Interfaces/IWorkOrderIngestDataService.cs | 3 + .../Implementation/WorkOrderIngestService.cs | 8 +++ .../WorkOrderCrmCancelUpliftTests.cs | 60 ++++++++++++++++++- 5 files changed, 92 insertions(+), 3 deletions(-) diff --git a/SeaHaven.DataServices/Helpers/PendingUpliftCancellation.cs b/SeaHaven.DataServices/Helpers/PendingUpliftCancellation.cs index 1a4e3c4..2264a2a 100644 --- a/SeaHaven.DataServices/Helpers/PendingUpliftCancellation.cs +++ b/SeaHaven.DataServices/Helpers/PendingUpliftCancellation.cs @@ -7,7 +7,7 @@ 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 + /// CRM webhook and reconciliation, legacy ingest) 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. /// @@ -22,6 +22,17 @@ namespace SeaHaven.DataServices.Helpers public static bool Applies(LifecycleStatus? before, LifecycleStatus? after) => before != LifecycleStatus.Canceled && after == LifecycleStatus.Canceled; + /// + /// 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. + /// + public static bool AppliesToStatusText(string? before, string? after) + => !IsCancelledText(before) && IsCancelledText(after); + + private static bool IsCancelledText(string? status) + => string.Equals(status, "Cancelled", StringComparison.OrdinalIgnoreCase) + || string.Equals(status, "Canceled", StringComparison.OrdinalIgnoreCase); + /// /// Uplift requests that belong to a work order: on a live dispatch that the work /// order owns or is linked to. diff --git a/SeaHaven.DataServices/Implementation/WorkOrderIngestDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderIngestDataService.cs index 5a2a835..6c13dce 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderIngestDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderIngestDataService.cs @@ -1,5 +1,7 @@ using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; namespace SeaHaven.DataServices.Implementation @@ -31,6 +33,15 @@ namespace SeaHaven.DataServices.Implementation await _context.SaveChangesAsync(cancellationToken); } + public Task StageCancelPendingUpliftsAsync(int workOrderId, CancellationToken cancellationToken) + => PendingUpliftCancellation.StageAsync( + _context, + workOrderId, + actorId: null, + actorType: AuditActorType.Sync.ToString(), + DateTime.UtcNow, + cancellationToken); + public async Task ExecuteTransactionalAsync(Func work, CancellationToken cancellationToken) { await using var transaction = _context.Database.IsRelational() diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderIngestDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderIngestDataService.cs index b97d7c1..5017633 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderIngestDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderIngestDataService.cs @@ -9,6 +9,9 @@ namespace SeaHaven.DataServices.Interfaces void TrackWorkOrder(WorkOrder workOrder); Task FindLocationAsync(string name, CancellationToken cancellationToken); Task AddAndSaveLocationAsync(Locations location, 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 StageCancelPendingUpliftsAsync(int workOrderId, CancellationToken cancellationToken); Task ExecuteTransactionalAsync(Func work, CancellationToken cancellationToken); Task SaveAsync(CancellationToken cancellationToken); } diff --git a/SeaHaven.Services/Implementation/WorkOrderIngestService.cs b/SeaHaven.Services/Implementation/WorkOrderIngestService.cs index bdadab1..a232128 100644 --- a/SeaHaven.Services/Implementation/WorkOrderIngestService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderIngestService.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Helpers; @@ -134,8 +135,15 @@ namespace SeaHaven.Services.Implementation var mappedStatus = WorkOrderIngestFieldMapper.MapStatus(item.WoStatus); if (mappedStatus != null) + { + var statusBefore = existing.Status; 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); if (priority != null) await _mergePolicy.TryApplyAsync(syncContext, "Priority", priority); diff --git a/SeaHavenIndustries.Tests/WorkOrderCrmCancelUpliftTests.cs b/SeaHavenIndustries.Tests/WorkOrderCrmCancelUpliftTests.cs index f8ae2a1..8b7c42a 100644 --- a/SeaHavenIndustries.Tests/WorkOrderCrmCancelUpliftTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderCrmCancelUpliftTests.cs @@ -6,6 +6,7 @@ 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; @@ -14,8 +15,8 @@ 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. +/// 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. /// public sealed class WorkOrderCrmCancelUpliftTests { @@ -140,6 +141,61 @@ public sealed class WorkOrderCrmCancelUpliftTests 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"); + } + + 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() { From 77dc3d85c336676347c993f0d15d57baf836d6af Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 19:15:38 -0300 Subject: [PATCH 4/5] Serialize a CRM cancel with uplift creation on the work order lock The per-work-order gate moves into a shared data-layer helper. A CRM mutation that cancels an existing work order now runs under it, so a create in flight either commits first and is cancelled, or sees the cancelled work order. --- .../Helpers/WorkOrderMutationLock.cs | 79 +++++++++++++++++++ .../Implementation/UpliftDataService.cs | 47 +---------- .../WorkOrderWebhookDataService.cs | 26 ++++++ .../WorkOrderCrmCancelUpliftTests.cs | 37 +++++++++ 4 files changed, 144 insertions(+), 45 deletions(-) create mode 100644 SeaHaven.DataServices/Helpers/WorkOrderMutationLock.cs diff --git a/SeaHaven.DataServices/Helpers/WorkOrderMutationLock.cs b/SeaHaven.DataServices/Helpers/WorkOrderMutationLock.cs new file mode 100644 index 0000000..e15df5d --- /dev/null +++ b/SeaHaven.DataServices/Helpers/WorkOrderMutationLock.cs @@ -0,0 +1,79 @@ +using System.Collections.Concurrent; +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; + +namespace SeaHaven.DataServices.Helpers +{ + /// + /// 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. + /// + public static class WorkOrderMutationLock + { + private static readonly ConcurrentDictionary Gates = new(); + + /// + /// Runs under the work order's gate and row lock. The + /// transaction commits when the work returns and (when + /// given) accepts its result; otherwise, or when the work throws, it rolls back. + /// + public static async Task RunAsync( + ApplicationDbContext context, + int workOrderId, + Func> work, + CancellationToken cancellationToken, + Func? commitWhen = null) + { + var gate = Gates.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 LockRowAsync(context, workOrderId, 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 + { + 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); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 3f9cf21..9f4e0c5 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; @@ -10,8 +9,6 @@ namespace SeaHaven.DataServices.Implementation { public class UpliftDataService : IUpliftDataService { - private static readonly ConcurrentDictionary WorkOrderGates = new(); - // The Rejected queue also surfaces the legacy "Denied" spelling, which reads as Rejected. private static readonly string[] RejectedStatuses = { "Rejected", "Denied" }; private readonly ApplicationDbContext _context; @@ -590,50 +587,10 @@ namespace SeaHaven.DataServices.Implementation return message.Contains(activeDispatchIndex, StringComparison.OrdinalIgnoreCase); } - public async Task ExecuteWorkOrderMutationAsync( + public Task ExecuteWorkOrderMutationAsync( int workOrderId, Func> work, CancellationToken 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); - } + => WorkOrderMutationLock.RunAsync(_context, workOrderId, work, cancellationToken); } } diff --git a/SeaHaven.DataServices/Implementation/WorkOrderWebhookDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderWebhookDataService.cs index 49cdc7b..bc0915a 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderWebhookDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderWebhookDataService.cs @@ -20,6 +20,32 @@ namespace SeaHaven.DataServices.Implementation public async Task ApplyAsync( WorkOrderWebhookMutation mutation, 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 ApplyUnlockedAsync( + WorkOrderWebhookMutation mutation, + CancellationToken cancellationToken) { var prior = await _context.WorkOrderWebhookDeliveries .AsNoTracking() diff --git a/SeaHavenIndustries.Tests/WorkOrderCrmCancelUpliftTests.cs b/SeaHavenIndustries.Tests/WorkOrderCrmCancelUpliftTests.cs index 8b7c42a..e576095 100644 --- a/SeaHavenIndustries.Tests/WorkOrderCrmCancelUpliftTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderCrmCancelUpliftTests.cs @@ -93,6 +93,43 @@ public sealed class WorkOrderCrmCancelUpliftTests 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() { From a32471d4c0fb54be28924e201110949d5ffa7eb1 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 19:37:08 -0300 Subject: [PATCH 5/5] Serialize sync cancels and vendor uplift requests on the work order lock The legacy ingest batch holds the per-work-order lock, taken in id order, for every work order it may cancel until the batch commits. A vendor uplift request now runs under the same lock. Uplift creation on both routes refuses a work order cancelled by either lifecycle or status text, so a request can neither slip past a cancel nor land after one. --- .../UpliftWorkflowTests.cs | 70 +++++++++++ .../Helpers/PendingUpliftCancellation.cs | 7 ++ .../Helpers/WorkOrderMutationLock.cs | 33 +++++- .../Implementation/UpliftDataService.cs | 11 ++ .../WorkOrderIngestDataService.cs | 39 +++--- .../Interfaces/IUpliftDataService.cs | 3 + .../Interfaces/IWorkOrderIngestDataService.cs | 10 +- .../Implementation/VendorPortalService.cs | 111 +++++++++++------- .../Implementation/WorkOrderIngestService.cs | 15 ++- .../Implementation/WorkOrderUpliftService.cs | 9 +- .../WorkOrderBoardCancelServiceTests.cs | 1 + .../WorkOrderCrmCancelUpliftTests.cs | 66 +++++++++++ .../WorkOrderUpliftServiceTests.cs | 20 ++++ 13 files changed, 323 insertions(+), 72 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs b/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs index 0c2f27d..8c72a7e 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftWorkflowTests.cs @@ -230,6 +230,11 @@ public sealed class UpliftWorkflowTests .Returns(Task.CompletedTask); upliftData.Setup(x => x.SaveChangesAsync(It.IsAny())) .ThrowsAsync(new SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException()); + upliftData.Setup(x => x.ExecuteWorkOrderMutationAsync( + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .Returns((int _, Func> work, CancellationToken ct) => work(ct)); var service = NewPortalService(context, new FakeEmailSender(deliver: true), upliftData: upliftData.Object); var session = await service.ResolveSessionAsync(Token, CancellationToken.None); @@ -1041,4 +1046,69 @@ public sealed class UpliftWorkflowTests 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().WithMessage("*cancelled work order*"); + context.DispatchUpliftRequests.Should().BeEmpty(); + } + + [Fact] + public async Task RequestUplift_WaitsForAWorkOrderCancelInFlightAndIsThenRefused() + { + var options = new DbContextOptionsBuilder() + .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() + .WithMessage("*cancelled work order*"); + using var verify = new ApplicationDbContext(options); + verify.DispatchUpliftRequests.Should().BeEmpty(); + } } diff --git a/SeaHaven.DataServices/Helpers/PendingUpliftCancellation.cs b/SeaHaven.DataServices/Helpers/PendingUpliftCancellation.cs index 2264a2a..86ec908 100644 --- a/SeaHaven.DataServices/Helpers/PendingUpliftCancellation.cs +++ b/SeaHaven.DataServices/Helpers/PendingUpliftCancellation.cs @@ -29,6 +29,13 @@ namespace SeaHaven.DataServices.Helpers public static bool AppliesToStatusText(string? before, string? after) => !IsCancelledText(before) && IsCancelledText(after); + /// + /// 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. + /// + 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); diff --git a/SeaHaven.DataServices/Helpers/WorkOrderMutationLock.cs b/SeaHaven.DataServices/Helpers/WorkOrderMutationLock.cs index e15df5d..a1618a0 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderMutationLock.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderMutationLock.cs @@ -20,23 +20,45 @@ namespace SeaHaven.DataServices.Helpers /// transaction commits when the work returns and (when /// given) accepts its result; otherwise, or when the work throws, it rolls back. /// - public static async Task RunAsync( + public static Task RunAsync( ApplicationDbContext context, int workOrderId, Func> work, CancellationToken cancellationToken, Func? commitWhen = null) + => RunManyAsync(context, new[] { workOrderId }, work, cancellationToken, commitWhen); + + /// + /// 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. + /// + public static async Task RunManyAsync( + ApplicationDbContext context, + IEnumerable workOrderIds, + Func> work, + CancellationToken cancellationToken, + Func? commitWhen = null) { - var gate = Gates.GetOrAdd(workOrderId, _ => new SemaphoreSlim(1, 1)); - await gate.WaitAsync(cancellationToken); + var ids = workOrderIds.Distinct().OrderBy(id => id).ToList(); + var held = new List(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 { - await LockRowAsync(context, workOrderId, cancellationToken); + foreach (var id in ids) + await LockRowAsync(context, id, cancellationToken); + var result = await work(cancellationToken); if (transaction is not null) { @@ -56,7 +78,8 @@ namespace SeaHaven.DataServices.Helpers } finally { - gate.Release(); + foreach (var gate in held) + gate.Release(); } } diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 9f4e0c5..6bee67c 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -465,6 +465,17 @@ namespace SeaHaven.DataServices.Implementation now, cancellationToken); + public async Task IsWorkOrderCancelledAsync(int workOrderId, CancellationToken cancellationToken) + { + var workOrder = await _context.workOrders + .AsNoTracking() + .Where(w => w.Id == workOrderId) + .Select(w => new { w.LifecycleStatus, w.Status }) + .FirstOrDefaultAsync(cancellationToken); + return workOrder is not null + && PendingUpliftCancellation.IsCancelled(workOrder.LifecycleStatus, workOrder.Status); + } + public async Task HasActiveAsync(int dispatchId, CancellationToken cancellationToken) { return await _context.DispatchUpliftRequests diff --git a/SeaHaven.DataServices/Implementation/WorkOrderIngestDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderIngestDataService.cs index 6c13dce..5d0f4ff 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderIngestDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderIngestDataService.cs @@ -42,25 +42,28 @@ namespace SeaHaven.DataServices.Implementation DateTime.UtcNow, cancellationToken); - public async Task ExecuteTransactionalAsync(Func work, CancellationToken cancellationToken) - { - await using var transaction = _context.Database.IsRelational() - ? await _context.Database.BeginTransactionAsync(cancellationToken) - : null; + public async Task> GetWorkOrderIdsByExternalIdsAsync( + IReadOnlyCollection externalWorkOrderIds, + CancellationToken cancellationToken) + => await _context.workOrders + .AsNoTracking() + .Where(w => w.ExternalWorkOrderId != null && externalWorkOrderIds.Contains(w.ExternalWorkOrderId)) + .Select(w => w.Id) + .ToListAsync(cancellationToken); - try - { - await work(cancellationToken); - if (transaction is not null) - await transaction.CommitAsync(cancellationToken); - } - catch - { - if (transaction is not null) - await transaction.RollbackAsync(cancellationToken); - throw; - } - } + public Task ExecuteTransactionalAsync( + IReadOnlyCollection lockedWorkOrderIds, + Func work, + CancellationToken cancellationToken) + => WorkOrderMutationLock.RunManyAsync( + _context, + lockedWorkOrderIds, + async ct => + { + await work(ct); + return true; + }, + cancellationToken); public Task SaveAsync(CancellationToken cancellationToken) => _context.SaveChangesAsync(cancellationToken); diff --git a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs index 1053202..c160561 100644 --- a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs @@ -39,6 +39,9 @@ namespace SeaHaven.DataServices.Interfaces 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 IsWorkOrderCancelledAsync(int workOrderId, 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.DataServices/Interfaces/IWorkOrderIngestDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderIngestDataService.cs index 5017633..8b6d3b8 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderIngestDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderIngestDataService.cs @@ -12,7 +12,15 @@ namespace SeaHaven.DataServices.Interfaces // Stages (does not save) the sync-attributed cancellation of the work order's pending // uplifts, each with its own audit row. See PendingUpliftCancellation. Task StageCancelPendingUpliftsAsync(int workOrderId, CancellationToken cancellationToken); - Task ExecuteTransactionalAsync(Func work, CancellationToken cancellationToken); + Task> GetWorkOrderIdsByExternalIdsAsync( + IReadOnlyCollection 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 lockedWorkOrderIds, + Func work, + CancellationToken cancellationToken); Task SaveAsync(CancellationToken cancellationToken); } } diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index d0f14d1..e756eb3 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -670,54 +670,75 @@ namespace SeaHaven.Services.Implementation } } - // At most one active (Pending or ChangesRequested) request per dispatch. - var activeExists = await _upliftData.HasActiveAsync(id, cancellationToken); - if (activeExists) + var vendorReason = reason.Trim(); + var evidenceId = evidence.Id; + + // 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 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 delta = requestedNTE - current; - var requiredTier = delta > tier1Max ? 2 : 1; - 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(); - } + var req = dispatch.WorkOrderId is int lockedWorkOrderId + ? await _upliftData.ExecuteWorkOrderMutationAsync(lockedWorkOrderId, PersistRequestAsync, cancellationToken) + : await PersistRequestAsync(cancellationToken); // Notification is best-effort and persisted separately from workflow state: a failure // never destroys the actionable request (the lifecycle sweep retries by sentinel). diff --git a/SeaHaven.Services/Implementation/WorkOrderIngestService.cs b/SeaHaven.Services/Implementation/WorkOrderIngestService.cs index a232128..ee4e129 100644 --- a/SeaHaven.Services/Implementation/WorkOrderIngestService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderIngestService.cs @@ -37,7 +37,20 @@ namespace SeaHaven.Services.Implementation if (items.Count == 0) 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() + : await _ingestData.GetWorkOrderIdsByExternalIdsAsync(cancellingExternalIds, cancellationToken); + + await _ingestData.ExecuteTransactionalAsync(lockedWorkOrderIds, async cancellationToken => { var nextSeed = await AllocateNextWoSeedAsync(cancellationToken); diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs index 79da4a2..cf8131f 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs @@ -2,6 +2,7 @@ using System.Security.Claims; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.Constants; @@ -139,10 +140,14 @@ namespace SeaHaven.Services.Implementation if (workOrder == null) 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( - $"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. diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index 76e521a..aa93e28 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -462,6 +462,7 @@ public class WorkOrderBoardCancelServiceTests public Task> GetApprovedExposureForWorkOrdersAsync(IReadOnlyCollection workOrderIds, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetPendingExposureTotalAsync(CancellationToken cancellationToken) => throw new NotSupportedException(); public Task StageCancelPendingForWorkOrderAsync(int workOrderId, string? actorId, DateTime now, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task IsWorkOrderCancelledAsync(int workOrderId, 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/WorkOrderCrmCancelUpliftTests.cs b/SeaHavenIndustries.Tests/WorkOrderCrmCancelUpliftTests.cs index e576095..62d6cb7 100644 --- a/SeaHavenIndustries.Tests/WorkOrderCrmCancelUpliftTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderCrmCancelUpliftTests.cs @@ -221,6 +221,72 @@ public sealed class WorkOrderCrmCancelUpliftTests 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() + .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)); diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs index 390beaf..a22c10d 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs @@ -603,6 +603,26 @@ public sealed class WorkOrderUpliftServiceTests 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(() => 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] [InlineData(LifecycleStatus.Completed)] [InlineData(LifecycleStatus.Canceled)]