mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-03 17:53:22 +00:00
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:
parent
b81cfbb005
commit
4c15669aff
8 changed files with 408 additions and 54 deletions
|
|
@ -237,6 +237,29 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
&& (u.IsDeleted == null || u.IsDeleted == false), cancellationToken);
|
&& (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)
|
public async Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return await _context.DispatchUpliftRequests
|
return await _context.DispatchUpliftRequests
|
||||||
|
|
@ -295,6 +318,17 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
.ToListAsync(cancellationToken);
|
.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)
|
public async Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await _context.DispatchUpliftRequests.AddAsync(request, cancellationToken);
|
await _context.DispatchUpliftRequests.AddAsync(request, cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
// Returns null when the request, the linked evidence, or the dispatch linkage is absent.
|
// Returns null when the request, the linked evidence, or the dispatch linkage is absent.
|
||||||
Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken);
|
Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken);
|
||||||
Task<bool> HasPendingAsync(int dispatchId, 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).
|
// SH-101: active = Pending or ChangesRequested (the only states that block a new request).
|
||||||
Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken);
|
Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken);
|
||||||
Task<DispatchUpliftRequest?> GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken);
|
Task<DispatchUpliftRequest?> GetActiveRequestAsync(int dispatchId, CancellationToken cancellationToken);
|
||||||
|
|
|
||||||
19
SeaHaven.Services/Helpers/WorkOrderUpliftAllowance.cs
Normal file
19
SeaHaven.Services/Helpers/WorkOrderUpliftAllowance.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,15 +13,18 @@ namespace SeaHaven.Services.Implementation
|
||||||
private readonly IWorkOrderBoardMutationDataService _mutationData;
|
private readonly IWorkOrderBoardMutationDataService _mutationData;
|
||||||
private readonly IWorkOrderBoardService _boardService;
|
private readonly IWorkOrderBoardService _boardService;
|
||||||
private readonly IWorkOrderAuditService _auditService;
|
private readonly IWorkOrderAuditService _auditService;
|
||||||
|
private readonly IWorkOrderUpliftService _upliftService;
|
||||||
|
|
||||||
public WorkOrderBoardCancelService(
|
public WorkOrderBoardCancelService(
|
||||||
IWorkOrderBoardMutationDataService mutationData,
|
IWorkOrderBoardMutationDataService mutationData,
|
||||||
IWorkOrderBoardService boardService,
|
IWorkOrderBoardService boardService,
|
||||||
IWorkOrderAuditService auditService)
|
IWorkOrderAuditService auditService,
|
||||||
|
IWorkOrderUpliftService upliftService)
|
||||||
{
|
{
|
||||||
_mutationData = mutationData;
|
_mutationData = mutationData;
|
||||||
_boardService = boardService;
|
_boardService = boardService;
|
||||||
_auditService = auditService;
|
_auditService = auditService;
|
||||||
|
_upliftService = upliftService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<WorkOrderBoardRowDto> CancelAsync(
|
public async Task<WorkOrderBoardRowDto> CancelAsync(
|
||||||
|
|
@ -49,6 +52,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
if (workOrder.LegacyStatus == null && workOrder.Status != null)
|
if (workOrder.LegacyStatus == null && workOrder.Status != null)
|
||||||
workOrder.LegacyStatus = workOrder.Status;
|
workOrder.LegacyStatus = workOrder.Status;
|
||||||
|
|
||||||
|
await _upliftService.WithdrawPendingForWorkOrderAsync(workOrderId, actorId, CancellationToken.None);
|
||||||
await _auditService.StageStatusChangedAsync(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId);
|
await _auditService.StageStatusChangedAsync(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId);
|
||||||
await _mutationData.SaveAsync(CancellationToken.None);
|
await _mutationData.SaveAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,59 +77,53 @@ namespace SeaHaven.Services.Implementation
|
||||||
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))
|
||||||
|
throw new InvalidOperationException("An open uplift request already exists for this work order");
|
||||||
|
|
||||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
var requesterName = await ResolveUserDisplayNameAsync(userId, cancellationToken);
|
var requesterName = await ResolveUserDisplayNameAsync(userId, cancellationToken);
|
||||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||||
var current = dispatch.NTEAmount ?? 0m;
|
var current = dispatch.NTEAmount ?? 0m;
|
||||||
var notes = request.Notes?.Trim() ?? "";
|
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
|
return await PersistCreatedAsync(
|
||||||
{
|
dispatch,
|
||||||
DispatchId = dispatchId,
|
workOrderId,
|
||||||
CurrentNTE = current,
|
userId,
|
||||||
RequestedNTE = request.Amount,
|
requesterName,
|
||||||
VendorReason = notes,
|
current,
|
||||||
Status = UpliftStatus.NoApprovalRequired,
|
request.Amount,
|
||||||
RequiredTier = 0,
|
notes,
|
||||||
RequestedByVendorName = requesterName,
|
UpliftStatus.NoApprovalRequired,
|
||||||
CreatedDate = now,
|
requiredTier: 0,
|
||||||
createdby = userId,
|
expiresAt: null,
|
||||||
NotificationStatus = UpliftNotificationStatus.Sent,
|
UpliftNotificationStatus.Sent,
|
||||||
};
|
"uplift_auto_approved",
|
||||||
await _upliftData.StageAsync(autoApproved, cancellationToken);
|
now,
|
||||||
await StageAuditAsync(dispatch, workOrderId, userId, current, request.Amount, "uplift_auto_approved", now, cancellationToken);
|
cancellationToken);
|
||||||
await _upliftData.SaveChangesAsync(cancellationToken);
|
|
||||||
return WorkOrderUpliftContractMapper.MapItem(autoApproved, requesterName, null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await _upliftData.HasActiveAsync(dispatchId, cancellationToken))
|
return await PersistCreatedAsync(
|
||||||
throw new InvalidOperationException("An active uplift request already exists for this dispatch");
|
dispatch,
|
||||||
|
workOrderId,
|
||||||
var tier1Max = _approvalsOptions.UpliftTier1MaxUsd ?? 2500m;
|
userId,
|
||||||
var delta = request.Amount - current;
|
requesterName,
|
||||||
var requiredTier = delta > tier1Max ? 2 : 1;
|
current,
|
||||||
var expiresAt = now + _approvalsOptions.EffectiveExpiration;
|
request.Amount,
|
||||||
|
notes,
|
||||||
var pending = new DispatchUpliftRequest
|
UpliftStatus.Pending,
|
||||||
{
|
requiredTier: 1,
|
||||||
DispatchId = dispatchId,
|
now + _approvalsOptions.EffectiveExpiration,
|
||||||
CurrentNTE = current,
|
UpliftNotificationStatus.Pending,
|
||||||
RequestedNTE = request.Amount,
|
"uplift_requested",
|
||||||
VendorReason = notes,
|
now,
|
||||||
Status = UpliftStatus.Pending,
|
cancellationToken);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<WorkOrderUpliftDto?> CancelAsync(
|
public async Task<WorkOrderUpliftDto?> CancelAsync(
|
||||||
|
|
@ -251,6 +245,82 @@ namespace SeaHaven.Services.Implementation
|
||||||
return WorkOrderUpliftContractMapper.MapRevokedItem(req, requesterName, decidedByName);
|
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(
|
private async Task<bool> HasWorkOrderAccessAsync(
|
||||||
int workOrderId,
|
int workOrderId,
|
||||||
ClaimsPrincipal user,
|
ClaimsPrincipal user,
|
||||||
|
|
|
||||||
|
|
@ -28,5 +28,10 @@ namespace SeaHaven.Services.Interfaces
|
||||||
RevokeWorkOrderUpliftRequestDto request,
|
RevokeWorkOrderUpliftRequestDto request,
|
||||||
ClaimsPrincipal user,
|
ClaimsPrincipal user,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task WithdrawPendingForWorkOrderAsync(
|
||||||
|
int workOrderId,
|
||||||
|
string? actorId,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Data.SeaHavenIndustries.Enums;
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using SeaHaven.DataServices.Implementation;
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.Services.Configuration;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
using SeaHaven.Services.Exceptions;
|
using SeaHaven.Services.Exceptions;
|
||||||
using SeaHaven.Services.Helpers;
|
using SeaHaven.Services.Helpers;
|
||||||
using SeaHaven.Services.Implementation;
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
namespace SeaHavenIndustries.Tests;
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
|
@ -21,7 +25,7 @@ public class WorkOrderBoardCancelServiceTests
|
||||||
var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context));
|
var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context));
|
||||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
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);
|
var update = new WorkOrderBoardUpdateService(boardData, mutationData, audit);
|
||||||
return (context, cancel, update);
|
return (context, cancel, update);
|
||||||
}
|
}
|
||||||
|
|
@ -149,4 +153,100 @@ public class WorkOrderBoardCancelServiceTests
|
||||||
|
|
||||||
Assert.Equal("CanceledReadOnly", ex.Code);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using SeaHaven.DataServices.Implementation;
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
|
@ -43,7 +44,9 @@ public sealed class WorkOrderUpliftServiceTests
|
||||||
private static ClaimsPrincipal Dispatcher(string userId = "dispatcher-1")
|
private static ClaimsPrincipal Dispatcher(string userId = "dispatcher-1")
|
||||||
=> WorkOrderAccountTestHelpers.OrgWideAdmin(userId);
|
=> 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);
|
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
|
||||||
context.Users.Add(new ApplicationUser
|
context.Users.Add(new ApplicationUser
|
||||||
|
|
@ -69,10 +72,11 @@ public sealed class WorkOrderUpliftServiceTests
|
||||||
InternalWONumber = "10000000001",
|
InternalWONumber = "10000000001",
|
||||||
PrimaryDispatchId = 10,
|
PrimaryDispatchId = 10,
|
||||||
AccountId = 1,
|
AccountId = 1,
|
||||||
|
WorkOrderType = type,
|
||||||
};
|
};
|
||||||
context.workOrders.Add(workOrder);
|
context.workOrders.Add(workOrder);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
return (workOrder, context.Dispatches.Single());
|
return (workOrder, context.Dispatches.Single(d => d.Id == 10));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -102,7 +106,7 @@ public sealed class WorkOrderUpliftServiceTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreateAsync_AboveCurrentNte_CreatesPendingRequest()
|
public async Task CreateAsync_PmAmountAtCap_CreatesAutoApprovedRequest()
|
||||||
{
|
{
|
||||||
await using var context = CreateContext();
|
await using var context = CreateContext();
|
||||||
var (workOrder, _) = await SeedWorkOrderAsync(context);
|
var (workOrder, _) = await SeedWorkOrderAsync(context);
|
||||||
|
|
@ -110,7 +114,24 @@ public sealed class WorkOrderUpliftServiceTests
|
||||||
|
|
||||||
var created = await service.CreateAsync(
|
var created = await service.CreateAsync(
|
||||||
workOrder.Id,
|
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(),
|
Dispatcher(),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
|
|
@ -121,22 +142,120 @@ public sealed class WorkOrderUpliftServiceTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreateAsync_AtOrBelowCurrentNte_CreatesAutoApprovedRequest()
|
public async Task CreateAsync_PmSecondRequestExceedsRemainingAllowance_CreatesPending()
|
||||||
{
|
{
|
||||||
await using var context = CreateContext();
|
await using var context = CreateContext();
|
||||||
var (workOrder, _) = await SeedWorkOrderAsync(context);
|
var (workOrder, _) = await SeedWorkOrderAsync(context);
|
||||||
var service = NewService(context);
|
var service = NewService(context);
|
||||||
|
|
||||||
var created = await service.CreateAsync(
|
var first = await service.CreateAsync(
|
||||||
workOrder.Id,
|
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(),
|
Dispatcher(),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
Assert.NotNull(created);
|
|
||||||
Assert.Equal("auto_approved", created!.Status);
|
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]
|
[Fact]
|
||||||
public async Task CancelAsync_PendingRequest_Withdraws()
|
public async Task CancelAsync_PendingRequest_Withdraws()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue