shoc-backend/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs
Alexandre Brandizzi a32471d4c0 Serialize sync cancels and vendor uplift requests on the work order lock
The legacy ingest batch holds the per-work-order lock, taken in id order,
for every work order it may cancel until the batch commits. A vendor
uplift request now runs under the same lock. Uplift creation on both
routes refuses a work order cancelled by either lifecycle or status text,
so a request can neither slip past a cancel nor land after one.
2026-09-25 19:37:08 -03:00

466 lines
20 KiB
C#

using System.Security.Claims;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class WorkOrderUpliftService : IWorkOrderUpliftService
{
private readonly IUpliftDataService _upliftData;
private readonly IDispatchDataService _dispatchData;
private readonly IWorkOrderDetailDataService _detailData;
private readonly IWorkOrderAccountResolver _accountResolver;
private readonly IUserDataService _userData;
private readonly ITeamPermissionOverrideDataService _permissionOverrideData;
private readonly ITeamPermissionPolicy _permissionPolicy;
private readonly TimeProvider _timeProvider;
private readonly ApprovalsOptions _approvalsOptions;
public WorkOrderUpliftService(
IUpliftDataService upliftData,
IDispatchDataService dispatchData,
IWorkOrderDetailDataService detailData,
IWorkOrderAccountResolver accountResolver,
IUserDataService userData,
ITeamPermissionOverrideDataService permissionOverrideData,
ITeamPermissionPolicy permissionPolicy,
TimeProvider timeProvider,
IOptions<ApprovalsOptions> approvalsOptions)
{
_upliftData = upliftData;
_dispatchData = dispatchData;
_detailData = detailData;
_accountResolver = accountResolver;
_userData = userData;
_permissionOverrideData = permissionOverrideData;
_permissionPolicy = permissionPolicy;
_timeProvider = timeProvider;
_approvalsOptions = approvalsOptions.Value;
}
public async Task<IReadOnlyList<WorkOrderUpliftDto>?> ListAsync(
int workOrderId,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken))
return null;
var rows = await _upliftData.GetForWorkOrderAsync(workOrderId, cancellationToken);
return rows.Select(WorkOrderUpliftContractMapper.MapItem).ToList();
}
public async Task<WorkOrderUpliftDto?> CreateAsync(
int workOrderId,
CreateWorkOrderUpliftRequestDto request,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken))
return null;
await EnsureCanRequestUpliftAsync(user, cancellationToken);
if (request.Amount <= 0)
throw new InvalidOperationException("Uplift amount must be greater than zero");
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
var requesterName = await ResolveUserDisplayNameAsync(userId, cancellationToken);
var notes = request.Notes?.Trim() ?? "";
var accountFilter = _accountResolver.ResolveAccountFilter(user);
try
{
return await _upliftData.ExecuteWorkOrderMutationAsync(
workOrderId,
ct => CreateLockedAsync(
workOrderId,
request.Amount,
notes,
userId,
requesterName,
accountFilter,
ct),
cancellationToken);
}
catch (SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException)
{
throw new UpliftConflictException();
}
}
private async Task EnsureCanRequestUpliftAsync(
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
var permissionUser = string.IsNullOrWhiteSpace(userId)
? null
: await _permissionOverrideData.GetUserAsync(userId, cancellationToken);
if (permissionUser is null)
{
throw new UpliftForbiddenException(
UpliftForbiddenException.RequestUpliftsDeniedMessage);
}
if (!_permissionPolicy.IsAllowed(
permissionUser.RoleName,
TeamPermissionKeys.RequestUplifts,
permissionUser.Overrides))
{
throw new UpliftForbiddenException(
UpliftForbiddenException.RequestUpliftsDeniedMessage);
}
}
private async Task<WorkOrderUpliftDto?> CreateLockedAsync(
int workOrderId,
decimal amount,
string notes,
string? userId,
string requesterName,
int? accountFilter,
CancellationToken cancellationToken)
{
var workOrder = await _detailData.GetWorkOrderForMediaAsync(
workOrderId,
cancellationToken,
accountFilter);
if (workOrder == null)
throw new InvalidOperationException("Work order has no primary dispatch for uplift requests");
if (workOrder.LifecycleStatus == LifecycleStatus.Completed
|| PendingUpliftCancellation.IsCancelled(workOrder.LifecycleStatus, workOrder.Status))
{
var closedAs = workOrder.LifecycleStatus == LifecycleStatus.Completed
? LifecycleStatus.Completed
: LifecycleStatus.Canceled;
throw new InvalidOperationException(
$"Cannot create an uplift on a '{closedAs}' work order");
}
// SH-393: write to a dispatch the work order's uplift reads resolve back to it.
// Loading the primary by id alone accepted soft-deleted, unlinked, or other work
// orders' dispatches, so the request never showed on this work order and never
// consumed its allowance.
var dispatch = await _upliftData.GetUpliftDispatchForWorkOrderAsync(
workOrderId,
workOrder.PrimaryDispatchId,
cancellationToken);
if (dispatch == null)
throw new InvalidOperationException("Work order has no primary dispatch for uplift requests");
if (IsTerminalForUplift(dispatch.Status))
throw new InvalidOperationException($"Cannot request uplift on a '{dispatch.Status}' dispatch");
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");
var now = _timeProvider.GetUtcNow().UtcDateTime;
var current = dispatch.NTEAmount ?? 0m;
var consumed = await _upliftData.SumAutoApprovedAmountForWorkOrderAsync(workOrderId, cancellationToken);
var remaining = WorkOrderUpliftAllowance.Remaining(
WorkOrderUpliftAllowance.CapFor(workOrder.WorkOrderType),
consumed);
if (WorkOrderUpliftAllowance.AutoApproves(amount, remaining))
{
return await PersistCreatedAsync(
dispatch,
workOrderId,
userId,
requesterName,
current,
amount,
notes,
UpliftStatus.NoApprovalRequired,
requiredTier: 0,
expiresAt: null,
UpliftNotificationStatus.Sent,
"uplift_auto_approved",
now,
cancellationToken);
}
return await PersistCreatedAsync(
dispatch,
workOrderId,
userId,
requesterName,
current,
amount,
notes,
UpliftStatus.Pending,
requiredTier: 1,
now + _approvalsOptions.EffectiveExpiration,
UpliftNotificationStatus.Pending,
"uplift_requested",
now,
cancellationToken);
}
public async Task<WorkOrderUpliftDto?> CancelAsync(
int workOrderId,
int upliftId,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken))
return null;
var req = await _upliftData.GetByIdAndWorkOrderAsync(upliftId, workOrderId, cancellationToken);
if (req == null)
throw new KeyNotFoundException("Uplift request not found");
if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.Withdrawn))
throw new InvalidOperationException($"Cannot cancel a '{UpliftStatus.ToCanonical(req.Status)}' uplift request");
var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId);
if (dispatch == null)
throw new KeyNotFoundException("Dispatch not found");
// SH-196: same terminal-lifecycle guard as revoke — the read-only dialog state
// is UX only and does not stop a direct API call.
await EnsureWorkOrderAcceptsUpliftMutationAsync(workOrderId, user, "cancel", cancellationToken);
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
var now = _timeProvider.GetUtcNow().UtcDateTime;
var previous = UpliftStatus.ToCanonical(req.Status);
req.Status = UpliftStatus.Withdrawn;
req.DecidedAt = now;
req.DecidedByUserId = userId;
req.LastModificationTime = now;
await StageAuditAsync(
dispatch,
workOrderId,
userId,
previous,
UpliftStatus.Withdrawn,
"uplift_cancel",
now,
cancellationToken,
isStatusTransition: true);
await _upliftData.SaveChangesAsync(cancellationToken);
var requesterName = req.RequestedByVendorName ?? await ResolveUserDisplayNameAsync(req.createdby, cancellationToken);
return WorkOrderUpliftContractMapper.MapItem(req, requesterName, await ResolveUserDisplayNameAsync(userId, cancellationToken));
}
public async Task<WorkOrderUpliftDto?> RevokeAsync(
int workOrderId,
int upliftId,
RevokeWorkOrderUpliftRequestDto request,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken))
return null;
var req = await _upliftData.GetByIdAndWorkOrderAsync(upliftId, workOrderId, cancellationToken);
if (req == null)
throw new KeyNotFoundException("Uplift request not found");
var canonical = UpliftStatus.ToCanonical(req.Status);
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (canonical == UpliftStatus.NoApprovalRequired)
{
if (string.IsNullOrWhiteSpace(userId)
|| !string.Equals(req.createdby, userId, StringComparison.Ordinal))
{
throw new UpliftForbiddenException("Only the request owner can revoke an auto-approved uplift");
}
}
else if (canonical == UpliftStatus.Approved)
{
if (!user.IsInRole("Admin"))
throw new UpliftForbiddenException("Admin role is required to revoke an approved uplift");
if (string.IsNullOrWhiteSpace(request.Reason))
throw new InvalidOperationException("A reason is required when revoking an approved uplift");
}
else
{
throw new InvalidOperationException($"Cannot revoke a '{canonical}' uplift request");
}
var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId);
if (dispatch == null)
throw new KeyNotFoundException("Dispatch not found");
// SH-196: revoking is blocked once the work order is Completed or Canceled.
// The board dialog enforces this in the UI only, so without a service-side
// guard a direct API call could still mutate uplifts on a terminal WO.
await EnsureWorkOrderAcceptsUpliftMutationAsync(workOrderId, user, "revoke", cancellationToken);
var now = _timeProvider.GetUtcNow().UtcDateTime;
var previous = canonical;
// SH-196: revoking must free the work order's uplift capacity again. Create
// raises the dispatch NTE for BOTH auto-approved and approved uplifts, so
// revoke has to compensate symmetrically. Restoring only on Approved left the
// NTE raised while SumAutoApprovedAmountForWorkOrderAsync stopped counting the
// revoked request, so every create -> auto-approve -> revoke cycle compounded
// the inflation and handed back allowance that was never actually released.
if (canonical == UpliftStatus.Approved || canonical == UpliftStatus.NoApprovalRequired)
{
var oldNte = dispatch.NTEAmount ?? 0m;
dispatch.NTEAmount = req.CurrentNTE ?? oldNte;
dispatch.LastModificationTime = now;
}
req.Status = UpliftStatus.Revoked;
req.DecidedAt = now;
req.DecidedByUserId = userId;
req.DecisionNote = string.IsNullOrWhiteSpace(request.Reason) ? null : request.Reason.Trim();
req.LastModificationTime = now;
await StageAuditAsync(
dispatch,
workOrderId,
userId,
previous,
UpliftStatus.Revoked,
"uplift_revoke",
now,
cancellationToken,
isStatusTransition: true);
await _upliftData.SaveChangesAsync(cancellationToken);
var decidedByName = await ResolveUserDisplayNameAsync(userId, cancellationToken);
var requesterName = req.RequestedByVendorName ?? await ResolveUserDisplayNameAsync(req.createdby, cancellationToken);
return WorkOrderUpliftContractMapper.MapRevokedItem(req, requesterName, decidedByName);
}
public async Task WithdrawPendingForWorkOrderAsync(
int workOrderId,
string? actorId,
CancellationToken cancellationToken)
{
await _upliftData.StageCancelPendingForWorkOrderAsync(
workOrderId,
actorId,
_timeProvider.GetUtcNow().UtcDateTime,
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,
};
if (status == UpliftStatus.NoApprovalRequired)
{
dispatch.NTEAmount = currentNte + amount;
dispatch.LastModificationTime = now;
}
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);
}
/// <summary>
/// SH-196: uplifts may not be mutated once the work order reaches a terminal
/// lifecycle state. Enforced in the service so a direct API call is rejected too,
/// not only the board dialog's read-only state.
/// </summary>
private async Task EnsureWorkOrderAcceptsUpliftMutationAsync(
int workOrderId,
ClaimsPrincipal user,
string action,
CancellationToken cancellationToken)
{
var accountFilter = _accountResolver.ResolveAccountFilter(user);
var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId, cancellationToken, accountFilter);
var lifecycle = workOrder?.LifecycleStatus;
if (lifecycle == LifecycleStatus.Completed || lifecycle == LifecycleStatus.Canceled)
throw new InvalidOperationException(
$"Cannot {action} an uplift on a '{lifecycle}' work order");
}
private async Task<bool> HasWorkOrderAccessAsync(
int workOrderId,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
var accountId = _accountResolver.ResolveAccountFilter(user);
return await _detailData.ExistsAsync(workOrderId, cancellationToken, accountId);
}
private async Task<string> ResolveUserDisplayNameAsync(string? userId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(userId))
return "";
var names = await _userData.GetDisplayNamesByIdsAsync(new[] { userId });
return names.TryGetValue(userId, out var name) ? name : "";
}
private async Task StageAuditAsync(
Dispatch dispatch,
int workOrderId,
string? userId,
object oldValue,
object newValue,
string action,
DateTime now,
CancellationToken cancellationToken,
bool isStatusTransition = false)
{
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
{
WorkOrderId = workOrderId,
UserId = userId,
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
OldValue = isStatusTransition ? oldValue.ToString() : $"${Convert.ToDecimal(oldValue):F2}",
NewValue = isStatusTransition ? newValue.ToString() : $"${Convert.ToDecimal(newValue):F2}",
Action = action,
ActorType = "internal",
CreatedAt = now,
}, cancellationToken);
}
private static bool IsTerminalForUplift(string? status) =>
status is "Verified" or "Cancelled" or "Canceled" or "Refused";
}
}