mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 10:43:13 +00:00
1196 lines
55 KiB
C#
1196 lines
55 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.Exceptions;
|
|
using SeaHaven.Services.Helpers;
|
|
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", "image/heic",
|
|
"video/mp4", "video/quicktime"
|
|
};
|
|
|
|
// The browser's File.type is unreliable on mobile (empty or application/octet-stream),
|
|
// so an extension pairing against the same allowlist resolves the real content type.
|
|
private static string? ResolveUploadContentType(IFormFile file)
|
|
{
|
|
var declaredType = (file.ContentType ?? string.Empty).Trim();
|
|
var extension = Path.GetExtension(file.FileName ?? string.Empty);
|
|
// iOS transcodes a picked HEIC photo to JPEG (named .jpg) but can keep image/heic.
|
|
var isTranscodedHeic = declaredType.Equals("image/heic", StringComparison.OrdinalIgnoreCase)
|
|
&& !extension.Equals(".heic", StringComparison.OrdinalIgnoreCase);
|
|
if (AllowedContentTypes.Contains(declaredType) && !isTranscodedHeic)
|
|
return declaredType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase)
|
|
? "image/jpeg"
|
|
: declaredType;
|
|
|
|
return extension.ToLowerInvariant() switch
|
|
{
|
|
".pdf" => "application/pdf",
|
|
".jpg" or ".jpeg" => "image/jpeg",
|
|
".png" => "image/png",
|
|
".heic" => "image/heic",
|
|
".mp4" => "video/mp4",
|
|
".mov" => "video/quicktime",
|
|
_ => null
|
|
};
|
|
}
|
|
|
|
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,
|
|
RaisedByVendor = u.RaisedByVendor,
|
|
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
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
PortalMediaCountsDTO? mediaCounts = null;
|
|
if (dispatch.WorkOrderId is int workOrderId)
|
|
{
|
|
// Same basis as the upload check: a new completion version replaces the
|
|
// dispatch's latest document, so that one is not counted for it.
|
|
var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync(
|
|
workOrderId, cancellationToken);
|
|
var vendorTypes = await _documentData.ListActiveContentTypesForWorkOrderAsync(
|
|
workOrderId, null, cancellationToken);
|
|
var latest = await _documentData.GetLatestForDispatchAsync(id, cancellationToken);
|
|
var completionTypes = latest == null
|
|
? vendorTypes
|
|
: await _documentData.ListActiveContentTypesForWorkOrderAsync(
|
|
workOrderId, latest.Id, cancellationToken);
|
|
var (photos, videos) = WorkOrderMediaContract.CountKinds(attachmentUrls, vendorTypes);
|
|
var (completionPhotos, completionVideos) =
|
|
WorkOrderMediaContract.CountKinds(attachmentUrls, completionTypes);
|
|
mediaCounts = new PortalMediaCountsDTO
|
|
{
|
|
MaxPhotos = WorkOrderMediaContract.MaxPhotosPerWorkOrder,
|
|
MaxVideos = WorkOrderMediaContract.MaxVideosPerWorkOrder,
|
|
Photos = photos,
|
|
Videos = videos,
|
|
CompletionPhotos = completionPhotos,
|
|
CompletionVideos = completionVideos
|
|
};
|
|
}
|
|
|
|
return new VendorDispatchDetailDTO
|
|
{
|
|
MediaCounts = mediaCounts,
|
|
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);
|
|
|
|
try
|
|
{
|
|
await _upliftData.SaveChangesAsync(cancellationToken);
|
|
}
|
|
catch (SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException)
|
|
{
|
|
throw new UpliftConflictException();
|
|
}
|
|
|
|
// 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);
|
|
// A vendor withdraws only requests it raised; requests raised from the work order
|
|
// (createdby set) are read-only in the portal.
|
|
if (req == null || req.createdby != 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);
|
|
// A vendor revises only requests it raised. Work-order requests (createdby set)
|
|
// store the increase, not a total, so a vendor revise would corrupt their amount.
|
|
if (req == null || req.createdby != 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.");
|
|
}
|
|
|
|
// Media contract: photos up to 10 MB (JPEG/PNG/HEIC), videos up to 100 MB
|
|
// (MP4/MOV). Documents keep the configured VendorDocuments cap.
|
|
var contentType = ResolveUploadContentType(file);
|
|
if (contentType == null)
|
|
{
|
|
throw new InvalidOperationException("Only PDF, JPG, PNG, HEIC, MP4, and MOV files are accepted.");
|
|
}
|
|
|
|
// Classify by the resolved type the signature check validates, never by the file name.
|
|
var kind = WorkOrderMediaContract.ResolveKind(contentType, fileName: null);
|
|
if (kind == WorkOrderMediaContract.UploadKind.Photo
|
|
&& file.Length > WorkOrderMediaContract.MaxPhotoBytes)
|
|
{
|
|
throw new InvalidOperationException("Photos must be 10 MB or smaller.");
|
|
}
|
|
if (kind == WorkOrderMediaContract.UploadKind.Video
|
|
&& file.Length > WorkOrderMediaContract.MaxVideoBytes)
|
|
{
|
|
throw new InvalidOperationException("Videos must be 100 MB or smaller.");
|
|
}
|
|
if (kind != WorkOrderMediaContract.UploadKind.Photo
|
|
&& kind != WorkOrderMediaContract.UploadKind.Video
|
|
&& file.Length > _documentOptions.MaxSizeBytes)
|
|
{
|
|
throw new InvalidOperationException("The uploaded file exceeds the maximum allowed size.");
|
|
}
|
|
|
|
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 (!WorkOrderMediaFileRules.MatchesSignature(contentType, bytes))
|
|
{
|
|
throw new InvalidOperationException("The uploaded file signature does not match its declared content type.");
|
|
}
|
|
|
|
if (kind == WorkOrderMediaContract.UploadKind.Video)
|
|
{
|
|
using var content = new MemoryStream(bytes, writable: false);
|
|
var durationMessage = WorkOrderMediaContract.ValidateVideoDuration(content);
|
|
if (durationMessage != null)
|
|
{
|
|
throw new InvalidOperationException(durationMessage);
|
|
}
|
|
}
|
|
|
|
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 isUpliftEvidence = resolvedPurpose == VendorDocumentPurpose.UpliftEvidence;
|
|
|
|
async Task<VendorCompletionDocument> PersistAsync(CancellationToken ct)
|
|
{
|
|
// The 10-photo / 3-video limit is per work order across the dispatcher and
|
|
// vendor surfaces. A new completion version replaces its predecessor, so that one
|
|
// does not count against the upload that supersedes it.
|
|
if (dispatch.WorkOrderId is int workOrderId)
|
|
{
|
|
var excluded = resolvedPurpose == VendorDocumentPurpose.Completion
|
|
? replacedDocument?.Id
|
|
: null;
|
|
var vendorTypes = await _documentData.ListActiveContentTypesForWorkOrderAsync(
|
|
workOrderId, excluded, ct);
|
|
var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync(
|
|
workOrderId, ct);
|
|
var countMessage = WorkOrderMediaContract.ValidateCount(kind, attachmentUrls, vendorTypes);
|
|
if (countMessage != null)
|
|
{
|
|
throw new InvalidOperationException(countMessage);
|
|
}
|
|
}
|
|
|
|
var now = DateTime.UtcNow;
|
|
var created = 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(created, ct);
|
|
|
|
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 {created.OriginalFileName}"
|
|
: $"v{version}",
|
|
Action = isUpliftEvidence
|
|
? "vendor_uplift_evidence_uploaded"
|
|
: "vendor_completion_document_uploaded",
|
|
ActorType = "vendor",
|
|
CreatedAt = now
|
|
}, ct);
|
|
await _documentData.SaveChangesAsync(ct);
|
|
return created;
|
|
}
|
|
|
|
// The count and the insert run under the per-work-order mutation lock the dispatcher
|
|
// media upload also takes, so concurrent uploads cannot both claim the last slot.
|
|
var document = dispatch.WorkOrderId is int lockedWorkOrderId
|
|
? await _upliftData.ExecuteWorkOrderMutationAsync(lockedWorkOrderId, PersistAsync, cancellationToken)
|
|
: await PersistAsync(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 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";
|
|
}
|
|
}
|