Merge pull request #167 from Sea-Haven-Industries/fix/ab/sh-387-uplift-create
Some checks failed
Backend CI / Build and test (push) Has been cancelled
Backend CI / architecture (push) Has been cancelled
Backend CI / review (push) Has been cancelled
Backend CI / ci-complete (push) Has been cancelled

Fix SH-387 uplift creation
This commit is contained in:
Alexandre Brandizzi 2026-09-23 03:45:17 +00:00 • committed by GitHub
commit f7db6b9f84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 207 additions and 27 deletions

View file

@ -122,25 +122,27 @@ namespace SeaHaven.DataServices.Implementation
return await _context.Dispatches.CountAsync(); return await _context.Dispatches.CountAsync();
} }
public async Task<IEnumerable<Dispatch>> GetByWorkOrderIdAsync(int workOrderId) public async Task<IEnumerable<Dispatch>> GetByWorkOrderIdAsync(
int workOrderId,
CancellationToken cancellationToken = default)
{ {
var dispatchIds = await _context.DispatchWorkOrders var dispatchIds = await _context.DispatchWorkOrders
.Where(dwo => dwo.WorkOrderId == workOrderId) .Where(dwo => dwo.WorkOrderId == workOrderId)
.Select(dwo => dwo.DispatchId) .Select(dwo => dwo.DispatchId)
.Distinct() .Distinct()
.ToListAsync(); .ToListAsync(cancellationToken);
var directIds = await _context.Dispatches var directIds = await _context.Dispatches
.Where(d => d.WorkOrderId == workOrderId) .Where(d => d.WorkOrderId == workOrderId)
.Select(d => d.Id) .Select(d => d.Id)
.ToListAsync(); .ToListAsync(cancellationToken);
var allIds = dispatchIds.Union(directIds).Distinct().ToList(); var allIds = dispatchIds.Union(directIds).Distinct().ToList();
return await _context.Dispatches return await _context.Dispatches
.Where(d => allIds.Contains(d.Id)) .Where(d => allIds.Contains(d.Id))
.Include(d => d.Vendor) .Include(d => d.Vendor)
.ToListAsync(); .ToListAsync(cancellationToken);
} }
public async Task<IEnumerable<Dispatch>> GetByWorkOrderIdsAsync(IEnumerable<int> workOrderIds) public async Task<IEnumerable<Dispatch>> GetByWorkOrderIdsAsync(IEnumerable<int> workOrderIds)

View file

