mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 22:23:12 +00:00
438 lines
18 KiB
C#
438 lines
18 KiB
C#
using System.Security.Claims;
|
|
using Data.SeaHavenIndustries;
|
|
using Data.SeaHavenIndustries.Enums;
|
|
using Microsoft.Extensions.Options;
|
|
using SeaHaven.DataServices.Interfaces;
|
|
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 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 TimeProvider _timeProvider;
|
|
private readonly ApprovalsOptions _approvalsOptions;
|
|
|
|
public WorkOrderUpliftService(
|
|
IUpliftDataService upliftData,
|
|
IDispatchDataService dispatchData,
|
|
IWorkOrderDetailDataService detailData,
|
|
IWorkOrderAccountResolver accountResolver,
|
|
IUserDataService userData,
|
|
TimeProvider timeProvider,
|
|
IOptions<ApprovalsOptions> approvalsOptions)
|
|
{
|
|
_upliftData = upliftData;
|
|
_dispatchData = dispatchData;
|
|
_detailData = detailData;
|
|
_accountResolver = accountResolver;
|
|
_userData = userData;
|
|
_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;
|
|
|
|
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);
|
|
|
|
return await _upliftData.ExecuteWorkOrderMutationAsync(
|
|
workOrderId,
|
|
ct => CreateLockedAsync(
|
|
workOrderId,
|
|
request.Amount,
|
|
notes,
|
|
userId,
|
|
requesterName,
|
|
accountFilter,
|
|
ct),
|
|
cancellationToken);
|
|
}
|
|
|
|
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?.PrimaryDispatchId is not int dispatchId)
|
|
throw new InvalidOperationException("Work order has no primary dispatch for uplift requests");
|
|
|
|
if (workOrder.LifecycleStatus is LifecycleStatus.Completed or LifecycleStatus.Canceled)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Cannot create an uplift on a '{workOrder.LifecycleStatus}' work order");
|
|
}
|
|
|
|
var dispatch = await _dispatchData.GetByIdAsync(dispatchId);
|
|
if (dispatch == null)
|
|
throw new KeyNotFoundException("Dispatch not found");
|
|
|
|
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 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)
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
|
|
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 = dispatch.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";
|
|
}
|
|
}
|