mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +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);
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
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 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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -28,5 +28,10 @@ namespace SeaHaven.Services.Interfaces
|
|||
RevokeWorkOrderUpliftRequestDto request,
|
||||
ClaimsPrincipal user,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task WithdrawPendingForWorkOrderAsync(
|
||||
int workOrderId,
|
||||
string? actorId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue