mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 10:43:13 +00:00
291 lines
14 KiB
C#
291 lines
14 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;
|
|
|
|
public UpliftService(
|
|
IUpliftDataService upliftData,
|
|
IDispatchDataService dispatchData,
|
|
IVendorDocumentStoragePort documentStorage,
|
|
TimeProvider timeProvider,
|
|
IOptions<ApprovalsOptions> approvalsOptions)
|
|
{
|
|
_upliftData = upliftData;
|
|
_dispatchData = dispatchData;
|
|
_documentStorage = documentStorage;
|
|
_timeProvider = timeProvider;
|
|
_approvalsOptions = approvalsOptions.Value;
|
|
}
|
|
|
|
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 mapped = items.Select(r => new UpliftListItemDTO
|
|
{
|
|
Id = r.Id,
|
|
DispatchId = r.DispatchId,
|
|
DispatchNumber = r.DispatchNumber,
|
|
PONumber = r.PONumber,
|
|
WorkOrderId = r.WorkOrderId,
|
|
VendorCompanyName = r.VendorCompanyName,
|
|
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
|
|
}).ToList();
|
|
|
|
return new UpliftListResultDTO
|
|
{
|
|
Total = total,
|
|
Page = page,
|
|
PageSize = pageSize,
|
|
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);
|
|
|
|
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;
|
|
}
|
|
|
|
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";
|
|
}
|
|
}
|