diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 97642ab..b65093e 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -237,6 +237,29 @@ namespace SeaHaven.DataServices.Implementation && (u.IsDeleted == null || u.IsDeleted == false), cancellationToken); } + public Task HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) + { + return ForWorkOrder(workOrderId) + .AnyAsync(u => u.Status == "Pending" || u.Status == "ChangesRequested", cancellationToken); + } + + public Task SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) + { + return ForWorkOrder(workOrderId) + .Where(u => u.Status == "NoApprovalRequired") + .SumAsync(u => u.RequestedNTE, cancellationToken); + } + + public Task> GetPendingForWorkOrderAsync( + int workOrderId, + CancellationToken cancellationToken) + { + return ForWorkOrder(workOrderId) + .Include(u => u.Dispatch) + .Where(u => u.Status == "Pending" || u.Status == "ChangesRequested") + .ToListAsync(cancellationToken); + } + public async Task HasActiveAsync(int dispatchId, CancellationToken cancellationToken) { return await _context.DispatchUpliftRequests @@ -295,6 +318,17 @@ namespace SeaHaven.DataServices.Implementation .ToListAsync(cancellationToken); } + private IQueryable ForWorkOrder(int 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) { await _context.DispatchUpliftRequests.AddAsync(request, cancellationToken); diff --git a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs index c5e46e6..b64bc68 100644 --- a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs @@ -16,6 +16,9 @@ namespace SeaHaven.DataServices.Interfaces // Returns null when the request, the linked evidence, or the dispatch linkage is absent. Task GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken); Task HasPendingAsync(int dispatchId, CancellationToken cancellationToken); + Task HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); + Task SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); + Task> GetPendingForWorkOrderAsync(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.Services/Helpers/WorkOrderUpliftAllowance.cs b/SeaHaven.Services/Helpers/WorkOrderUpliftAllowance.cs new file mode 100644 index 0000000..2c8a97d --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderUpliftAllowance.cs @@ -0,0 +1,19 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderUpliftAllowance + { + public const decimal StandardCapUsd = 500m; + public const decimal EmergencyCapUsd = 5000m; + + public static decimal CapFor(WorkOrderType? type) => + type == WorkOrderType.Emergency ? EmergencyCapUsd : StandardCapUsd; + + public static decimal Remaining(decimal cap, decimal consumedAutoApproved) => + Math.Max(0m, cap - consumedAutoApproved); + + public static bool AutoApproves(decimal amount, decimal remaining) => + amount <= remaining; + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs index ec6d73d..e847b23 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs @@ -13,15 +13,18 @@ namespace SeaHaven.Services.Implementation private readonly IWorkOrderBoardMutationDataService _mutationData; private readonly IWorkOrderBoardService _boardService; private readonly IWorkOrderAuditService _auditService; + private readonly IWorkOrderUpliftService _upliftService; public WorkOrderBoardCancelService( IWorkOrderBoardMutationDataService mutationData, IWorkOrderBoardService boardService, - IWorkOrderAuditService auditService) + IWorkOrderAuditService auditService, + IWorkOrderUpliftService upliftService) { _mutationData = mutationData; _boardService = boardService; _auditService = auditService; + _upliftService = upliftService; } public async Task CancelAsync( @@ -49,6 +52,7 @@ namespace SeaHaven.Services.Implementation if (workOrder.LegacyStatus == null && workOrder.Status != null) workOrder.LegacyStatus = workOrder.Status; + await _upliftService.WithdrawPendingForWorkOrderAsync(workOrderId, actorId, CancellationToken.None); await _auditService.StageStatusChangedAsync(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId); await _mutationData.SaveAsync(CancellationToken.None); diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs index bd79b44..1111186 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs @@ -77,59 +77,53 @@ namespace SeaHaven.Services.Implementation if (IsTerminalForUplift(dispatch.Status)) throw new InvalidOperationException($"Cannot request uplift on a '{dispatch.Status}' dispatch"); + if (await _upliftData.HasPendingForWorkOrderAsync(workOrderId, cancellationToken)) + throw new InvalidOperationException("An open uplift request already exists for this work order"); + var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); var requesterName = await ResolveUserDisplayNameAsync(userId, cancellationToken); var now = _timeProvider.GetUtcNow().UtcDateTime; var current = dispatch.NTEAmount ?? 0m; var notes = request.Notes?.Trim() ?? ""; + var consumed = await _upliftData.SumAutoApprovedAmountForWorkOrderAsync(workOrderId, cancellationToken); + var remaining = WorkOrderUpliftAllowance.Remaining( + WorkOrderUpliftAllowance.CapFor(workOrder.WorkOrderType), + consumed); - if (request.Amount <= current) + if (WorkOrderUpliftAllowance.AutoApproves(request.Amount, remaining)) { - var autoApproved = new DispatchUpliftRequest - { - DispatchId = dispatchId, - CurrentNTE = current, - RequestedNTE = request.Amount, - VendorReason = notes, - Status = UpliftStatus.NoApprovalRequired, - RequiredTier = 0, - RequestedByVendorName = requesterName, - CreatedDate = now, - createdby = userId, - NotificationStatus = UpliftNotificationStatus.Sent, - }; - await _upliftData.StageAsync(autoApproved, cancellationToken); - await StageAuditAsync(dispatch, workOrderId, userId, current, request.Amount, "uplift_auto_approved", now, cancellationToken); - await _upliftData.SaveChangesAsync(cancellationToken); - return WorkOrderUpliftContractMapper.MapItem(autoApproved, requesterName, null); + return await PersistCreatedAsync( + dispatch, + workOrderId, + userId, + requesterName, + current, + request.Amount, + notes, + UpliftStatus.NoApprovalRequired, + requiredTier: 0, + expiresAt: null, + UpliftNotificationStatus.Sent, + "uplift_auto_approved", + now, + cancellationToken); } - if (await _upliftData.HasActiveAsync(dispatchId, cancellationToken)) - throw new InvalidOperationException("An active uplift request already exists for this dispatch"); - - var tier1Max = _approvalsOptions.UpliftTier1MaxUsd ?? 2500m; - var delta = request.Amount - current; - var requiredTier = delta > tier1Max ? 2 : 1; - var expiresAt = now + _approvalsOptions.EffectiveExpiration; - - var pending = new DispatchUpliftRequest - { - DispatchId = dispatchId, - CurrentNTE = current, - RequestedNTE = request.Amount, - VendorReason = notes, - Status = UpliftStatus.Pending, - RequiredTier = requiredTier, - RequestedByVendorName = requesterName, - CreatedDate = now, - createdby = userId, - ExpiresAt = expiresAt, - NotificationStatus = UpliftNotificationStatus.Pending, - }; - await _upliftData.StageAsync(pending, cancellationToken); - await StageAuditAsync(dispatch, workOrderId, userId, current, request.Amount, "uplift_requested", now, cancellationToken); - await _upliftData.SaveChangesAsync(cancellationToken); - return WorkOrderUpliftContractMapper.MapItem(pending, requesterName, null); + return await PersistCreatedAsync( + dispatch, + workOrderId, + userId, + requesterName, + current, + request.Amount, + notes, + UpliftStatus.Pending, + requiredTier: 1, + now + _approvalsOptions.EffectiveExpiration, + UpliftNotificationStatus.Pending, + "uplift_requested", + now, + cancellationToken); } public async Task CancelAsync( @@ -251,6 +245,82 @@ namespace SeaHaven.Services.Implementation return WorkOrderUpliftContractMapper.MapRevokedItem(req, requesterName, decidedByName); } + public async Task WithdrawPendingForWorkOrderAsync( + int workOrderId, + string? actorId, + CancellationToken cancellationToken) + { + var pending = await _upliftData.GetPendingForWorkOrderAsync(workOrderId, cancellationToken); + if (pending.Count == 0) + return; + + var now = _timeProvider.GetUtcNow().UtcDateTime; + 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); + } + + await _upliftData.SaveChangesAsync(cancellationToken); + } + + private async Task PersistCreatedAsync( + Dispatch dispatch, + int workOrderId, + string? userId, + string requesterName, + decimal currentNte, + decimal amount, + string notes, + string status, + int requiredTier, + DateTime? expiresAt, + string notificationStatus, + string auditAction, + DateTime now, + CancellationToken cancellationToken) + { + var created = new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + CurrentNTE = currentNte, + RequestedNTE = amount, + VendorReason = notes, + Status = status, + RequiredTier = requiredTier, + RequestedByVendorName = requesterName, + CreatedDate = now, + createdby = userId, + ExpiresAt = expiresAt, + NotificationStatus = notificationStatus, + }; + await _upliftData.StageAsync(created, cancellationToken); + await StageAuditAsync(dispatch, workOrderId, userId, currentNte, amount, auditAction, now, cancellationToken); + await _upliftData.SaveChangesAsync(cancellationToken); + return WorkOrderUpliftContractMapper.MapItem(created, requesterName, null); + } + private async Task HasWorkOrderAccessAsync( int workOrderId, ClaimsPrincipal user, diff --git a/SeaHaven.Services/Interfaces/IWorkOrderUpliftService.cs b/SeaHaven.Services/Interfaces/IWorkOrderUpliftService.cs index 84d6940..962ffb4 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderUpliftService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderUpliftService.cs @@ -28,5 +28,10 @@ namespace SeaHaven.Services.Interfaces RevokeWorkOrderUpliftRequestDto request, ClaimsPrincipal user, CancellationToken cancellationToken); + + Task WithdrawPendingForWorkOrderAsync( + int workOrderId, + string? actorId, + CancellationToken cancellationToken); } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index 0538e90..1f8e2b0 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -1,10 +1,14 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; namespace SeaHavenIndustries.Tests; @@ -21,7 +25,7 @@ public class WorkOrderBoardCancelServiceTests var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context)); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); - var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit); + var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, new NoOpUpliftService()); var update = new WorkOrderBoardUpdateService(boardData, mutationData, audit); return (context, cancel, update); } @@ -149,4 +153,100 @@ public class WorkOrderBoardCancelServiceTests Assert.Equal("CanceledReadOnly", ex.Code); } + + [Fact] + public async Task Cancel_WithdrawsPendingUpliftAndWritesAudit() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using var context = new ApplicationDbContext(options); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); + context.Dispatches.Add(new Dispatch + { + Id = 10, + VendorId = 1, + WorkOrderId = 1, + DispatchNumber = "DIS-10", + Status = "Scheduled", + }); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + Status = "Incomplete", + PrimaryDispatchId = 10, + AccountId = 1, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + Id = 100, + DispatchId = 10, + RequestedNTE = 600m, + Status = "Pending", + RequiredTier = 1, + NotificationStatus = "Pending", + }); + await context.SaveChangesAsync(); + + var boardData = new WorkOrderBoardDataService(context); + var mutationData = new WorkOrderBoardMutationDataService(context); + var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context)); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var uplifts = new WorkOrderUpliftService( + new UpliftDataService(context), + new DispatchDataService(context), + new WorkOrderDetailDataService(context), + WorkOrderAccountTestHelpers.Resolver(context), + new UserDataService(context), + TimeProvider.System, + Options.Create(new ApprovalsOptions())); + var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, uplifts); + + var result = await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1"); + + Assert.Equal(LifecycleStatus.Canceled, result.LifecycleStatus); + Assert.Equal("Withdrawn", Assert.Single(context.DispatchUpliftRequests).Status); + Assert.Contains(context.WorkOrderAuditLogs, log => log.Action == "uplift_cancel"); + Assert.Contains(context.WorkOrderAuditLogs, log => log.Action == "StatusChanged"); + } + + private sealed class NoOpUpliftService : IWorkOrderUpliftService + { + public Task?> ListAsync( + int workOrderId, + System.Security.Claims.ClaimsPrincipal user, + CancellationToken cancellationToken) => + Task.FromResult?>(Array.Empty()); + + public Task CreateAsync( + int workOrderId, + CreateWorkOrderUpliftRequestDto request, + System.Security.Claims.ClaimsPrincipal user, + CancellationToken cancellationToken) => + Task.FromResult(null); + + public Task CancelAsync( + int workOrderId, + int upliftId, + System.Security.Claims.ClaimsPrincipal user, + CancellationToken cancellationToken) => + Task.FromResult(null); + + public Task RevokeAsync( + int workOrderId, + int upliftId, + RevokeWorkOrderUpliftRequestDto request, + System.Security.Claims.ClaimsPrincipal user, + CancellationToken cancellationToken) => + Task.FromResult(null); + + public Task WithdrawPendingForWorkOrderAsync( + int workOrderId, + string? actorId, + CancellationToken cancellationToken) => Task.CompletedTask; + } } diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs index 21b010a..0ac7d7a 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using SeaHaven.DataServices.Implementation; @@ -43,7 +44,9 @@ public sealed class WorkOrderUpliftServiceTests private static ClaimsPrincipal Dispatcher(string userId = "dispatcher-1") => WorkOrderAccountTestHelpers.OrgWideAdmin(userId); - private static async Task<(WorkOrder WorkOrder, Dispatch Dispatch)> SeedWorkOrderAsync(ApplicationDbContext context) + private static async Task<(WorkOrder WorkOrder, Dispatch Dispatch)> SeedWorkOrderAsync( + ApplicationDbContext context, + WorkOrderType type = WorkOrderType.PM) { await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); context.Users.Add(new ApplicationUser @@ -69,10 +72,11 @@ public sealed class WorkOrderUpliftServiceTests InternalWONumber = "10000000001", PrimaryDispatchId = 10, AccountId = 1, + WorkOrderType = type, }; context.workOrders.Add(workOrder); await context.SaveChangesAsync(); - return (workOrder, context.Dispatches.Single()); + return (workOrder, context.Dispatches.Single(d => d.Id == 10)); } [Fact] @@ -102,7 +106,7 @@ public sealed class WorkOrderUpliftServiceTests } [Fact] - public async Task CreateAsync_AboveCurrentNte_CreatesPendingRequest() + public async Task CreateAsync_PmAmountAtCap_CreatesAutoApprovedRequest() { await using var context = CreateContext(); var (workOrder, _) = await SeedWorkOrderAsync(context); @@ -110,7 +114,24 @@ public sealed class WorkOrderUpliftServiceTests var created = await service.CreateAsync( workOrder.Id, - new CreateWorkOrderUpliftRequestDto { Amount = 1800m, Notes = "Parts" }, + new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "Within limit" }, + Dispatcher(), + CancellationToken.None); + + Assert.NotNull(created); + Assert.Equal("auto_approved", created!.Status); + } + + [Fact] + public async Task CreateAsync_PmAmountAboveCap_CreatesPendingRequest() + { + await using var context = CreateContext(); + var (workOrder, _) = await SeedWorkOrderAsync(context); + var service = NewService(context); + + var created = await service.CreateAsync( + workOrder.Id, + new CreateWorkOrderUpliftRequestDto { Amount = 501m, Notes = "Parts" }, Dispatcher(), CancellationToken.None); @@ -121,22 +142,120 @@ public sealed class WorkOrderUpliftServiceTests } [Fact] - public async Task CreateAsync_AtOrBelowCurrentNte_CreatesAutoApprovedRequest() + public async Task CreateAsync_PmSecondRequestExceedsRemainingAllowance_CreatesPending() { await using var context = CreateContext(); var (workOrder, _) = await SeedWorkOrderAsync(context); var service = NewService(context); - var created = await service.CreateAsync( + var first = await service.CreateAsync( workOrder.Id, - new CreateWorkOrderUpliftRequestDto { Amount = 900m, Notes = "Within limit" }, + new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "First" }, + Dispatcher(), + CancellationToken.None); + Assert.Equal("auto_approved", first!.Status); + + var second = await service.CreateAsync( + workOrder.Id, + new CreateWorkOrderUpliftRequestDto { Amount = 200m, Notes = "Second" }, + Dispatcher(), + CancellationToken.None); + + Assert.Equal("pending", second!.Status); + } + + [Fact] + public async Task CreateAsync_EmergencyAtCap_CreatesAutoApprovedRequest() + { + await using var context = CreateContext(); + var (workOrder, _) = await SeedWorkOrderAsync(context, WorkOrderType.Emergency); + var service = NewService(context); + + var created = await service.CreateAsync( + workOrder.Id, + new CreateWorkOrderUpliftRequestDto { Amount = 5000m, Notes = "Emergency parts" }, Dispatcher(), CancellationToken.None); - Assert.NotNull(created); Assert.Equal("auto_approved", created!.Status); } + [Fact] + public async Task CreateAsync_EmergencyAboveCap_CreatesPendingRequest() + { + await using var context = CreateContext(); + var (workOrder, _) = await SeedWorkOrderAsync(context, WorkOrderType.Emergency); + var service = NewService(context); + + var created = await service.CreateAsync( + workOrder.Id, + new CreateWorkOrderUpliftRequestDto { Amount = 5001m, Notes = "Above emergency cap" }, + Dispatcher(), + CancellationToken.None); + + Assert.Equal("pending", created!.Status); + } + + [Fact] + public async Task CreateAsync_PendingOnOtherDispatch_BlocksNewRequest() + { + await using var context = CreateContext(); + var (workOrder, _) = await SeedWorkOrderAsync(context); + context.Dispatches.Add(new Dispatch + { + Id = 11, + VendorId = 1, + WorkOrderId = workOrder.Id, + NTEAmount = 800m, + DispatchNumber = "DIS-11", + Status = "Scheduled", + }); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + Id = 100, + DispatchId = 11, + RequestedNTE = 300m, + Status = "Pending", + RequiredTier = 1, + NotificationStatus = "Pending", + }); + await context.SaveChangesAsync(); + + var service = NewService(context); + var ex = await Assert.ThrowsAsync(() => + service.CreateAsync( + workOrder.Id, + new CreateWorkOrderUpliftRequestDto { Amount = 100m, Notes = "Second open" }, + Dispatcher(), + CancellationToken.None)); + + Assert.Contains("open uplift request", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task WithdrawPendingForWorkOrderAsync_CancelsOpenRequestsAndAudits() + { + await using var context = CreateContext(); + var (workOrder, dispatch) = await SeedWorkOrderAsync(context); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + Id = 100, + DispatchId = dispatch.Id, + RequestedNTE = 600m, + Status = "Pending", + RequiredTier = 1, + NotificationStatus = "Pending", + createdby = "dispatcher-1", + }); + await context.SaveChangesAsync(); + + var service = NewService(context); + await service.WithdrawPendingForWorkOrderAsync(workOrder.Id, "actor-1", CancellationToken.None); + + Assert.Equal("Withdrawn", Assert.Single(context.DispatchUpliftRequests).Status); + Assert.Contains(context.WorkOrderAuditLogs, log => log.Action == "uplift_cancel"); + } + [Fact] public async Task CancelAsync_PendingRequest_Withdraws() {