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) { _upliftData = upliftData; _dispatchData = dispatchData; _detailData = detailData; _accountResolver = accountResolver; _userData = userData; _timeProvider = timeProvider; _approvalsOptions = approvalsOptions.Value; } public async Task?> 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 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 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 change uplifts 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 CancelAsync( int workOrderId, int upliftId, ClaimsPrincipal user, CancellationToken cancellationToken) { if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken)) return null; await EnsureWorkOrderAllowsUpliftMutationAsync(workOrderId, user, cancellationToken); 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"); 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 RevokeAsync( int workOrderId, int upliftId, RevokeWorkOrderUpliftRequestDto request, ClaimsPrincipal user, CancellationToken cancellationToken) { if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken)) return null; await EnsureWorkOrderAllowsUpliftMutationAsync(workOrderId, user, cancellationToken); 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"); var now = _timeProvider.GetUtcNow().UtcDateTime; var previous = canonical; 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 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); } private async Task EnsureWorkOrderAllowsUpliftMutationAsync( int workOrderId, ClaimsPrincipal user, CancellationToken cancellationToken) { var accountFilter = _accountResolver.ResolveAccountFilter(user); var workOrder = await _detailData.GetWorkOrderForMediaAsync( workOrderId, cancellationToken, accountFilter); if (workOrder == null) throw new KeyNotFoundException("Work order not found"); if (workOrder.LifecycleStatus is LifecycleStatus.Completed or LifecycleStatus.Canceled) { throw new InvalidOperationException( $"Cannot change uplifts on a '{workOrder.LifecycleStatus}' work order"); } } private async Task HasWorkOrderAccessAsync( int workOrderId, ClaimsPrincipal user, CancellationToken cancellationToken) { var accountId = _accountResolver.ResolveAccountFilter(user); return await _detailData.ExistsAsync(workOrderId, cancellationToken, accountId); } private async Task 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"; } }