mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-05 08:22:10 +00:00
Merge pull request #167 from Sea-Haven-Industries/fix/ab/sh-387-uplift-create
Fix SH-387 uplift creation
This commit is contained in:
commit
f7db6b9f84
6 changed files with 207 additions and 27 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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}",
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue