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 == null) 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"); } // 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 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 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 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); } /// /// 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. /// 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 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 = 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"; } }