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.
This commit is contained in:
Alexandre Brandizzi 2026-09-25 19:01:37 -03:00
parent 99499c3281
commit 306ab159cc
5 changed files with 92 additions and 3 deletions

View file

@ -7,7 +7,7 @@ namespace SeaHaven.DataServices.Helpers
/// <summary>
/// The single rule and write for "a work order that becomes Canceled cancels its pending
/// uplifts in the same action". Every path that can cancel a work order (board update,
/// the cancel endpoint, CRM webhook and reconciliation) asks <see cref="Applies"/> and
/// CRM webhook and reconciliation, legacy ingest) asks <see cref="Applies"/> and
/// stages the cancellation into its own unit of work, so the uplift change and its own
/// audit row commit, or roll back, with the work order's status change.
/// </summary>
@ -22,6 +22,17 @@ namespace SeaHaven.DataServices.Helpers
public static bool Applies(LifecycleStatus? before, LifecycleStatus? after)
=> before != LifecycleStatus.Canceled && after == LifecycleStatus.Canceled;
/// <summary>
/// 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.
/// </summary>
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);
/// <summary>
/// Uplift requests that belong to a work order: on a live dispatch that the work
/// order owns or is linked to.

View file

@ -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<int> StageCancelPendingUpliftsAsync(int workOrderId, CancellationToken cancellationToken)
=> PendingUpliftCancellation.StageAsync(
_context,
workOrderId,
actorId: null,
actorType: AuditActorType.Sync.ToString(),
DateTime.UtcNow,
cancellationToken);
public async Task ExecuteTransactionalAsync(Func<CancellationToken, Task> work, CancellationToken cancellationToken)
{
await using var transaction = _context.Database.IsRelational()

View file

@ -9,6 +9,9 @@ namespace SeaHaven.DataServices.Interfaces
void TrackWorkOrder(WorkOrder workOrder);
Task<Locations?> 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<int> StageCancelPendingUpliftsAsync(int workOrderId, CancellationToken cancellationToken);
Task ExecuteTransactionalAsync(Func<CancellationToken, Task> work, CancellationToken cancellationToken);
Task SaveAsync(CancellationToken cancellationToken);
}

View file

@ -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);

View file

@ -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;
/// <summary>
/// A work order cancelled by the CRM (webhook or reconciliation, both persisted by the
/// webhook data service) cancels its pending uplift in the same save, with an audit entry
/// of its own, exactly as a cancel from the board does.
/// 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.
/// </summary>
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()
{