mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 18:53:12 +00:00
The uplift detail modal needs the work order's assigned dispatcher, the requesting vendor's technician and the scheduled date. They now resolve from the same effective work order and vendor as the existing queue row, so the modal no longer depends on a separate work-order fetch that account-scoped staff cannot read.
374 lines
19 KiB
C#
374 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");
|
|
}
|
|
|
|
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<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";
|
|
}
|
|
}
|