diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs index ec436ab..b3cdbfc 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderUpliftControllerTests.cs @@ -112,4 +112,39 @@ public sealed class WorkOrderUpliftControllerTests result.Should().BeOfType().Which.StatusCode.Should().Be(StatusCodes.Status403Forbidden); } + + [Fact] + public async Task CancelUplift_TerminalWorkOrder_Returns400() + { + var service = new Mock(); + service.Setup(x => x.CancelAsync(7, 99, It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Cannot change uplifts on a 'Completed' work order")); + + var controller = NewController(service); + var result = await controller.CancelUplift(7, 99, CancellationToken.None); + + result.Should().BeOfType(); + } + + [Fact] + public async Task RevokeUplift_TerminalWorkOrder_Returns400() + { + var service = new Mock(); + service.Setup(x => x.RevokeAsync( + 7, + 99, + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Cannot change uplifts on a 'Canceled' work order")); + + var controller = NewController(service, "Admin"); + var result = await controller.RevokeUplift( + 7, + 99, + new RevokeWorkOrderUpliftRequestDto { Reason = "Completed" }, + CancellationToken.None); + + result.Should().BeOfType(); + } } diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs index e847b23..f0347f2 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs @@ -14,17 +14,20 @@ namespace SeaHaven.Services.Implementation private readonly IWorkOrderBoardService _boardService; private readonly IWorkOrderAuditService _auditService; private readonly IWorkOrderUpliftService _upliftService; + private readonly IUpliftDataService _upliftData; public WorkOrderBoardCancelService( IWorkOrderBoardMutationDataService mutationData, IWorkOrderBoardService boardService, IWorkOrderAuditService auditService, - IWorkOrderUpliftService upliftService) + IWorkOrderUpliftService upliftService, + IUpliftDataService upliftData) { _mutationData = mutationData; _boardService = boardService; _auditService = auditService; _upliftService = upliftService; + _upliftData = upliftData; } public async Task CancelAsync( @@ -32,7 +35,19 @@ namespace SeaHaven.Services.Implementation ClaimsPrincipal user, string? actorId) { - var workOrder = await _mutationData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None); + return await _upliftData.ExecuteWorkOrderMutationAsync( + workOrderId, + ct => CancelLockedAsync(workOrderId, user, actorId, ct), + CancellationToken.None); + } + + private async Task CancelLockedAsync( + int workOrderId, + ClaimsPrincipal user, + string? actorId, + CancellationToken cancellationToken) + { + var workOrder = await _mutationData.GetTrackedWorkOrderAsync(workOrderId, cancellationToken); if (workOrder == null) throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); @@ -52,9 +67,9 @@ namespace SeaHaven.Services.Implementation if (workOrder.LegacyStatus == null && workOrder.Status != null) workOrder.LegacyStatus = workOrder.Status; - await _upliftService.WithdrawPendingForWorkOrderAsync(workOrderId, actorId, CancellationToken.None); + await _upliftService.WithdrawPendingForWorkOrderAsync(workOrderId, actorId, cancellationToken); await _auditService.StageStatusChangedAsync(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId); - await _mutationData.SaveAsync(CancellationToken.None); + await _mutationData.SaveAsync(cancellationToken); var row = await _boardService.GetBoardRowAsync(workOrderId, user); return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs index 45ff8ea..d53b7b9 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; @@ -97,6 +98,12 @@ namespace SeaHaven.Services.Implementation if (workOrder?.PrimaryDispatchId is not int dispatchId) throw new InvalidOperationException("Work order has no primary dispatch for uplift requests"); + if (workOrder.LifecycleStatus is LifecycleStatus.Completed or LifecycleStatus.Canceled) + { + throw new InvalidOperationException( + $"Cannot change uplifts on a '{workOrder.LifecycleStatus}' work order"); + } + var dispatch = await _dispatchData.GetByIdAsync(dispatchId); if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); @@ -159,6 +166,8 @@ namespace SeaHaven.Services.Implementation if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken)) return null; + await EnsureWorkOrderAllowsUpliftMutationAsync(workOrderId, user, cancellationToken); + var req = await _upliftData.GetByIdAndWorkOrderAsync(upliftId, workOrderId, cancellationToken); if (req == null) throw new KeyNotFoundException("Uplift request not found"); @@ -204,6 +213,8 @@ namespace SeaHaven.Services.Implementation if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken)) return null; + await EnsureWorkOrderAllowsUpliftMutationAsync(workOrderId, user, cancellationToken); + var req = await _upliftData.GetByIdAndWorkOrderAsync(upliftId, workOrderId, cancellationToken); if (req == null) throw new KeyNotFoundException("Uplift request not found"); @@ -239,7 +250,7 @@ namespace SeaHaven.Services.Implementation var now = _timeProvider.GetUtcNow().UtcDateTime; var previous = canonical; - if (canonical == UpliftStatus.Approved) + if (canonical == UpliftStatus.Approved || canonical == UpliftStatus.NoApprovalRequired) { var oldNte = dispatch.NTEAmount ?? 0m; dispatch.NTEAmount = req.CurrentNTE ?? oldNte; @@ -348,6 +359,26 @@ namespace SeaHaven.Services.Implementation return WorkOrderUpliftContractMapper.MapItem(created, requesterName, null); } + private async Task EnsureWorkOrderAllowsUpliftMutationAsync( + int workOrderId, + ClaimsPrincipal user, + CancellationToken cancellationToken) + { + var accountFilter = _accountResolver.ResolveAccountFilter(user); + var workOrder = await _detailData.GetWorkOrderForMediaAsync( + workOrderId, + cancellationToken, + accountFilter); + if (workOrder == null) + throw new KeyNotFoundException("Work order not found"); + + if (workOrder.LifecycleStatus is LifecycleStatus.Completed or LifecycleStatus.Canceled) + { + throw new InvalidOperationException( + $"Cannot change uplifts on a '{workOrder.LifecycleStatus}' work order"); + } + } + private async Task HasWorkOrderAccessAsync( int workOrderId, ClaimsPrincipal user, diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index 2aae357..42d1fa6 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -26,7 +26,12 @@ 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, new NoOpUpliftService()); + var cancel = new WorkOrderBoardCancelService( + mutationData, + boardService, + audit, + new NoOpUpliftService(), + new UpliftDataService(context)); var update = new WorkOrderBoardUpdateService(boardData, mutationData, audit); return (context, cancel, update); } @@ -205,7 +210,7 @@ public class WorkOrderBoardCancelServiceTests new UserDataService(context), TimeProvider.System, Options.Create(new ApprovalsOptions())); - var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, uplifts); + var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, uplifts, new UpliftDataService(context)); var result = await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1"); @@ -270,7 +275,8 @@ public class WorkOrderBoardCancelServiceTests new ThrowingSaveMutationData(mutationData), boardService, audit, - uplifts); + uplifts, + new UpliftDataService(context)); await Assert.ThrowsAsync(() => cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1")); @@ -281,6 +287,92 @@ public class WorkOrderBoardCancelServiceTests Assert.Empty(verify.WorkOrderAuditLogs); } + [Fact] + public async Task Cancel_SerializedWithCreate_DoesNotLeavePendingOnCanceledWorkOrder() + { + var databaseName = Guid.NewGuid().ToString(); + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName) + .Options; + + await using (var seed = new ApplicationDbContext(options)) + { + await WorkOrderAccountTestHelpers.EnsureAccountAsync(seed); + seed.Users.Add(new ApplicationUser + { + Id = "dispatcher-1", + UserName = "dispatcher-1", + FirstName = "Alex", + LastName = "Dispatcher", + }); + seed.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" }); + seed.Dispatches.Add(new Dispatch + { + Id = 10, + VendorId = 1, + WorkOrderId = 1, + NTEAmount = 1000m, + DispatchNumber = "DIS-10", + Status = "Scheduled", + }); + seed.workOrders.Add(new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + PrimaryDispatchId = 10, + AccountId = 1, + WorkOrderType = WorkOrderType.PM, + LifecycleStatus = LifecycleStatus.Incomplete, + Status = "Incomplete", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }, + }); + await seed.SaveChangesAsync(); + } + + await using var createContext = new ApplicationDbContext(options); + await using var cancelContext = new ApplicationDbContext(options); + var createService = new WorkOrderUpliftService( + new UpliftDataService(createContext), + new DispatchDataService(createContext), + new WorkOrderDetailDataService(createContext), + WorkOrderAccountTestHelpers.Resolver(createContext), + new UserDataService(createContext), + TimeProvider.System, + Options.Create(new ApprovalsOptions())); + var boardData = new WorkOrderBoardDataService(cancelContext); + var mutationData = new WorkOrderBoardMutationDataService(cancelContext); + var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(cancelContext)); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(cancelContext)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(cancelContext), fieldLocks); + var cancelUplifts = new WorkOrderUpliftService( + new UpliftDataService(cancelContext), + new DispatchDataService(cancelContext), + new WorkOrderDetailDataService(cancelContext), + WorkOrderAccountTestHelpers.Resolver(cancelContext), + new UserDataService(cancelContext), + TimeProvider.System, + Options.Create(new ApprovalsOptions())); + var cancel = new WorkOrderBoardCancelService( + mutationData, + boardService, + audit, + cancelUplifts, + new UpliftDataService(cancelContext)); + + await Task.WhenAll( + createService.CreateAsync( + 1, + new CreateWorkOrderUpliftRequestDto { Amount = 501m, Notes = "Pending" }, + WorkOrderAccountTestHelpers.OrgWideAdmin("dispatcher-1"), + CancellationToken.None), + cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1")); + + await using var verify = new ApplicationDbContext(options); + var workOrder = Assert.Single(verify.workOrders); + Assert.Equal(LifecycleStatus.Canceled, workOrder.LifecycleStatus); + Assert.DoesNotContain(verify.DispatchUpliftRequests, row => row.Status == "Pending"); + } + private sealed class ThrowingSaveMutationData : IWorkOrderBoardMutationDataService { private readonly IWorkOrderBoardMutationDataService _inner; diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs index 9ca211e..5b7d6f9 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs @@ -374,4 +374,89 @@ public sealed class WorkOrderUpliftServiceTests Assert.Single(pending); Assert.True(autoApproved.Sum(row => row.RequestedNTE) <= 500m); } + + [Fact] + public async Task RevokeAsync_OwnerRevokesAutoApproved_RestoresNteAndAllowance() + { + await using var context = CreateContext(); + var (workOrder, _) = await SeedWorkOrderAsync(context); + var service = NewService(context); + + var created = await service.CreateAsync( + workOrder.Id, + new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "Within limit" }, + Dispatcher(), + CancellationToken.None); + + Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount); + + var revoked = await service.RevokeAsync( + workOrder.Id, + created!.Id, + new RevokeWorkOrderUpliftRequestDto(), + Dispatcher(), + CancellationToken.None); + + Assert.Equal("revoked", revoked!.Status); + Assert.Equal(1000m, context.Dispatches.Single(d => d.Id == 10).NTEAmount); + Assert.Equal(0m, await new UpliftDataService(context) + .SumAutoApprovedAmountForWorkOrderAsync(workOrder.Id, CancellationToken.None)); + } + + [Theory] + [InlineData(LifecycleStatus.Completed)] + [InlineData(LifecycleStatus.Canceled)] + public async Task RevokeAsync_TerminalWorkOrder_Throws(LifecycleStatus status) + { + await using var context = CreateContext(); + var (workOrder, dispatch) = await SeedWorkOrderAsync(context); + workOrder.LifecycleStatus = status; + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + Id = 100, + DispatchId = dispatch.Id, + RequestedNTE = 400m, + CurrentNTE = 1000m, + Status = "NoApprovalRequired", + RequiredTier = 0, + NotificationStatus = "Sent", + createdby = "dispatcher-1", + }); + await context.SaveChangesAsync(); + + var service = NewService(context); + var ex = await Assert.ThrowsAsync(() => + service.RevokeAsync( + workOrder.Id, + 100, + new RevokeWorkOrderUpliftRequestDto(), + Dispatcher(), + CancellationToken.None)); + Assert.Contains("work order", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData(LifecycleStatus.Completed)] + [InlineData(LifecycleStatus.Canceled)] + public async Task CancelAsync_TerminalWorkOrder_Throws(LifecycleStatus status) + { + await using var context = CreateContext(); + var (workOrder, dispatch) = await SeedWorkOrderAsync(context); + workOrder.LifecycleStatus = status; + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + Id = 100, + DispatchId = dispatch.Id, + RequestedNTE = 600m, + Status = "Pending", + RequiredTier = 1, + NotificationStatus = "Pending", + }); + await context.SaveChangesAsync(); + + var service = NewService(context); + var ex = await Assert.ThrowsAsync(() => + service.CancelAsync(workOrder.Id, 100, Dispatcher(), CancellationToken.None)); + Assert.Contains("work order", ex.Message, StringComparison.OrdinalIgnoreCase); + } }