mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
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.
This commit is contained in:
parent
9bad363930
commit
99499c3281
10 changed files with 356 additions and 54 deletions
79
SeaHaven.DataServices/Helpers/PendingUpliftCancellation.cs
Normal file
79
SeaHaven.DataServices/Helpers/PendingUpliftCancellation.cs
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
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
|
||||||
|
/// 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>
|
||||||
|
public static class PendingUpliftCancellation
|
||||||
|
{
|
||||||
|
public const string AuditAction = "uplift_cancel";
|
||||||
|
public const string CancelledStatus = "Withdrawn";
|
||||||
|
|
||||||
|
private static readonly string[] PendingStatuses = { "Pending", "ChangesRequested" };
|
||||||
|
|
||||||
|
/// <summary>True when a lifecycle change moves a work order into Canceled.</summary>
|
||||||
|
public static bool Applies(LifecycleStatus? before, LifecycleStatus? after)
|
||||||
|
=> before != LifecycleStatus.Canceled && after == LifecycleStatus.Canceled;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Uplift requests that belong to a work order: on a live dispatch that the work
|
||||||
|
/// order owns or is linked to.
|
||||||
|
/// </summary>
|
||||||
|
internal static IQueryable<DispatchUpliftRequest> 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)));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal static async Task<int> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -455,15 +455,18 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
.SumAsync(cancellationToken);
|
.SumAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(
|
public Task<int> StageCancelPendingForWorkOrderAsync(
|
||||||
int workOrderId,
|
int workOrderId,
|
||||||
|
string? actorId,
|
||||||
|
DateTime now,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
=> PendingUpliftCancellation.StageAsync(
|
||||||
return ForWorkOrder(workOrderId)
|
_context,
|
||||||
.Include(u => u.Dispatch)
|
workOrderId,
|
||||||
.Where(u => u.Status == "Pending" || u.Status == "ChangesRequested")
|
actorId,
|
||||||
.ToListAsync(cancellationToken);
|
actorType: "internal",
|
||||||
}
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
public async Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken)
|
public async Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|
@ -524,15 +527,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
private IQueryable<DispatchUpliftRequest> ForWorkOrder(int workOrderId)
|
private IQueryable<DispatchUpliftRequest> ForWorkOrder(int workOrderId)
|
||||||
{
|
=> PendingUpliftCancellation.ForWorkOrder(_context, 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)));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken)
|
public async Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Storage;
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
|
using SeaHaven.DataServices.Helpers;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
namespace SeaHaven.DataServices.Implementation
|
namespace SeaHaven.DataServices.Implementation
|
||||||
|
|
@ -112,8 +114,22 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
workOrder.WorkerOrderTitle = mutation.Title ?? mutation.Description;
|
workOrder.WorkerOrderTitle = mutation.Title ?? mutation.Description;
|
||||||
workOrder.Description = mutation.Description;
|
workOrder.Description = mutation.Description;
|
||||||
workOrder.Status = mutation.IsCancelled ? "Cancelled" : mutation.Status;
|
workOrder.Status = mutation.IsCancelled ? "Cancelled" : mutation.Status;
|
||||||
|
var lifecycleBefore = workOrder.LifecycleStatus;
|
||||||
if (mutation.LifecycleStatus.HasValue)
|
if (mutation.LifecycleStatus.HasValue)
|
||||||
workOrder.LifecycleStatus = mutation.LifecycleStatus.Value;
|
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.Severity = mutation.Severity;
|
||||||
workOrder.Priority = mutation.Priority;
|
workOrder.Priority = mutation.Priority;
|
||||||
workOrder.ExternalAssignedTo = mutation.AssignedTo;
|
workOrder.ExternalAssignedTo = mutation.AssignedTo;
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,13 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
// SH-207: queue-wide pending exposure for the approvals header (sum of
|
// SH-207: queue-wide pending exposure for the approvals header (sum of
|
||||||
// RequestedNTE over non-deleted Pending requests on non-deleted dispatches).
|
// RequestedNTE over non-deleted Pending requests on non-deleted dispatches).
|
||||||
Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken);
|
Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken);
|
||||||
Task<List<DispatchUpliftRequest>> 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<int> StageCancelPendingForWorkOrderAsync(
|
||||||
|
int workOrderId,
|
||||||
|
string? actorId,
|
||||||
|
DateTime now,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
// SH-101: active = Pending or ChangesRequested (the only states that block a new request).
|
// SH-101: active = Pending or ChangesRequested (the only states that block a new request).
|
||||||
Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken);
|
Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken);
|
||||||
Task<DispatchUpliftRequest?> GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken);
|
Task<DispatchUpliftRequest?> GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -139,8 +139,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
// Cancelling the work order cancels its pending uplift in the same action: the
|
// 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
|
// withdrawal and its own audit entry are staged here and committed by the same
|
||||||
// save as the status change.
|
// save as the status change.
|
||||||
if (lifecycleBefore != LifecycleStatus.Canceled
|
if (PendingUpliftCancellation.Applies(lifecycleBefore, workOrder.LifecycleStatus))
|
||||||
&& workOrder.LifecycleStatus == LifecycleStatus.Canceled)
|
|
||||||
{
|
{
|
||||||
await _upliftService.WithdrawPendingForWorkOrderAsync(workOrderId, actorId, ct);
|
await _upliftService.WithdrawPendingForWorkOrderAsync(workOrderId, actorId, ct);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -345,37 +345,11 @@ namespace SeaHaven.Services.Implementation
|
||||||
string? actorId,
|
string? actorId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var pending = await _upliftData.GetPendingForWorkOrderAsync(workOrderId, cancellationToken);
|
await _upliftData.StageCancelPendingForWorkOrderAsync(
|
||||||
if (pending.Count == 0)
|
workOrderId,
|
||||||
return;
|
actorId,
|
||||||
|
_timeProvider.GetUtcNow().UtcDateTime,
|
||||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
cancellationToken);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<WorkOrderUpliftDto> PersistCreatedAsync(
|
private async Task<WorkOrderUpliftDto> PersistCreatedAsync(
|
||||||
|
|
|
||||||
|
|
@ -461,7 +461,7 @@ public class WorkOrderBoardCancelServiceTests
|
||||||
public Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
public Task<IReadOnlyList<WorkOrderUpliftExposureData>> GetApprovedExposureForWorkOrdersAsync(IReadOnlyCollection<int> workOrderIds, CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<IReadOnlyList<WorkOrderUpliftExposureData>> GetApprovedExposureForWorkOrdersAsync(IReadOnlyCollection<int> workOrderIds, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
public Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
public Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<int> StageCancelPendingForWorkOrderAsync(int workOrderId, string? actorId, DateTime now, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
public Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
public Task<DispatchUpliftRequest?> GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<DispatchUpliftRequest?> GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
public Task<DispatchUpliftRequest?> GetByRequestKeyAsync(int dispatchId, string requestKey, CancellationToken cancellationToken) => throw new NotSupportedException();
|
public Task<DispatchUpliftRequest?> GetByRequestKeyAsync(int dispatchId, string requestKey, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ public sealed class WorkOrderBoardPatchCancelUpliftTests
|
||||||
|
|
||||||
// Saved in stages: the work order and its primary dispatch reference each other, which a
|
// Saved in stages: the work order and its primary dispatch reference each other, which a
|
||||||
// relational provider cannot insert in one statement batch.
|
// 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.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 = "actor-1", UserName = "actor-1" });
|
||||||
|
|
@ -50,6 +50,7 @@ public sealed class WorkOrderBoardPatchCancelUpliftTests
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
InternalWONumber = "10000000001",
|
InternalWONumber = "10000000001",
|
||||||
|
ExternalWorkOrderId = "123",
|
||||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||||
Status = "Incomplete",
|
Status = "Incomplete",
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
|
|
@ -73,7 +74,7 @@ public sealed class WorkOrderBoardPatchCancelUpliftTests
|
||||||
await context.SaveChangesAsync();
|
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,
|
Id = id,
|
||||||
DispatchId = 10,
|
DispatchId = 10,
|
||||||
|
|
@ -231,7 +232,7 @@ public sealed class WorkOrderBoardPatchCancelUpliftTests
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class SqliteRowVersionDbContext : ApplicationDbContext
|
internal sealed class SqliteRowVersionDbContext : ApplicationDbContext
|
||||||
{
|
{
|
||||||
public SqliteRowVersionDbContext(DbContextOptions<ApplicationDbContext> options)
|
public SqliteRowVersionDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||||
: base(options)
|
: base(options)
|
||||||
|
|
|
||||||
222
SeaHavenIndustries.Tests/WorkOrderCrmCancelUpliftTests.cs
Normal file
222
SeaHavenIndustries.Tests/WorkOrderCrmCancelUpliftTests.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <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.
|
||||||
|
/// </summary>
|
||||||
|
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<DbContextOptions<ApplicationDbContext>> SeedInMemoryAsync(
|
||||||
|
params DispatchUpliftRequest[] uplifts)
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.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<ApplicationDbContext>()
|
||||||
|
.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<InvalidOperationException>(() => 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<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
|
||||||
|
DbCommand command,
|
||||||
|
CommandEventData eventData,
|
||||||
|
InterceptionResult<DbDataReader> 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<InterceptionResult> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -216,6 +216,16 @@ public sealed class WorkOrderReconciliationTests
|
||||||
Assert.Equal("winner", (await context.workOrders.SingleAsync()).WorkerOrderTitle);
|
Assert.Equal("winner", (await context.workOrders.SingleAsync()).WorkerOrderTitle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static WorkOrderReconciliationService CreateService(
|
||||||
|
IReadOnlyList<ProcurementWorkOrder> workOrders,
|
||||||
|
IWorkOrderWebhookDataService data) =>
|
||||||
|
Create(
|
||||||
|
new MockClient(
|
||||||
|
new[] { new ProcurementPage<ProcurementWorkOrder>(workOrders, null) },
|
||||||
|
new()),
|
||||||
|
data,
|
||||||
|
new RecordingJobs());
|
||||||
|
|
||||||
private static WorkOrderReconciliationService Create(
|
private static WorkOrderReconciliationService Create(
|
||||||
IProcurementWorkOrderClient client,
|
IProcurementWorkOrderClient client,
|
||||||
IWorkOrderWebhookDataService workOrders,
|
IWorkOrderWebhookDataService workOrders,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue