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)]