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