@ -535,22 +535,25 @@ namespace SeaHaven.DataServices.Implementation
{ {
var gate = WorkOrderGates.GetOrAdd(workOrderId, _ => new SemaphoreSlim(1, 1)); var gate = WorkOrderGates.GetOrAdd(workOrderId, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken); await gate.WaitAsync(cancellationToken);
await using var transaction = _context.Database.IsRelational()
? await _context.Database.BeginTransactionAsync(cancellationToken)
: null;
try try
{ {
await LockWorkOrderRowAsync(workOrderId, cancellationToken); await using var transaction = _context.Database.IsRelational()
var result = await work(cancellationToken); ? await _context.Database.BeginTransactionAsync(cancellationToken)
if (transaction is not null) : null;
await transaction.CommitAsync(cancellationToken); try
return result; {
} await LockWorkOrderRowAsync(workOrderId, cancellationToken);
catch var result = await work(cancellationToken);
{ if (transaction is not null)
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
await transaction.RollbackAsync(cancellationToken); return result;
throw; }
catch
{
if (transaction is not null)
await transaction.RollbackAsync(cancellationToken);
throw;
}
} }
finally finally
{ {

View file

@ -11,7 +11,9 @@ namespace SeaHaven.DataServices.Interfaces
Task<Dispatch?> GetByIdWithVendorAndWorkOrderAsync(int id); Task<Dispatch?> GetByIdWithVendorAndWorkOrderAsync(int id);
Task<IEnumerable<Dispatch>> GetAllAsync(); Task<IEnumerable<Dispatch>> GetAllAsync();
Task<IEnumerable<Dispatch>> GetByVendorIdAsync(int vendorId); Task<IEnumerable<Dispatch>> GetByVendorIdAsync(int vendorId);
Task<IEnumerable<Dispatch>> GetByWorkOrderIdAsync(int workOrderId); Task<IEnumerable<Dispatch>> GetByWorkOrderIdAsync(
int workOrderId,
CancellationToken cancellationToken = default);
Task<IEnumerable<Dispatch>> GetByWorkOrderIdsAsync(IEnumerable<int> workOrderIds); Task<IEnumerable<Dispatch>> GetByWorkOrderIdsAsync(IEnumerable<int> workOrderIds);
Task<IEnumerable<Dispatch>> GetByStatusAsync(string status); Task<IEnumerable<Dispatch>> GetByStatusAsync(string status);
Task<(IEnumerable<Dispatch> Items, int TotalCount)> GetPagedAsync(int page, int pageSize, string? search = null, string? status = null); Task<(IEnumerable<Dispatch> Items, int TotalCount)> GetPagedAsync(int page, int pageSize, string? search = null, string? status = null);

View file

@ -95,7 +95,7 @@ namespace SeaHaven.Services.Implementation
workOrderId, workOrderId,
cancellationToken, cancellationToken,
accountFilter); accountFilter);
if (workOrder?.PrimaryDispatchId is not int dispatchId) if (workOrder == null)
throw new InvalidOperationException("Work order has no primary dispatch for uplift requests"); throw new InvalidOperationException("Work order has no primary dispatch for uplift requests");
if (workOrder.LifecycleStatus is LifecycleStatus.Completed or LifecycleStatus.Canceled) 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"); $"Cannot create an uplift on a '{workOrder.LifecycleStatus}' work order");
} }
var dispatch = await _dispatchData.GetByIdAsync(dispatchId); Dispatch? dispatch;
if (dispatch == null) if (workOrder.PrimaryDispatchId is int primaryDispatchId)
throw new KeyNotFoundException("Dispatch not found"); {
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)) if (IsTerminalForUplift(dispatch.Status))
throw new InvalidOperationException($"Cannot request uplift on a '{dispatch.Status}' dispatch"); 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"); throw new InvalidOperationException("An open uplift request already exists for this work order");
var now = _timeProvider.GetUtcNow().UtcDateTime; var now = _timeProvider.GetUtcNow().UtcDateTime;
@ -421,7 +437,7 @@ namespace SeaHaven.Services.Implementation
{ {
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
{ {
WorkOrderId = dispatch.WorkOrderId ?? workOrderId, WorkOrderId = workOrderId,
UserId = userId, UserId = userId,
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift", FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
OldValue = isStatusTransition ? oldValue.ToString() : $"${Convert.ToDecimal(oldValue):F2}", OldValue = isStatusTransition ? oldValue.ToString() : $"${Convert.ToDecimal(oldValue):F2}",

View file

@ -0,0 +1,77 @@
using System.Data.Common;
using Data.SeaHavenIndustries;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using SeaHaven.DataServices.Implementation;
namespace SeaHavenIndustries.Tests;
public sealed class UpliftDataServiceTransactionTests
{
[Fact]
public async Task ExecuteWorkOrderMutationAsync_ReleasesGate_WhenTransactionInitializationFails()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
var failingOptions = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.AddInterceptors(new ThrowOnceTransactionInterceptor())
.Options;
await using (var failedContext = new ApplicationDbContext(failingOptions))
{
var failedService = new UpliftDataService(failedContext);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
failedService.ExecuteWorkOrderMutationAsync(
387,
_ => Task.FromResult(true),
CancellationToken.None));
}
var retryOptions = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
await using var retryContext = new ApplicationDbContext(retryOptions);
var retryService = new UpliftDataService(retryContext);
var retry = retryService.ExecuteWorkOrderMutationAsync(
387,
_ => Task.FromResult("entered"),
CancellationToken.None);
Assert.Equal("entered", await retry.WaitAsync(TimeSpan.FromSeconds(1)));
}
private sealed class ThrowOnceTransactionInterceptor : DbTransactionInterceptor
{
private int _remaining = 1;
public override InterceptionResult<DbTransaction> TransactionStarting(
DbConnection connection,
TransactionStartingEventData eventData,
InterceptionResult<DbTransaction> result)
{
ThrowOnce();
return base.TransactionStarting(connection, eventData, result);
}
public override ValueTask<InterceptionResult<DbTransaction>> TransactionStartingAsync(
DbConnection connection,
TransactionStartingEventData eventData,
InterceptionResult<DbTransaction> result,
CancellationToken cancellationToken = default)
{
ThrowOnce();
return base.TransactionStartingAsync(connection, eventData, result, cancellationToken);
}
private void ThrowOnce()
{
if (Interlocked.Exchange(ref _remaining, 0) == 1)
throw new InvalidOperationException("Forced transaction initialization failure");
}
}
}

View file

@ -46,7 +46,8 @@ public sealed class WorkOrderUpliftServiceTests
private static async Task<(WorkOrder WorkOrder, Dispatch Dispatch)> SeedWorkOrderAsync( private static async Task<(WorkOrder WorkOrder, Dispatch Dispatch)> SeedWorkOrderAsync(
ApplicationDbContext context, ApplicationDbContext context,
WorkOrderType type = WorkOrderType.PM) WorkOrderType type = WorkOrderType.PM,
int? primaryDispatchId = 10)
{ {
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
context.Users.Add(new ApplicationUser context.Users.Add(new ApplicationUser
@ -70,7 +71,7 @@ public sealed class WorkOrderUpliftServiceTests
{ {
Id = 1, Id = 1,
InternalWONumber = "10000000001", InternalWONumber = "10000000001",
PrimaryDispatchId = 10, PrimaryDispatchId = primaryDispatchId,
AccountId = 1, AccountId = 1,
WorkOrderType = type, WorkOrderType = type,
}; };
@ -123,6 +124,56 @@ public sealed class WorkOrderUpliftServiceTests
Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount); 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_WithoutPrimaryDispatch_AuditsRequestedWorkOrderWhenDispatchBelongsToAnother()
{
await using var context = CreateContext();
var (workOrder, dispatch) = await SeedWorkOrderAsync(context, primaryDispatchId: null);
context.workOrders.Add(new WorkOrder
{
Id = 2,
InternalWONumber = "10000000002",
AccountId = 1,
WorkOrderType = WorkOrderType.PM,
});
dispatch.WorkOrderId = 2;
context.DispatchWorkOrders.Add(new DispatchWorkOrder
{
DispatchId = dispatch.Id,
WorkOrderId = workOrder.Id,
});
await context.SaveChangesAsync();
var service = NewService(context);
await service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 400m, Notes = "Associated dispatch" },
Dispatcher(),
CancellationToken.None);
var audit = Assert.Single(context.WorkOrderAuditLogs);
Assert.Equal(workOrder.Id, audit.WorkOrderId);
}
[Fact] [Fact]
public async Task CreateAsync_PmAmountAboveCap_CreatesPendingRequest() public async Task CreateAsync_PmAmountAboveCap_CreatesPendingRequest()
{ {
@ -233,6 +284,35 @@ public sealed class WorkOrderUpliftServiceTests
Assert.Contains("open uplift request", ex.Message, StringComparison.OrdinalIgnoreCase); 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<InvalidOperationException>(() =>
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] [Fact]
public async Task WithdrawPendingForWorkOrderAsync_CancelsOpenRequestsAndAudits() public async Task WithdrawPendingForWorkOrderAsync_CancelsOpenRequestsAndAudits()
{ {