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()
{