shoc-backend/SeaHaven.Services/Implementation/UpliftService.cs
2026-09-24 22:57:09 -03:00

378 lines
19 KiB
C#

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> approvalsOptions,
IWorkOrderUpliftService? workOrderUpliftService = null)
{
_upliftData = upliftData;
_dispatchData = dispatchData;
_documentStorage = documentStorage;
_timeProvider = timeProvider;
_approvalsOptions = approvalsOptions.Value;
_workOrderUpliftService = workOrderUpliftService;
}
public async Task<UpliftListResultDTO> 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,
WorkOrderScheduledDate = r.WorkOrderScheduledDate,
WorkOrderDispatcherName = ResolveRequestedByName(null, r.WorkOrderDispatcherFirstName, r.WorkOrderDispatcherLastName),
TechnicianName = string.IsNullOrWhiteSpace(r.TechnicianName) ? null : r.TechnicianName.Trim(),
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<IEnumerable<UpliftForDispatchDTO>> 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<UpliftApproveResultDTO> 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<UpliftDenyResultDTO> 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<UpliftDecisionResultDTO> RejectAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken)
=> RejectInternalAsync(user, id, note, "reject", cancellationToken);
public async Task<UpliftDecisionResultDTO> 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<UpliftDecisionResultDTO> 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<UpliftDecisionResultDTO> 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<UpliftEvidenceDownloadResultDTO> 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");
}
// Admin passes every permission check regardless of stored configuration,
// so the tier-role lists only govern non-Admin approvers.
private bool UserCanApprove(ClaimsPrincipal user, int requiredTier)
{
if (user.IsInRole("Admin")) return true;
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<string>()
};
// 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";
}
}