shoc-backend/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs
Arthur Bassi 31dd2d5dcd merge(main): keep uplift evidence on the current work-order routes
The route contract keeps the poc endpoint from main and the uplift evidence routes from this branch. Create still passes the scanned evidence document through the locked mutation.
2026-09-28 15:56:45 -03:00

503 lines
21 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;
private readonly IVendorDocumentDataService _documentData;
public WorkOrderUpliftService(
IUpliftDataService upliftData,
IDispatchDataService dispatchData,
IWorkOrderDetailDataService detailData,
IWorkOrderAccountResolver accountResolver,
IUserDataService userData,
ITeamPermissionOverrideDataService permissionOverrideData,
ITeamPermissionPolicy permissionPolicy,
TimeProvider timeProvider,
IOptions<ApprovalsOptions> approvalsOptions,
IVendorDocumentDataService documentData)
{
_upliftData = upliftData;
_dispatchData = dispatchData;
_detailData = detailData;
_accountResolver = accountResolver;
_userData = userData;
_permissionOverrideData = permissionOverrideData;
_permissionPolicy = permissionPolicy;
_timeProvider = timeProvider;
_approvalsOptions = approvalsOptions.Value;
_documentData = documentData;
}
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,
request.EvidenceDocumentId,
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,
int? evidenceDocumentId,
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");
await RequirePassedEvidenceAsync(evidenceDocumentId, dispatch.Id, dispatch.VendorId, cancellationToken);
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,
evidenceDocumentId,
UpliftStatus.NoApprovalRequired,
requiredTier: 0,
expiresAt: null,
UpliftNotificationStatus.Sent,
"uplift_auto_approved",
now,
cancellationToken);
}
return await PersistCreatedAsync(
dispatch,
workOrderId,
userId,
requesterName,
current,
amount,
notes,
evidenceDocumentId,
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)
{
// An admin revoke overturns a human decision, and an auto-approval has none,
// so admins are refused here even when they filed the request themselves.
if (user.IsInRole("Admin"))
throw new UpliftForbiddenException("Admins can revoke only admin-approved uplifts");
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,
int? evidenceDocumentId,
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,
EvidenceDocumentId = evidenceDocumentId,
};
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 RequirePassedEvidenceAsync(
int? evidenceDocumentId,
int dispatchId,
int vendorId,
CancellationToken cancellationToken)
{
if (!evidenceDocumentId.HasValue)
return;
var evidence = await _documentData.GetUpliftEvidenceAsync(
evidenceDocumentId.Value,
dispatchId,
vendorId,
cancellationToken);
if (evidence == null)
{
throw new InvalidOperationException(
"The selected evidence document is not available or has not passed scanning");
}
}
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";
}
}