shoc-backend/SeaHaven.Services/Implementation/VendorPortalService.cs
2026-08-11 08:58:19 -03:00

1099 lines
49 KiB
C#

using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class VendorPortalService : IVendorPortalService
{
private static readonly HashSet<string> AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase)
{
"application/pdf", "image/jpeg", "image/jpg", "image/png"
};
private const int MaxRefusalReasonLength = 500;
private readonly IVendorPortalTokenService _tokens;
private readonly IDispatchDataService _dispatchData;
private readonly IUpliftDataService _upliftData;
private readonly ICommentDataService _commentData;
private readonly IUserDataService _userData;
private readonly IEmailSender _emailSender;
private readonly IVendorDocumentDataService _documentData;
private readonly IVendorDocumentStoragePort _documentStorage;
private readonly FrontendOptions _frontendOptions;
private readonly ApprovalsOptions _approvalsOptions;
private readonly VendorDocumentsOptions _documentOptions;
private readonly TimeProvider _timeProvider;
public VendorPortalService(
IVendorPortalTokenService tokens,
IDispatchDataService dispatchData,
IUpliftDataService upliftData,
ICommentDataService commentData,
IUserDataService userData,
IEmailSender emailSender,
IVendorDocumentDataService documentData,
IVendorDocumentStoragePort documentStorage,
IOptions<FrontendOptions> frontendOptions,
IOptions<ApprovalsOptions> approvalsOptions,
IOptions<VendorDocumentsOptions> documentOptions,
TimeProvider timeProvider)
{
_tokens = tokens;
_dispatchData = dispatchData;
_upliftData = upliftData;
_commentData = commentData;
_userData = userData;
_emailSender = emailSender;
_documentData = documentData;
_documentStorage = documentStorage;
_frontendOptions = frontendOptions.Value;
_approvalsOptions = approvalsOptions.Value;
_documentOptions = documentOptions.Value;
_timeProvider = timeProvider;
}
public async Task<VendorPortalSession?> ResolveSessionAsync(string? token, CancellationToken cancellationToken)
{
return await _tokens.ResolveSessionAsync(token, cancellationToken);
}
public async Task<IEnumerable<VendorDispatchSummaryDTO>> ListDispatchesAsync(VendorPortalSession session, string? status, CancellationToken cancellationToken)
{
var summaries = await _dispatchData.GetVendorDispatchSummariesAsync(session.Id, status, cancellationToken);
return summaries.Select(d => new VendorDispatchSummaryDTO
{
Id = d.Id,
DispatchNumber = d.DispatchNumber,
PONumber = d.PONumber,
Status = d.Status,
NTEAmount = d.NTEAmount,
ScheduledDate = d.ScheduledDate,
CompletedDate = d.CompletedDate,
DispatchedAt = d.DispatchedAt,
AcknowledgedAt = d.AcknowledgedAt,
WorkOrderTitle = d.WorkOrderTitle,
InternalWONumber = d.InternalWONumber,
LocationName = d.LocationName,
LocationCity = d.LocationCity,
LocationState = d.LocationState
});
}
public async Task<VendorDispatchDetailDTO?> GetDispatchDetailAsync(VendorPortalSession session, int id, CancellationToken cancellationToken)
{
var dispatch = await _dispatchData.GetVendorDispatchDetailAsync(id, session.Id, cancellationToken);
if (dispatch == null) return null;
var checklist = await _dispatchData.GetPortalChecklistAsync(id, cancellationToken);
var signoffs = await _dispatchData.GetPortalSignoffsAsync(id, cancellationToken);
var rawUplifts = await _upliftData.GetForVendorDispatchAsync(id, cancellationToken);
var rawComments = await _commentData.GetVendorViewableForDispatchAsync(id, cancellationToken);
var rawDocuments = await _documentData.ListForVendorDispatchAsync(id, session.Id, cancellationToken);
var upliftRequests = rawUplifts.Select(u => new PortalUpliftDTO
{
Id = u.Id,
CurrentNTE = u.CurrentNTE,
RequestedNTE = u.RequestedNTE,
VendorReason = u.VendorReason,
Status = UpliftStatus.ToCanonical(u.Status),
RequiredTier = u.RequiredTier,
RequestedByVendorName = u.RequestedByVendorName,
RequestedAt = u.CreatedDate,
DecidedAt = u.DecidedAt,
DecisionNote = u.DecisionNote,
DecidedByName = string.Join(" ", new[] { u.DecidedByFirstName, u.DecidedByLastName }
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim(),
EvidenceDocumentId = u.EvidenceDocumentId,
EvidenceFileName = u.EvidenceFileName,
EvidenceContentType = u.EvidenceContentType,
EvidenceSizeBytes = u.EvidenceSizeBytes,
EvidenceScanPassed = u.EvidenceScanPassed,
ExpiresAt = u.ExpiresAt,
NotificationStatus = u.NotificationStatus,
NotificationError = u.NotificationError,
HasChangesRequested = UpliftStatus.ToCanonical(u.Status) == UpliftStatus.ChangesRequested
}).ToList();
var comments = rawComments.Select(c =>
{
var hasShocUser = !string.IsNullOrWhiteSpace(c.UserId);
var userName = string.Join(" ", new[] { c.UserFirstName, c.UserLastName }
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim();
string resolvedType;
string resolvedCommenter;
if (hasShocUser)
{
resolvedType = "dispatcher";
resolvedCommenter = !string.IsNullOrWhiteSpace(userName) ? userName
: !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter!
: "Dispatcher";
}
else if (c.CommentType == "customer")
{
resolvedType = "customer";
resolvedCommenter = !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : "Customer";
}
else
{
resolvedType = "vendor";
resolvedCommenter = !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : session.CompanyName ?? "Vendor";
}
return new PortalCommentDTO
{
Id = c.Id,
Commenttext = c.Commenttext,
Commenter = resolvedCommenter,
CommentType = resolvedType,
CreatedDate = c.CreatedDate
};
}).ToList();
DispatcherContactDTO? dispatcherContact = null;
if (dispatch.WorkOrderId.HasValue)
{
var dispatcherUserId = await _dispatchData.GetDispatchDispatcherUserIdAsync(
dispatch.WorkOrderId.Value, cancellationToken);
if (!string.IsNullOrWhiteSpace(dispatcherUserId))
{
var profile = await _userData.GetProfileAsync(dispatcherUserId, cancellationToken);
if (profile != null)
{
dispatcherContact = new DispatcherContactDTO
{
Name = profile.FirstName,
Email = profile.Email,
Phone = profile.Contact
};
}
}
}
return new VendorDispatchDetailDTO
{
Id = dispatch.Id,
DispatchNumber = dispatch.DispatchNumber,
PONumber = dispatch.PONumber,
Status = dispatch.Status,
InvoiceNumber = dispatch.InvoiceNumber,
InvoiceStatus = dispatch.InvoiceStatus,
PaymentStatus = dispatch.PaymentStatus,
NTEAmount = dispatch.NTEAmount,
Description = dispatch.Description,
ScheduledDate = dispatch.ScheduledDate,
CompletedDate = dispatch.CompletedDate,
DispatchedAt = dispatch.DispatchedAt,
AcknowledgedAt = dispatch.AcknowledgedAt,
StatusUpdatedAt = dispatch.LastModificationTime,
WorkOrder = dispatch.WorkOrder == null ? null : new PortalWorkOrderDTO
{
Id = dispatch.WorkOrder.Id,
InternalWONumber = dispatch.WorkOrder.InternalWONumber,
WorkerOrderTitle = dispatch.WorkOrder.WorkerOrderTitle,
Description = dispatch.WorkOrder.Description,
Priority = dispatch.WorkOrder.Priority,
DueDate = dispatch.WorkOrder.DueDate,
Trade = dispatch.WorkOrder.Trade,
SubTrade = dispatch.WorkOrder.SubTrade,
Service = dispatch.WorkOrder.Service,
Problem = dispatch.WorkOrder.Problem,
SiteCode = dispatch.WorkOrder.SiteCode
},
Location = dispatch.WorkOrder?.Locations == null ? null : new PortalLocationDTO
{
Name = dispatch.WorkOrder.Locations.Name,
Address1 = dispatch.WorkOrder.Locations.Address1,
City = dispatch.WorkOrder.Locations.City,
State = dispatch.WorkOrder.Locations.State,
Zip = dispatch.WorkOrder.Locations.Zip
},
DispatcherContact = dispatcherContact,
Checklist = checklist.Select(c => new PortalChecklistItemDTO
{
Id = c.Id,
ItemText = c.ItemText,
IsCompleted = c.IsCompleted,
CompletedBy = c.CompletedBy,
CompletedAt = c.CompletedAt
}),
Signoffs = signoffs.Select(s => new PortalSignoffDTO
{
Id = s.Id,
SignoffType = s.SignoffType,
Name = s.Name,
SignatureMethod = s.SignatureMethod,
SignedAt = s.SignedAt
}),
Comments = comments,
UpliftRequests = upliftRequests,
Documents = rawDocuments.Select(document => new VendorPortalDocumentDTO
{
Id = document.Id,
OriginalFileName = document.OriginalFileName,
ContentType = document.ContentType,
SizeBytes = document.SizeBytes,
ScanStatus = document.ScanStatus,
ReviewStatus = document.ReviewStatus,
RejectionReason = document.RejectionReason,
Version = document.Version,
ReplacesDocumentId = document.ReplacesDocumentId,
CreatedDate = document.CreatedDate,
ScannedAt = document.ScannedAt,
ReviewedAt = document.ReviewedAt,
CanDownload = string.Equals(document.ScanStatus, "Passed", StringComparison.OrdinalIgnoreCase)
&& !IsPortalLockedStatus(dispatch.Status)
})
};
}
public async Task<AcceptDispatchResultDTO> AcceptDispatchAsync(VendorPortalSession session, int id, CancellationToken cancellationToken)
{
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken);
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
if (dispatch.Status != "Sent")
{
throw new InvalidOperationException($"Cannot accept a dispatch with status '{dispatch.Status}'");
}
var now = DateTime.UtcNow;
dispatch.Status = "Acknowledged";
dispatch.AcknowledgedAt = now;
dispatch.LastModificationTime = now;
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
FieldName = $"Dispatch {dispatch.DispatchNumber} Status",
OldValue = "Sent",
NewValue = "Acknowledged",
Action = "vendor_accept",
CreatedAt = now
}, cancellationToken);
await _dispatchData.SaveChangesAsync(cancellationToken);
return new AcceptDispatchResultDTO { Id = dispatch.Id, Status = dispatch.Status, AcknowledgedAt = dispatch.AcknowledgedAt };
}
public async Task<RefuseDispatchResultDTO> RefuseDispatchAsync(VendorPortalSession session, int id, string? reason, CancellationToken cancellationToken)
{
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken);
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
if (dispatch.Status != "Sent")
{
throw new InvalidOperationException($"Cannot refuse a dispatch with status '{dispatch.Status}'");
}
var reasonText = reason?.Trim();
if (reasonText != null && reasonText.Length > MaxRefusalReasonLength)
{
throw new InvalidOperationException($"Refusal reason must be {MaxRefusalReasonLength} characters or fewer");
}
var now = DateTime.UtcNow;
dispatch.Status = "Refused";
dispatch.LastModificationTime = now;
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
FieldName = $"Dispatch {dispatch.DispatchNumber} Status",
OldValue = "Sent",
NewValue = "Refused",
Action = "vendor_refuse",
CreatedAt = now
}, cancellationToken);
if (!string.IsNullOrWhiteSpace(reasonText))
{
await _commentData.StageAsync(new Comments
{
DispatchId = id,
WorkerOrderId = dispatch.WorkOrderId,
Commenter = session.CompanyName,
CommentType = "vendor",
RecordType = "refusal",
Commenttext = reasonText,
CreatedDate = now
}, cancellationToken);
}
await _dispatchData.SaveChangesAsync(cancellationToken);
return new RefuseDispatchResultDTO { Id = dispatch.Id, Status = dispatch.Status, RefusedAt = now };
}
public async Task<ChangeStatusResultDTO> ChangeStatusAsync(VendorPortalSession session, int id, string? to, CancellationToken cancellationToken)
{
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken);
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
var from = dispatch.Status;
if (!IsAllowedVendorTransition(from, to))
{
throw new InvalidOperationException($"Transition from '{from}' to '{to}' is not allowed");
}
var now = DateTime.UtcNow;
dispatch.Status = to;
dispatch.LastModificationTime = now;
if (to == "Completed") dispatch.CompletedDate = now;
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
FieldName = $"Dispatch {dispatch.DispatchNumber} Status",
OldValue = from,
NewValue = to,
Action = "vendor_status_change",
CreatedAt = now
}, cancellationToken);
await _dispatchData.SaveChangesAsync(cancellationToken);
return new ChangeStatusResultDTO { Id = dispatch.Id, Status = dispatch.Status, CompletedDate = dispatch.CompletedDate };
}
public async Task RequestCancelAsync(VendorPortalSession session, int id, string? reason, CancellationToken cancellationToken)
{
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken);
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
if (IsPortalLockedStatus(dispatch.Status))
{
throw new InvalidOperationException($"Cannot request cancel on a '{dispatch.Status}' dispatch");
}
var now = DateTime.UtcNow;
var reasonText = string.IsNullOrWhiteSpace(reason) ? "(no reason provided)" : reason!.Trim();
await _commentData.StageAsync(new Comments
{
DispatchId = id,
WorkerOrderId = dispatch.WorkOrderId,
Commenter = session.CompanyName,
CommentType = "vendor",
RecordType = "cancel_request",
Commenttext = $"Vendor requested cancellation: {reasonText}",
CreatedDate = now
}, cancellationToken);
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
FieldName = $"Dispatch {dispatch.DispatchNumber}",
OldValue = dispatch.Status,
NewValue = "Cancel Requested",
Action = "vendor_request_cancel",
CreatedAt = now
}, cancellationToken);
await _dispatchData.SaveChangesAsync(cancellationToken);
}
public async Task<ChecklistItemResultDTO> UpdateChecklistItemAsync(VendorPortalSession session, int id, int itemId, bool isCompleted, CancellationToken cancellationToken)
{
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken);
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
if (IsPortalLockedStatus(dispatch.Status))
{
throw new InvalidOperationException("Dispatch is locked");
}
var item = await _dispatchData.GetChecklistItemForDispatchAsync(itemId, id, cancellationToken);
if (item == null) throw new KeyNotFoundException("Checklist item not found");
var now = DateTime.UtcNow;
item.IsCompleted = isCompleted;
item.CompletedBy = isCompleted ? session.CompanyName : null;
item.CompletedAt = isCompleted ? now : null;
item.LastModificationTime = now;
await _dispatchData.SaveChangesAsync(cancellationToken);
return new ChecklistItemResultDTO
{
Id = item.Id,
IsCompleted = item.IsCompleted,
CompletedBy = item.CompletedBy,
CompletedAt = item.CompletedAt
};
}
public async Task<SignoffResultDTO> AddSignoffAsync(VendorPortalSession session, int id, string? signoffType, string? name, string? signature, string? signatureMethod, CancellationToken cancellationToken)
{
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken);
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
if (dispatch.Status != "In Progress" && dispatch.Status != "Completed")
{
throw new InvalidOperationException("Signoffs only allowed on In Progress or Completed dispatches");
}
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(signature))
{
throw new InvalidOperationException("Name and signature are required");
}
var normalizedSignoffType = (signoffType ?? "vendor").Trim().ToLowerInvariant();
if (normalizedSignoffType != "vendor" && normalizedSignoffType != "customer")
{
throw new InvalidOperationException("signoffType must be 'vendor' or 'customer'");
}
var existing = await _dispatchData.HasSignoffTypeAsync(id, normalizedSignoffType);
if (existing)
{
throw new InvalidOperationException($"A {normalizedSignoffType} signoff already exists for this dispatch");
}
var now = DateTime.UtcNow;
var signoff = new DispatchSignoff
{
DispatchId = id,
SignoffType = normalizedSignoffType,
Name = name,
Signature = signature,
SignatureMethod = signatureMethod ?? "drawn",
SignedAt = now,
CreatedDate = now
};
await _dispatchData.StageSignoffAsync(signoff, cancellationToken);
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
FieldName = $"Dispatch {dispatch.DispatchNumber} Signoff",
OldValue = null,
NewValue = $"{(normalizedSignoffType == "customer" ? "Customer" : "Vendor")}: {name}",
Action = normalizedSignoffType == "customer" ? "customer_signoff" : "vendor_signoff",
CreatedAt = now
}, cancellationToken);
await _dispatchData.SaveChangesAsync(cancellationToken);
return new SignoffResultDTO { Id = signoff.Id, SignedAt = signoff.SignedAt };
}
public async Task<CommentResultDTO> AddCommentAsync(VendorPortalSession session, int id, string? commentText, CancellationToken cancellationToken)
{
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken);
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
if (string.IsNullOrWhiteSpace(commentText))
{
throw new InvalidOperationException("Comment cannot be empty");
}
var now = DateTime.UtcNow;
var comment = new Comments
{
DispatchId = id,
WorkerOrderId = dispatch.WorkOrderId,
Commenter = session.CompanyName,
CommentType = "vendor",
RecordType = "comment",
Commenttext = commentText,
CreatedDate = now
};
await _commentData.StageAsync(comment, cancellationToken);
await _dispatchData.SaveChangesAsync(cancellationToken);
return new CommentResultDTO
{
Id = comment.Id,
Commenttext = comment.Commenttext,
Commenter = comment.Commenter,
CreatedDate = comment.CreatedDate
};
}
public async Task<UpliftRequestResultDTO> RequestUpliftAsync(VendorPortalSession session, int id, decimal requestedNTE, string? reason, string? requestKey, int? evidenceDocumentId, CancellationToken cancellationToken)
{
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken);
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
if (IsPortalLockedStatus(dispatch.Status))
{
throw new InvalidOperationException($"Cannot request uplift on a '{dispatch.Status}' dispatch");
}
if (requestedNTE <= 0)
{
throw new InvalidOperationException("Requested NTE must be greater than zero");
}
var now = _timeProvider.GetUtcNow().UtcDateTime;
var current = dispatch.NTEAmount ?? 0m;
// NoApprovalRequired: requestedNTE <= current. Do not create an uplift or change NTE;
// record an audit event documenting the routed non-uplift outcome.
if (requestedNTE <= current)
{
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
OldValue = $"${current:F2}",
NewValue = $"${requestedNTE:F2}",
Action = "uplift_no_approval_required",
ActorType = "vendor",
CreatedAt = now
}, cancellationToken);
await _upliftData.SaveChangesAsync(cancellationToken);
return new UpliftRequestResultDTO
{
Id = 0,
Status = UpliftStatus.NoApprovalRequired,
RequiredTier = 0,
CurrentNTE = current,
RequestedNTE = requestedNTE,
NoApprovalRequired = true
};
}
// requestedNTE > current: reason and scanned evidence are mandatory.
if (string.IsNullOrWhiteSpace(reason))
{
throw new InvalidOperationException("A reason is required when requesting an uplift above the current NTE");
}
if (!evidenceDocumentId.HasValue)
{
throw new InvalidOperationException("Supporting evidence is required when requesting an uplift above the current NTE");
}
var evidence = await _documentData.GetUpliftEvidenceAsync(evidenceDocumentId.Value, id, session.Id, cancellationToken);
if (evidence == null)
{
throw new InvalidOperationException("The selected evidence document is not available or has not passed scanning");
}
// Idempotency: a repeated identical RequestKey returns the same request; mismatched reuse rejects.
if (!string.IsNullOrWhiteSpace(requestKey))
{
var normalizedKey = requestKey.Trim();
if (normalizedKey.Length > 100)
{
throw new InvalidOperationException("RequestKey is too long");
}
var existing = await _upliftData.GetByRequestKeyAsync(id, normalizedKey, cancellationToken);
if (existing != null)
{
if (existing.RequestedNTE != requestedNTE
|| existing.EvidenceDocumentId != evidence.Id
|| !string.Equals(existing.VendorReason ?? "", reason.Trim(), StringComparison.Ordinal))
{
throw new InvalidOperationException("RequestKey was already used with different uplift details");
}
return new UpliftRequestResultDTO
{
Id = existing.Id,
Status = UpliftStatus.ToCanonical(existing.Status),
RequiredTier = existing.RequiredTier,
CurrentNTE = existing.CurrentNTE,
RequestedNTE = existing.RequestedNTE,
NoApprovalRequired = false,
EvidenceDocumentId = existing.EvidenceDocumentId,
ExpiresAt = existing.ExpiresAt,
NotificationStatus = existing.NotificationStatus,
IdempotentReplay = true
};
}
}
// At most one active (Pending or ChangesRequested) request per dispatch.
var activeExists = await _upliftData.HasActiveAsync(id, cancellationToken);
if (activeExists)
{
throw new InvalidOperationException("An active uplift request already exists for this dispatch");
}
var tier1Max = _approvalsOptions.UpliftTier1MaxUsd ?? 2500m;
var delta = requestedNTE - current;
var requiredTier = delta > tier1Max ? 2 : 1;
var expiresAt = now + _approvalsOptions.EffectiveExpiration;
var req = new DispatchUpliftRequest
{
DispatchId = id,
CurrentNTE = current,
RequestedNTE = requestedNTE,
VendorReason = reason.Trim(),
Status = UpliftStatus.Pending,
RequiredTier = requiredTier,
RequestedByVendorName = session.CompanyName,
EvidenceDocumentId = evidence.Id,
RequestKey = string.IsNullOrWhiteSpace(requestKey) ? null : requestKey.Trim(),
ExpiresAt = expiresAt,
NotificationStatus = UpliftNotificationStatus.Pending,
CreatedDate = now
};
await _upliftData.StageAsync(req, cancellationToken);
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
OldValue = $"${current:F2}",
NewValue = $"${requestedNTE:F2}",
Action = "uplift_requested",
ActorType = "vendor",
CreatedAt = now
}, cancellationToken);
await _upliftData.SaveChangesAsync(cancellationToken);
// Notification is best-effort and persisted separately from workflow state: a failure
// never destroys the actionable request (the lifecycle sweep retries by sentinel).
await NotifyDispatcherOfUpliftAsync(dispatch, req, session, cancellationToken);
return new UpliftRequestResultDTO
{
Id = req.Id,
Status = req.Status,
RequiredTier = req.RequiredTier,
CurrentNTE = req.CurrentNTE,
RequestedNTE = req.RequestedNTE,
EvidenceDocumentId = req.EvidenceDocumentId,
ExpiresAt = req.ExpiresAt,
NotificationStatus = req.NotificationStatus
};
}
// Cancel alias stays route-compatible; canonical stored status is Withdrawn.
public async Task<CancelUpliftResultDTO> CancelUpliftRequestAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken)
{
var result = await WithdrawInternalAsync(session, id, requestId, "cancel", cancellationToken);
return new CancelUpliftResultDTO { Id = result.Id, Status = result.Status };
}
public Task<WithdrawUpliftResultDTO> WithdrawUpliftAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken)
=> WithdrawInternalAsync(session, id, requestId, "withdraw", cancellationToken);
private async Task<WithdrawUpliftResultDTO> WithdrawInternalAsync(VendorPortalSession session, int id, int requestId, string actionSuffix, CancellationToken cancellationToken)
{
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken);
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
var req = await _upliftData.GetByIdAndDispatchAsync(requestId, id, cancellationToken);
if (req == null) throw new KeyNotFoundException("Uplift request not found");
if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.Withdrawn))
{
throw new InvalidOperationException($"Cannot withdraw a '{UpliftStatus.ToCanonical(req.Status)}' uplift request");
}
var now = _timeProvider.GetUtcNow().UtcDateTime;
var previous = UpliftStatus.ToCanonical(req.Status);
req.Status = UpliftStatus.Withdrawn;
req.DecidedAt = now;
req.LastModificationTime = now;
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
OldValue = previous,
NewValue = UpliftStatus.Withdrawn,
Action = $"uplift_{actionSuffix}",
ActorType = "vendor",
CreatedAt = now
}, cancellationToken);
await _upliftData.SaveChangesAsync(cancellationToken);
return new WithdrawUpliftResultDTO { Id = req.Id, Status = UpliftStatus.Withdrawn };
}
// Vendor revise endpoint on ChangesRequested: updates requested amount/reason/evidence,
// recomputes tier/expiry, returns Pending, and audits old/new values while retaining the
// same row/history.
public async Task<ReviseUpliftResultDTO> ReviseUpliftAsync(VendorPortalSession session, int id, int requestId, decimal requestedNTE, string? reason, int? evidenceDocumentId, CancellationToken cancellationToken)
{
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken);
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
if (requestedNTE <= 0)
{
throw new InvalidOperationException("Requested NTE must be greater than zero");
}
var req = await _upliftData.GetByIdAndDispatchAsync(requestId, id, cancellationToken);
if (req == null) throw new KeyNotFoundException("Uplift request not found");
if (UpliftStatus.ToCanonical(req.Status) != UpliftStatus.ChangesRequested)
{
throw new InvalidOperationException($"Cannot revise a '{UpliftStatus.ToCanonical(req.Status)}' uplift request");
}
var current = dispatch.NTEAmount ?? 0m;
if (requestedNTE <= current)
{
throw new InvalidOperationException("Requested NTE must be greater than the current NTE");
}
if (string.IsNullOrWhiteSpace(reason))
{
throw new InvalidOperationException("A reason is required when revising an uplift request");
}
if (!evidenceDocumentId.HasValue)
{
throw new InvalidOperationException("Supporting evidence is required when revising an uplift request");
}
var evidence = await _documentData.GetUpliftEvidenceAsync(evidenceDocumentId.Value, id, session.Id, cancellationToken);
if (evidence == null)
{
throw new InvalidOperationException("The selected evidence document is not available or has not passed scanning");
}
var tier1Max = _approvalsOptions.UpliftTier1MaxUsd ?? 2500m;
var now = _timeProvider.GetUtcNow().UtcDateTime;
var oldRequested = req.RequestedNTE;
var oldReason = req.VendorReason;
var oldTier = req.RequiredTier;
req.RequestedNTE = requestedNTE;
req.VendorReason = reason.Trim();
req.EvidenceDocumentId = evidence.Id;
req.CurrentNTE = current;
req.RequiredTier = (requestedNTE - current) > tier1Max ? 2 : 1;
req.ExpiresAt = now + _approvalsOptions.EffectiveExpiration;
req.Status = UpliftStatus.Pending;
req.DecisionNote = null;
req.DecidedAt = null;
req.DecidedByUserId = null;
req.NotificationStatus = UpliftNotificationStatus.Pending;
req.NotificationError = null;
req.InitialNotificationSentAt = null;
req.EscalatedAt = null;
req.LastModificationTime = now;
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
OldValue = $"${oldRequested:F2} (Tier {oldTier})",
NewValue = $"${requestedNTE:F2} (Tier {req.RequiredTier})",
Action = "uplift_revised",
ActorType = "vendor",
CreatedAt = now
}, cancellationToken);
await _upliftData.SaveChangesAsync(cancellationToken);
await NotifyDispatcherOfUpliftAsync(dispatch, req, session, cancellationToken);
return new ReviseUpliftResultDTO
{
Id = req.Id,
Status = req.Status,
RequiredTier = req.RequiredTier,
CurrentNTE = req.CurrentNTE,
RequestedNTE = req.RequestedNTE,
EvidenceDocumentId = req.EvidenceDocumentId,
ExpiresAt = req.ExpiresAt,
NotificationStatus = req.NotificationStatus
};
}
public async Task<UploadCompletionDocumentResultDTO> UploadCompletionDocumentAsync(
VendorPortalSession session,
int dispatchId,
IFormFile file,
int? replacesDocumentId,
string? purpose,
CancellationToken cancellationToken)
{
if (file is null || file.Length == 0)
{
throw new InvalidOperationException("A completion document file is required.");
}
if (file.Length > _documentOptions.MaxSizeBytes)
{
throw new InvalidOperationException("The uploaded file exceeds the maximum allowed size.");
}
var contentType = (file.ContentType ?? string.Empty).Trim();
if (!AllowedContentTypes.Contains(contentType))
{
throw new InvalidOperationException("Only PDF, JPG, and PNG completion documents are accepted.");
}
var resolvedPurpose = string.IsNullOrWhiteSpace(purpose)
? VendorDocumentPurpose.Completion
: purpose.Trim();
if (!VendorDocumentPurpose.IsValid(resolvedPurpose))
{
throw new InvalidOperationException("The document purpose is not valid.");
}
// Uplift evidence is immutable supporting evidence: it never participates in completion
// replacement/version semantics.
if (resolvedPurpose == VendorDocumentPurpose.UpliftEvidence && replacesDocumentId.HasValue)
{
throw new InvalidOperationException("Uplift evidence documents cannot replace another document.");
}
using var buffer = new MemoryStream();
await file.CopyToAsync(buffer, cancellationToken);
var bytes = buffer.ToArray();
if (!MatchesSignature(contentType, bytes))
{
throw new InvalidOperationException("The uploaded file signature does not match its declared content type.");
}
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(dispatchId, session.Id, cancellationToken);
if (dispatch == null)
{
throw new KeyNotFoundException("Dispatch not found");
}
if (IsPortalLockedStatus(dispatch.Status))
{
throw new InvalidOperationException("Dispatch is locked");
}
var latest = await _documentData.GetLatestForDispatchAsync(dispatchId, cancellationToken);
var replacedDocument = latest;
if (replacesDocumentId.HasValue)
{
replacedDocument = await _documentData.GetForVendorDispatchAsync(
replacesDocumentId.Value,
dispatchId,
session.Id,
cancellationToken);
if (replacedDocument == null)
{
throw new InvalidOperationException(
"The completion document could not be replaced.");
}
}
var version = (latest?.Version ?? 0) + 1;
var storedFileName = $"{dispatchId}_{version}_{Guid.NewGuid():N}{GetSafeExtension(file.FileName)}";
var now = DateTime.UtcNow;
var document = new VendorCompletionDocument
{
VendorId = session.Id,
DispatchId = dispatchId,
WorkOrderId = dispatch.WorkOrderId ?? 0,
OriginalFileName = Path.GetFileName(file.FileName),
StoredFileName = storedFileName,
ContentType = contentType,
SizeBytes = bytes.Length,
ScanStatus = "Pending",
ReviewStatus = "Processing",
Version = version,
ReplacesDocumentId = replacedDocument?.Id,
Purpose = resolvedPurpose,
CreatedDate = now
};
await _documentData.AddAsync(document, cancellationToken);
var isUpliftEvidence = resolvedPurpose == VendorDocumentPurpose.UpliftEvidence;
var fieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document";
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
DispatchId = dispatchId,
FieldName = fieldName,
OldValue = isUpliftEvidence || replacedDocument == null
? null
: $"v{replacedDocument.Version}",
NewValue = isUpliftEvidence
? $"evidence {document.OriginalFileName}"
: $"v{version}",
Action = isUpliftEvidence
? "vendor_uplift_evidence_uploaded"
: "vendor_completion_document_uploaded",
ActorType = "vendor",
CreatedAt = now
}, cancellationToken);
await _documentData.SaveChangesAsync(cancellationToken);
using var stored = new MemoryStream(bytes);
await _documentStorage.SaveAsync(session.Id, dispatchId, storedFileName, stored, cancellationToken);
return new UploadCompletionDocumentResultDTO
{
Id = document.Id,
Version = document.Version,
ScanStatus = document.ScanStatus,
ReviewStatus = document.ReviewStatus,
Purpose = document.Purpose
};
}
public async Task<DownloadCompletionDocumentResultDTO> DownloadCompletionDocumentAsync(
VendorPortalSession session, int dispatchId, int documentId, CancellationToken cancellationToken)
{
var document = await _documentData.GetForVendorDispatchAsync(documentId, dispatchId, session.Id, cancellationToken);
if (document == null)
{
return DownloadCompletionDocumentResultDTO.NotFound();
}
if (!string.Equals(document.ScanStatus, "Passed", StringComparison.OrdinalIgnoreCase))
{
return DownloadCompletionDocumentResultDTO.Locked();
}
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(dispatchId, session.Id, cancellationToken);
if (dispatch != null && IsPortalLockedStatus(dispatch.Status))
{
return DownloadCompletionDocumentResultDTO.Locked();
}
var content = _documentStorage.OpenRead(session.Id, dispatchId, document.StoredFileName);
return DownloadCompletionDocumentResultDTO.Ok(content, document.ContentType, document.OriginalFileName);
}
public async Task<VendorDocumentStatusDTO?> GetDocumentStatusAsync(
VendorPortalSession session, int dispatchId, int documentId, CancellationToken cancellationToken)
{
var document = await _documentData.GetMetadataForVendorDispatchAsync(
documentId, dispatchId, session.Id, cancellationToken);
if (document == null)
{
return null;
}
return new VendorDocumentStatusDTO
{
Id = document.Id,
OriginalFileName = document.OriginalFileName,
ScanStatus = document.ScanStatus,
ReviewStatus = document.ReviewStatus,
Purpose = document.Purpose
};
}
private static bool MatchesSignature(string contentType, byte[] bytes)
{
if (bytes.Length == 0)
{
return false;
}
if (contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase))
{
return bytes.Length >= 4
&& bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46; // %PDF
}
if (contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase))
{
return bytes.Length >= 8
&& bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47
&& bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A;
}
if (contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase)
|| contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase))
{
return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
}
return false;
}
private static string GetSafeExtension(string fileName)
{
var extension = Path.GetExtension(fileName);
return string.IsNullOrWhiteSpace(extension) ? string.Empty : extension;
}
private async Task NotifyDispatcherOfUpliftAsync(Dispatch dispatch, DispatchUpliftRequest req, VendorPortalSession session, CancellationToken cancellationToken)
{
var recipients = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var dispatcherUserId = await _dispatchData.GetDispatchDispatcherUserIdAsync(dispatch.WorkOrderId ?? 0, cancellationToken);
if (!string.IsNullOrWhiteSpace(dispatcherUserId))
{
var dispatcherEmail = await _userData.GetEmailByIdAsync(dispatcherUserId, cancellationToken);
if (!string.IsNullOrWhiteSpace(dispatcherEmail))
recipients.Add(dispatcherEmail);
}
// Tier recipients come from configured Approvals options; dedupe against the dispatcher.
var tierRecipients = req.RequiredTier == 2
? _approvalsOptions.Tier2NotificationRecipients
: _approvalsOptions.Tier1NotificationRecipients;
if (tierRecipients != null)
{
foreach (var recipient in tierRecipients)
{
if (!string.IsNullOrWhiteSpace(recipient))
recipients.Add(recipient.Trim());
}
}
if (recipients.Count == 0)
{
// No one to notify: record a safe persisted signal without leaking internal detail.
req.NotificationStatus = UpliftNotificationStatus.Error;
req.NotificationError = "No notification recipients are configured for this dispatch.";
req.LastModificationTime = _timeProvider.GetUtcNow().UtcDateTime;
await _upliftData.SaveChangesAsync(cancellationToken);
return;
}
var frontendBase = _frontendOptions.FrontendBaseUrl ?? string.Empty;
var (subject, body) = UpliftNotificationMessage.BuildInitial(dispatch, req, session.CompanyName ?? "Vendor", frontendBase);
var now = _timeProvider.GetUtcNow().UtcDateTime;
try
{
foreach (var recipient in recipients)
{
var delivered = await _emailSender.SendEmailAsync(recipient, subject, body);
if (!delivered)
throw new InvalidOperationException("The notification provider reported a delivery failure.");
}
req.InitialNotificationSentAt = now;
req.NotificationStatus = UpliftNotificationStatus.Sent;
req.NotificationError = null;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception)
{
// Persist a safe signal; never store exception text or addresses.
req.NotificationStatus = UpliftNotificationStatus.Error;
req.NotificationError = "The uplift notification could not be delivered.";
}
req.LastModificationTime = now;
await _upliftData.SaveChangesAsync(cancellationToken);
}
private static bool IsAllowedVendorTransition(string? from, string? to)
{
if (from == "Acknowledged" && to == "In Progress") return true;
if (from == "In Progress" && to == "Completed") return true;
return false;
}
private static bool IsPortalLockedStatus(string? status)
=> status is "Verified" or "Cancelled" or "Canceled" or "Refused";
}
}