mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-02 22:43:31 +00:00
Adds MediaCounts (limits, current photos/videos, and the counts a new completion version would see) so the portal can refuse an 11th photo or 4th video before uploading it. Uses the same count and replacement rule the upload check enforces.
1173 lines
54 KiB
C#
1173 lines
54 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.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,
|
|
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);
|
|
|
|
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.");
|
|
}
|
|
|
|
// SH-116 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.");
|
|
}
|
|
}
|
|
|
|
// SH-116: 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, cancellationToken);
|
|
var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync(
|
|
workOrderId, cancellationToken);
|
|
var countMessage = WorkOrderMediaContract.ValidateCount(kind, attachmentUrls, vendorTypes);
|
|
if (countMessage != null)
|
|
{
|
|
throw new InvalidOperationException(countMessage);
|
|
}
|
|
}
|
|
|
|
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 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";
|
|
}
|
|
}
|