From d2827991279762aef3a72736aaeb69a5ad3ebd27 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 22 Sep 2026 16:43:39 -0300 Subject: [PATCH] Fix SH-387 uplift creation without primary dispatch --- .../Implementation/DispatchDataService.cs | 10 ++-- .../Interfaces/IDispatchDataService.cs | 4 +- .../Implementation/WorkOrderUpliftService.cs | 26 +++++++-- .../WorkOrderUpliftServiceTests.cs | 53 ++++++++++++++++++- 4 files changed, 81 insertions(+), 12 deletions(-) diff --git a/SeaHaven.DataServices/Implementation/DispatchDataService.cs b/SeaHaven.DataServices/Implementation/DispatchDataService.cs index 16d5747..6511ad6 100644 --- a/SeaHaven.DataServices/Implementation/DispatchDataService.cs +++ b/SeaHaven.DataServices/Implementation/DispatchDataService.cs @@ -122,25 +122,27 @@ namespace SeaHaven.DataServices.Implementation return await _context.Dispatches.CountAsync(); } - public async Task> GetByWorkOrderIdAsync(int workOrderId) + public async Task> GetByWorkOrderIdAsync( + int workOrderId, + CancellationToken cancellationToken = default) { var dispatchIds = await _context.DispatchWorkOrders .Where(dwo => dwo.WorkOrderId == workOrderId) .Select(dwo => dwo.DispatchId) .Distinct() - .ToListAsync(); + .ToListAsync(cancellationToken); var directIds = await _context.Dispatches .Where(d => d.WorkOrderId == workOrderId) .Select(d => d.Id) - .ToListAsync(); + .ToListAsync(cancellationToken); var allIds = dispatchIds.Union(directIds).Distinct().ToList(); return await _context.Dispatches .Where(d => allIds.Contains(d.Id)) .Include(d => d.Vendor) - .ToListAsync(); + .ToListAsync(cancellationToken); } public async Task> GetByWorkOrderIdsAsync(IEnumerable workOrderIds) diff --git a/SeaHaven.DataServices/Interfaces/IDispatchDataService.cs b/SeaHaven.DataServices/Interfaces/IDispatchDataService.cs index efe7dcc..56f3581 100644 --- a/SeaHaven.DataServices/Interfaces/IDispatchDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IDispatchDataService.cs @@ -11,7 +11,9 @@ namespace SeaHaven.DataServices.Interfaces Task GetByIdWithVendorAndWorkOrderAsync(int id); Task> GetAllAsync(); Task> GetByVendorIdAsync(int vendorId); - Task> GetByWorkOrderIdAsync(int workOrderId); + Task> GetByWorkOrderIdAsync( + int workOrderId, + CancellationToken cancellationToken = default); Task> GetByWorkOrderIdsAsync(IEnumerable workOrderIds); Task> GetByStatusAsync(string status); Task<(IEnumerable Items, int TotalCount)> GetPagedAsync(int page, int pageSize, string? search = null, string? status = null); diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs index 6a2c388..9d4f08b 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs @@ -95,7 +95,7 @@ namespace SeaHaven.Services.Implementation workOrderId, cancellationToken, accountFilter); - if (workOrder?.PrimaryDispatchId is not int dispatchId) + if (workOrder == null) throw new InvalidOperationException("Work order has no primary dispatch for uplift requests"); if (workOrder.LifecycleStatus is LifecycleStatus.Completed or LifecycleStatus.Canceled) @@ -104,14 +104,30 @@ namespace SeaHaven.Services.Implementation $"Cannot create an uplift on a '{workOrder.LifecycleStatus}' work order"); } - var dispatch = await _dispatchData.GetByIdAsync(dispatchId); - if (dispatch == null) - throw new KeyNotFoundException("Dispatch not found"); + Dispatch? dispatch; + if (workOrder.PrimaryDispatchId is int primaryDispatchId) + { + dispatch = await _dispatchData.GetByIdAsync(primaryDispatchId); + if (dispatch == null) + throw new KeyNotFoundException("Dispatch not found"); + } + else + { + dispatch = (await _dispatchData.GetByWorkOrderIdAsync(workOrderId, cancellationToken)) + .Where(candidate => candidate.IsDeleted != true) + .OrderByDescending(candidate => candidate.DispatchedAt ?? candidate.CreatedDate) + .ThenByDescending(candidate => candidate.Id) + .FirstOrDefault(); + + if (dispatch == null) + throw new InvalidOperationException("Work order has no primary dispatch for uplift requests"); + } if (IsTerminalForUplift(dispatch.Status)) throw new InvalidOperationException($"Cannot request uplift on a '{dispatch.Status}' dispatch"); - if (await _upliftData.HasPendingForWorkOrderAsync(workOrderId, cancellationToken)) + if (await _upliftData.HasPendingForWorkOrderAsync(workOrderId, cancellationToken) + || await _upliftData.HasActiveAsync(dispatch.Id, cancellationToken)) throw new InvalidOperationException("An open uplift request already exists for this work order"); var now = _timeProvider.GetUtcNow().UtcDateTime; diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs index 7ec8856..9546fbf 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs @@ -46,7 +46,8 @@ public sealed class WorkOrderUpliftServiceTests private static async Task<(WorkOrder WorkOrder, Dispatch Dispatch)> SeedWorkOrderAsync( ApplicationDbContext context, - WorkOrderType type = WorkOrderType.PM) + WorkOrderType type = WorkOrderType.PM, + int? primaryDispatchId = 10) { await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); context.Users.Add(new ApplicationUser @@ -70,7 +71,7 @@ public sealed class WorkOrderUpliftServiceTests { Id = 1, InternalWONumber = "10000000001", - PrimaryDispatchId = 10, + PrimaryDispatchId = primaryDispatchId, AccountId = 1, WorkOrderType = type, }; @@ -123,6 +124,25 @@ public sealed class WorkOrderUpliftServiceTests Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount); } + [Fact] + public async Task CreateAsync_WithoutPrimaryDispatch_UsesWorkOrderDispatch() + { + await using var context = CreateContext(); + var (workOrder, dispatch) = await SeedWorkOrderAsync(context, primaryDispatchId: null); + var service = NewService(context); + + var created = await service.CreateAsync( + workOrder.Id, + new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "Missing primary" }, + Dispatcher(), + CancellationToken.None); + + Assert.NotNull(created); + Assert.Equal("auto_approved", created!.Status); + Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == dispatch.Id).NTEAmount); + Assert.Equal(1, await context.DispatchUpliftRequests.CountAsync()); + } + [Fact] public async Task CreateAsync_PmAmountAboveCap_CreatesPendingRequest() { @@ -233,6 +253,35 @@ public sealed class WorkOrderUpliftServiceTests Assert.Contains("open uplift request", ex.Message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task CreateAsync_ActiveRequestOnPrimaryDispatch_BlocksDuplicate() + { + await using var context = CreateContext(); + var (workOrder, dispatch) = await SeedWorkOrderAsync(context); + dispatch.WorkOrderId = null; + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + Id = 100, + DispatchId = dispatch.Id, + 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 = "Duplicate open" }, + Dispatcher(), + CancellationToken.None)); + + Assert.Contains("open uplift request", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Single(context.DispatchUpliftRequests); + } + [Fact] public async Task WithdrawPendingForWorkOrderAsync_CancelsOpenRequestsAndAudits() {