using Data.SeaHavenIndustries; using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; using System.Security.Claims; namespace SeaHaven.Services.Implementation { public class UpliftService : IUpliftService { private readonly IUpliftDataService _upliftData; private readonly IDispatchDataService _dispatchData; private readonly IVendorDocumentStoragePort _documentStorage; private readonly TimeProvider _timeProvider; private readonly ApprovalsOptions _approvalsOptions; private readonly IWorkOrderUpliftService? _workOrderUpliftService; public UpliftService( IUpliftDataService upliftData, IDispatchDataService dispatchData, IVendorDocumentStoragePort documentStorage, TimeProvider timeProvider, IOptions approvalsOptions, IWorkOrderUpliftService? workOrderUpliftService = null) { _upliftData = upliftData; _dispatchData = dispatchData; _documentStorage = documentStorage; _timeProvider = timeProvider; _approvalsOptions = approvalsOptions.Value; _workOrderUpliftService = workOrderUpliftService; } public async Task ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken) { var (total, items) = await _upliftData.GetPagedAsync(status, tier, page, pageSize, cancellationToken); var pendingExposureTotal = await _upliftData.GetPendingExposureAsync(cancellationToken); var mapped = items.Select(r => new UpliftListItemDTO { Id = r.Id, DispatchId = r.DispatchId, DispatchNumber = r.DispatchNumber, PONumber = r.PONumber, WorkOrderId = r.WorkOrderId, VendorCompanyName = r.VendorCompanyName, WorkOrderNumber = r.WorkOrderNumber, WorkOrderSite = r.WorkOrderSiteCode, WorkOrderService = r.WorkOrderService, RequestedByName = ResolveRequestedByName(r.RequestedByVendorName, r.RequestedByFirstName, r.RequestedByLastName), DecidedByName = ResolveRequestedByName(null, r.DecidedByFirstName, r.DecidedByLastName), CurrentNTE = r.CurrentNTE, RequestedNTE = r.RequestedNTE, Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m), VendorReason = r.VendorReason, RequiredTier = r.RequiredTier, Status = UpliftStatus.ToCanonical(r.Status), RequestedAt = r.CreatedDate, DecidedAt = r.DecidedAt, DecisionNote = r.DecisionNote, CanDecide = UserCanApprove(user, r.RequiredTier), EvidenceDocumentId = r.EvidenceDocumentId, EvidenceFileName = r.EvidenceFileName, EvidenceContentType = r.EvidenceContentType, EvidenceSizeBytes = r.EvidenceSizeBytes, ExpiresAt = r.ExpiresAt, NotificationStatus = r.NotificationStatus, NotificationError = r.NotificationError, AttachmentCount = r.AttachmentCount, WorkOrderClosed = r.WorkOrderClosed }).ToList(); // Approved-on-WO exposure totals are aggregated once for the whole page // (single set-based data-service call) and then attached per item. var workOrderIds = mapped .Where(i => i.WorkOrderId.HasValue) .Select(i => i.WorkOrderId!.Value) .Distinct() .ToList(); var exposureByWorkOrder = (await _upliftData .GetApprovedExposureForWorkOrdersAsync(workOrderIds, cancellationToken)) .ToDictionary(e => e.WorkOrderId); foreach (var item in mapped) { if (!item.WorkOrderId.HasValue) continue; if (!exposureByWorkOrder.TryGetValue(item.WorkOrderId.Value, out var exposure)) continue; item.WorkOrderAutoApprovedTotal = exposure.AutoApprovedTotal; item.WorkOrderAdminApprovedTotal = exposure.AdminApprovedTotal; item.WorkOrderApprovedExposureTotal = exposure.AutoApprovedTotal + exposure.AdminApprovedTotal; } return new UpliftListResultDTO { Total = total, Page = page, PageSize = pageSize, PendingExposureTotal = pendingExposureTotal, Items = mapped }; } public async Task> ListForDispatchAsync(ClaimsPrincipal user, int dispatchId, CancellationToken cancellationToken) { var rows = await _upliftData.GetForDispatchAsync(dispatchId, cancellationToken); return rows.Select(r => new UpliftForDispatchDTO { Id = r.Id, DispatchId = r.DispatchId, CurrentNTE = r.CurrentNTE, RequestedNTE = r.RequestedNTE, Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m), VendorReason = r.VendorReason, Status = UpliftStatus.ToCanonical(r.Status), RequiredTier = r.RequiredTier, RequestedByVendorName = r.RequestedByVendorName, RequestedAt = r.CreatedDate, DecidedAt = r.DecidedAt, DecisionNote = r.DecisionNote, DecidedByName = string.Join(" ", new[] { r.DecidedByFirstName, r.DecidedByLastName } .Where(s => !string.IsNullOrWhiteSpace(s))).Trim(), CanDecide = UserCanApprove(user, r.RequiredTier), EvidenceDocumentId = r.EvidenceDocumentId, EvidenceFileName = r.EvidenceFileName, EvidenceContentType = r.EvidenceContentType, EvidenceSizeBytes = r.EvidenceSizeBytes, EvidenceScanPassed = r.EvidenceScanPassed, ExpiresAt = r.ExpiresAt, NotificationStatus = r.NotificationStatus, NotificationError = r.NotificationError }).ToList(); } public async Task ApproveAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken) { var req = await _upliftData.GetByIdAsync(id, cancellationToken); if (req == null) throw new KeyNotFoundException("Uplift request not found"); if (UpliftStatus.IsTerminal(req.Status)) throw new InvalidOperationException($"Cannot approve a '{UpliftStatus.ToCanonical(req.Status)}' request"); if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.Approved)) throw new InvalidOperationException($"Cannot approve a '{UpliftStatus.ToCanonical(req.Status)}' request"); if (!UserCanApprove(user, req.RequiredTier)) throw new UpliftForbiddenException($"Approval requires a Tier {req.RequiredTier} role"); var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId); if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); if (IsTerminalForUplift(dispatch.Status)) throw new InvalidOperationException($"Cannot approve uplift on a '{dispatch.Status}' dispatch"); var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; var now = _timeProvider.GetUtcNow().UtcDateTime; var oldNTE = dispatch.NTEAmount ?? 0m; dispatch.NTEAmount = req.RequestedNTE; dispatch.LastModificationTime = now; req.Status = UpliftStatus.Approved; req.DecidedAt = now; req.DecidedByUserId = userId; req.DecisionNote = string.IsNullOrWhiteSpace(note) ? null : note!.Trim(); req.LastModificationTime = now; await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = dispatch.WorkOrderId ?? 0, UserId = userId, FieldName = $"Dispatch {dispatch.DispatchNumber} NTE", OldValue = $"${oldNTE:F2}", NewValue = $"${req.RequestedNTE:F2}", Action = "uplift_approved", CreatedAt = now }, cancellationToken); // One EF unit-of-work commit for the dispatch RowVersion-protected NTE + request + audit. await _upliftData.SaveChangesAsync(cancellationToken); return new UpliftApproveResultDTO { Id = req.Id, Status = UpliftStatus.Approved, NTEAmount = dispatch.NTEAmount }; } public async Task DenyAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken) { var result = await RejectInternalAsync(user, id, note, "deny", cancellationToken); return new UpliftDenyResultDTO { Id = result.Id, Status = result.Status }; } public Task RejectAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken) => RejectInternalAsync(user, id, note, "reject", cancellationToken); public async Task RevokeAsync( ClaimsPrincipal user, int id, string reason, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(reason)) throw new InvalidOperationException("A reason is required when revoking an approved uplift"); var request = await _upliftData.GetByIdAsync(id, cancellationToken); if (request == null) throw new KeyNotFoundException("Uplift request not found"); if (!string.Equals(UpliftStatus.ToCanonical(request.Status), UpliftStatus.Approved, StringComparison.Ordinal)) throw new InvalidOperationException($"Cannot revoke a '{UpliftStatus.ToCanonical(request.Status)}' uplift request"); if (!user.IsInRole("Admin")) throw new UpliftForbiddenException("Admin role is required to revoke an approved uplift"); if (_workOrderUpliftService == null) throw new InvalidOperationException("The work-order uplift flow is unavailable"); var workOrderId = await _upliftData.GetWorkOrderIdForUpliftAsync(id, cancellationToken); if (!workOrderId.HasValue) throw new KeyNotFoundException("Work order not found"); var revoked = await _workOrderUpliftService.RevokeAsync( workOrderId.Value, id, new RevokeWorkOrderUpliftRequestDto { Reason = reason.Trim() }, user, cancellationToken); if (revoked == null) throw new KeyNotFoundException("Work order not found"); return new UpliftDecisionResultDTO { Id = revoked.Id, Status = UpliftStatus.Revoked }; } private async Task RejectInternalAsync(ClaimsPrincipal user, int id, string? note, string actionSuffix, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(note)) throw new InvalidOperationException("A note is required when rejecting"); var req = await _upliftData.GetByIdAsync(id, cancellationToken); if (req == null) throw new KeyNotFoundException("Uplift request not found"); if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.Rejected)) throw new InvalidOperationException($"Cannot reject a '{UpliftStatus.ToCanonical(req.Status)}' request"); if (!UserCanApprove(user, req.RequiredTier)) throw new UpliftForbiddenException($"Decision requires a Tier {req.RequiredTier} role"); var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId); var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; var now = _timeProvider.GetUtcNow().UtcDateTime; // Capture the pre-transition canonical status before mutating req.Status, // otherwise the audit OldValue would record the already-applied Rejected value. var previous = UpliftStatus.ToCanonical(req.Status); req.Status = UpliftStatus.Rejected; req.DecidedAt = now; req.DecidedByUserId = userId; req.DecisionNote = note!.Trim(); req.LastModificationTime = now; await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = dispatch?.WorkOrderId ?? 0, UserId = userId, FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift", OldValue = previous, NewValue = UpliftStatus.Rejected, Action = $"uplift_{actionSuffix}", CreatedAt = now }, cancellationToken); await _upliftData.SaveChangesAsync(cancellationToken); return new UpliftDecisionResultDTO { Id = req.Id, Status = UpliftStatus.Rejected }; } public async Task RequestChangesAsync(ClaimsPrincipal user, int id, string note, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(note)) throw new InvalidOperationException("A note is required when requesting changes"); var req = await _upliftData.GetByIdAsync(id, cancellationToken); if (req == null) throw new KeyNotFoundException("Uplift request not found"); if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.ChangesRequested)) throw new InvalidOperationException($"Cannot request changes on a '{UpliftStatus.ToCanonical(req.Status)}' request"); if (!UserCanApprove(user, req.RequiredTier)) throw new UpliftForbiddenException($"Requesting changes requires a Tier {req.RequiredTier} role"); var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId); var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; var now = _timeProvider.GetUtcNow().UtcDateTime; var previous = UpliftStatus.ToCanonical(req.Status); req.Status = UpliftStatus.ChangesRequested; req.DecidedAt = now; req.DecidedByUserId = userId; req.DecisionNote = note.Trim(); req.LastModificationTime = now; await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = dispatch?.WorkOrderId ?? 0, UserId = userId, FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift", OldValue = previous, NewValue = UpliftStatus.ChangesRequested, Action = "uplift_changes_requested", CreatedAt = now }, cancellationToken); await _upliftData.SaveChangesAsync(cancellationToken); return new UpliftDecisionResultDTO { Id = req.Id, Status = UpliftStatus.ChangesRequested }; } public bool CanApprove(ClaimsPrincipal user, int tier) => UserCanApprove(user, tier); // SH-101: authorized internal download of a Passed UpliftEvidence file. The data // service resolves the document by the request's own linkage (no client-supplied // document id or vendor/public path). Only UpliftEvidence documents are exposed; // completion documents resolve to NotFound. A scan that has not Passed is Locked. public async Task GetEvidenceForDownloadAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken) { var evidence = await _upliftData.GetEvidenceForInternalDownloadAsync(id, cancellationToken); if (evidence == null || evidence.EvidenceDocumentId == null || !string.Equals(evidence.Purpose, VendorDocumentPurpose.UpliftEvidence, StringComparison.Ordinal)) { return UpliftEvidenceDownloadResultDTO.NotFound(); } if (!UserCanApprove(user, evidence.RequiredTier)) { throw new UpliftForbiddenException($"Evidence access requires a Tier {evidence.RequiredTier} role"); } if (!string.Equals(evidence.ScanStatus, "Passed", StringComparison.OrdinalIgnoreCase)) { return UpliftEvidenceDownloadResultDTO.Locked(); } var content = _documentStorage.OpenRead(evidence.VendorId, evidence.DispatchId, evidence.StoredFileName ?? string.Empty); return UpliftEvidenceDownloadResultDTO.Ok(content, evidence.ContentType ?? "application/octet-stream", evidence.OriginalFileName ?? "evidence"); } private bool UserCanApprove(ClaimsPrincipal user, int requiredTier) { var roles = RolesForTier(requiredTier); foreach (var r in roles) { if (!string.IsNullOrWhiteSpace(r) && user.IsInRole(r)) return true; } return false; } // Approval queue read contract: the requester label prefers the recorded // requester (vendor or internal display name captured at creation) and falls // back to the creating user's name resolved server-side. private static string? ResolveRequestedByName(string? vendorName, string? firstName, string? lastName) { if (!string.IsNullOrWhiteSpace(vendorName)) return vendorName; var composed = string.Join(" ", new[] { firstName, lastName } .Where(s => !string.IsNullOrWhiteSpace(s))).Trim(); return composed.Length > 0 ? composed : null; } private string[] RolesForTier(int requiredTier) => requiredTier switch { 1 => _approvalsOptions.Tier1Roles, 2 => _approvalsOptions.Tier2Roles, _ => Array.Empty() }; // Terminal dispatch guard for uplift decisions. "Refused" is the SH-98 // dispatch-refusal workflow; a refused dispatch is terminal for uplift just // like Verified/Cancelled. private static bool IsTerminalForUplift(string? status) => status is "Verified" or "Cancelled" or "Canceled" or "Refused"; } }