fix(work-orders): enforce SH-196 cumulative allowance and one pending per WO

Auto-approval now uses the WO-scoped $500/$5,000 Emergency cap instead of dispatch NTE, rejects a second open request across dispatches, and cancelling a WO withdraws pending uplifts with audit.
This commit is contained in:
arthur.bassi 2026-08-14 10:36:35 -03:00
parent b81cfbb005
commit 4c15669aff
8 changed files with 408 additions and 54 deletions

View file

@ -237,6 +237,29 @@ namespace SeaHaven.DataServices.Implementation
&& (u.IsDeleted == null || u.IsDeleted == false), cancellationToken);
}
public Task<bool> HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
{
return ForWorkOrder(workOrderId)
.AnyAsync(u => u.Status == "Pending" || u.Status == "ChangesRequested", cancellationToken);
}
public Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
{
return ForWorkOrder(workOrderId)
.Where(u => u.Status == "NoApprovalRequired")
.SumAsync(u => u.RequestedNTE, cancellationToken);
}
public Task<List<DispatchUpliftRequest>> 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<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken)
{
return await _context.DispatchUpliftRequests
@ -295,6 +318,17 @@ namespace SeaHaven.DataServices.Implementation
.ToListAsync(cancellationToken);
}
private IQueryable<DispatchUpliftRequest> 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);

View file

@ -16,6 +16,9 @@ namespace SeaHaven.DataServices.Interfaces
// Returns null when the request, the linked evidence, or the dispatch linkage is absent.
Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken);
Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken);
Task<bool> HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
// SH-101: active = Pending or ChangesRequested (the only states that block a new request).
Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken);
Task<DispatchUpliftRequest?> GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken);

View file

@ -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;
}
}

View file

@ -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<WorkOrderBoardRowDto> 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);

View file

@ -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<WorkOrderUpliftDto?> 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<WorkOrderUpliftDto> 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<bool> HasWorkOrderAccessAsync(
int workOrderId,
ClaimsPrincipal user,

View file

@ -28,5 +28,10 @@ namespace SeaHaven.Services.Interfaces
RevokeWorkOrderUpliftRequestDto request,
ClaimsPrincipal user,
CancellationToken cancellationToken);
Task WithdrawPendingForWorkOrderAsync(
int workOrderId,
string? actorId,
CancellationToken cancellationToken);
}
}

View file

@ -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<ApplicationDbContext>()
.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<IReadOnlyList<WorkOrderUpliftDto>?> ListAsync(
int workOrderId,
System.Security.Claims.ClaimsPrincipal user,
CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<WorkOrderUpliftDto>?>(Array.Empty<WorkOrderUpliftDto>());
public Task<WorkOrderUpliftDto?> CreateAsync(
int workOrderId,
CreateWorkOrderUpliftRequestDto request,
System.Security.Claims.ClaimsPrincipal user,
CancellationToken cancellationToken) =>
Task.FromResult<WorkOrderUpliftDto?>(null);
public Task<WorkOrderUpliftDto?> CancelAsync(
int workOrderId,
int upliftId,
System.Security.Claims.ClaimsPrincipal user,
CancellationToken cancellationToken) =>
Task.FromResult<WorkOrderUpliftDto?>(null);
public Task<WorkOrderUpliftDto?> RevokeAsync(
int workOrderId,
int upliftId,
RevokeWorkOrderUpliftRequestDto request,
System.Security.Claims.ClaimsPrincipal user,
CancellationToken cancellationToken) =>
Task.FromResult<WorkOrderUpliftDto?>(null);
public Task WithdrawPendingForWorkOrderAsync(
int workOrderId,
string? actorId,
CancellationToken cancellationToken) => Task.CompletedTask;
}
}

View file

@ -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<InvalidOperationException>(() =>
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()
{