mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 13:03:12 +00:00
* refactor(api): enforce service and data-service boundaries * refactor(api): complete feature service boundaries * refactor(identity): enforce service and data boundaries * refactor(vendors): enforce service and data boundaries * refactor(workorders): enforce service and data boundaries * refactor(backend): enforce architecture and optimize dispatch * style(backend): format changed architecture files * fix(architecture): address backend review follow-ups * fix(backend): sanitize exception disclosure in changed API endpoints Replace raw exception-message disclosure (ex.Message) returned to API callers with a stable sanitized public message plus correlated structured internal logging, across the endpoints changed in this PR. - Add SanitizedErrors helper: logs the original exception at Error with a generated correlation id and returns a stable public message referencing it so support can trace without exposing internals. - Inject ILogger<T> into the 14 changed controllers and route every ex.Message/dbex.Message disclosure through the helper, preserving status codes, response shapes, and business data (e.g. OpenWorkOrders). - Leave FluentValidation (vex.Errors) and existing fixed-message catches untouched; out-of-scope controllers (Account/Contact/Employee/Asset/ PMSchedule) are unchanged. - Add focused tests proving internal exception text is not returned and that Error logging carrying the original exception is invoked. * fix(architecture): abstract job run state access * style: format board update service * test: use collection assertion idiom
703 lines
32 KiB
C#
703 lines
32 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 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;
|
|
|
|
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)
|
|
{
|
|
_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;
|
|
}
|
|
|
|
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 upliftRequests = rawUplifts.Select(u => new PortalUpliftDTO
|
|
{
|
|
Id = u.Id,
|
|
CurrentNTE = u.CurrentNTE,
|
|
RequestedNTE = u.RequestedNTE,
|
|
VendorReason = u.VendorReason,
|
|
Status = 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()
|
|
}).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();
|
|
|
|
return new VendorDispatchDetailDTO
|
|
{
|
|
Id = dispatch.Id,
|
|
DispatchNumber = dispatch.DispatchNumber,
|
|
PONumber = dispatch.PONumber,
|
|
Status = dispatch.Status,
|
|
NTEAmount = dispatch.NTEAmount,
|
|
Description = dispatch.Description,
|
|
ScheduledDate = dispatch.ScheduledDate,
|
|
CompletedDate = dispatch.CompletedDate,
|
|
DispatchedAt = dispatch.DispatchedAt,
|
|
AcknowledgedAt = dispatch.AcknowledgedAt,
|
|
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,
|
|
Problem = dispatch.WorkOrder.Problem
|
|
},
|
|
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
|
|
},
|
|
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
|
|
};
|
|
}
|
|
|
|
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<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 (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")
|
|
{
|
|
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 (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")
|
|
{
|
|
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, CancellationToken cancellationToken)
|
|
{
|
|
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken);
|
|
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
|
|
|
|
if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")
|
|
{
|
|
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 current = dispatch.NTEAmount ?? 0m;
|
|
if (requestedNTE <= current)
|
|
{
|
|
throw new InvalidOperationException("Requested NTE must be greater than the current NTE");
|
|
}
|
|
|
|
var pendingExists = await _upliftData.HasPendingAsync(id, cancellationToken);
|
|
if (pendingExists)
|
|
{
|
|
throw new InvalidOperationException("A pending uplift request already exists for this dispatch");
|
|
}
|
|
|
|
var tier1Max = _approvalsOptions.UpliftTier1MaxUsd ?? 2500m;
|
|
var delta = requestedNTE - current;
|
|
var requiredTier = delta > tier1Max ? 2 : 1;
|
|
|
|
var now = DateTime.UtcNow;
|
|
var req = new DispatchUpliftRequest
|
|
{
|
|
DispatchId = id,
|
|
CurrentNTE = current,
|
|
RequestedNTE = requestedNTE,
|
|
VendorReason = string.IsNullOrWhiteSpace(reason) ? null : reason!.Trim(),
|
|
Status = "Pending",
|
|
RequiredTier = requiredTier,
|
|
RequestedByVendorName = session.CompanyName,
|
|
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",
|
|
CreatedAt = now
|
|
}, cancellationToken);
|
|
|
|
await _upliftData.SaveChangesAsync(cancellationToken);
|
|
|
|
await NotifyDispatcherOfUpliftAsync(dispatch, req, session, cancellationToken);
|
|
|
|
return new UpliftRequestResultDTO
|
|
{
|
|
Id = req.Id,
|
|
Status = req.Status,
|
|
RequiredTier = req.RequiredTier,
|
|
CurrentNTE = req.CurrentNTE,
|
|
RequestedNTE = req.RequestedNTE
|
|
};
|
|
}
|
|
|
|
public async Task<CancelUpliftResultDTO> CancelUpliftRequestAsync(VendorPortalSession session, int id, int requestId, 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 (req.Status != "Pending")
|
|
{
|
|
throw new InvalidOperationException($"Cannot cancel a '{req.Status}' uplift request");
|
|
}
|
|
|
|
var now = DateTime.UtcNow;
|
|
req.Status = "Cancelled";
|
|
req.DecidedAt = now;
|
|
req.LastModificationTime = now;
|
|
|
|
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
|
{
|
|
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
|
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
|
|
OldValue = "Pending",
|
|
NewValue = "Cancelled",
|
|
Action = "uplift_cancelled",
|
|
CreatedAt = now
|
|
}, cancellationToken);
|
|
|
|
await _upliftData.SaveChangesAsync(cancellationToken);
|
|
return new CancelUpliftResultDTO { Id = req.Id, Status = req.Status };
|
|
}
|
|
|
|
public async Task<UploadCompletionDocumentResultDTO> UploadCompletionDocumentAsync(
|
|
VendorPortalSession session, int dispatchId, IFormFile file, 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.");
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
var latest = await _documentData.GetLatestForDispatchAsync(dispatchId, cancellationToken);
|
|
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 = latest?.Id,
|
|
CreatedDate = now
|
|
};
|
|
|
|
await _documentData.AddAsync(document, cancellationToken);
|
|
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
|
{
|
|
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
|
DispatchId = dispatchId,
|
|
FieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document",
|
|
OldValue = latest == null ? null : $"v{latest.Version}",
|
|
NewValue = $"v{version}",
|
|
Action = "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
|
|
};
|
|
}
|
|
|
|
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 && (dispatch.Status == "Verified" || dispatch.Status == "Cancelled"))
|
|
{
|
|
return DownloadCompletionDocumentResultDTO.Locked();
|
|
}
|
|
|
|
var content = _documentStorage.OpenRead(session.Id, dispatchId, document.StoredFileName);
|
|
return DownloadCompletionDocumentResultDTO.Ok(content, document.ContentType, document.OriginalFileName);
|
|
}
|
|
|
|
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)
|
|
{
|
|
try
|
|
{
|
|
var dispatcherUserId = await _dispatchData.GetDispatchDispatcherUserIdAsync(dispatch.WorkOrderId ?? 0, cancellationToken);
|
|
if (string.IsNullOrWhiteSpace(dispatcherUserId)) return;
|
|
|
|
var dispatcherEmail = await _userData.GetEmailByIdAsync(dispatcherUserId, cancellationToken);
|
|
if (string.IsNullOrWhiteSpace(dispatcherEmail)) return;
|
|
|
|
var frontendBase = _frontendOptions.FrontendBaseUrl?.TrimEnd('/') ?? "";
|
|
var workOrderLink = dispatch.WorkOrderId.HasValue
|
|
? $"{frontendBase}/workorders/{dispatch.WorkOrderId.Value}"
|
|
: frontendBase;
|
|
|
|
var vendorName = System.Net.WebUtility.HtmlEncode(session.CompanyName ?? "Vendor");
|
|
var reason = System.Net.WebUtility.HtmlEncode(req.VendorReason ?? "(no reason provided)");
|
|
var dispatchNum = System.Net.WebUtility.HtmlEncode(dispatch.DispatchNumber ?? "");
|
|
var tierText = req.RequiredTier == 2 ? "Tier 2 (Manager approval required)" : "Tier 1";
|
|
|
|
var subject = $"[Uplift Request] {dispatch.DispatchNumber} — {session.CompanyName} requests ${req.RequestedNTE:F2} (was ${req.CurrentNTE ?? 0m:F2})";
|
|
var body = $@"
|
|
<h2>Vendor Uplift Request</h2>
|
|
<p><strong>Dispatch:</strong> {dispatchNum}</p>
|
|
<p><strong>Vendor:</strong> {vendorName}</p>
|
|
<table style='border-collapse:collapse;font-family:Arial,sans-serif;'>
|
|
<tr><td style='padding:4px 10px;'><strong>Current NTE</strong></td><td style='padding:4px 10px;'>${req.CurrentNTE ?? 0m:F2}</td></tr>
|
|
<tr><td style='padding:4px 10px;'><strong>Requested NTE</strong></td><td style='padding:4px 10px;'>${req.RequestedNTE:F2}</td></tr>
|
|
<tr><td style='padding:4px 10px;'><strong>Delta</strong></td><td style='padding:4px 10px;'>${(req.RequestedNTE - (req.CurrentNTE ?? 0m)):F2}</td></tr>
|
|
<tr><td style='padding:4px 10px;'><strong>Required Approval</strong></td><td style='padding:4px 10px;'>{tierText}</td></tr>
|
|
</table>
|
|
<h3>Vendor Reason</h3>
|
|
<p>{reason}</p>
|
|
<div style='margin:20px 0;'>
|
|
<a href='{workOrderLink}' style='display:inline-block;padding:10px 22px;background:#2563eb;color:white;text-decoration:none;border-radius:6px;font-weight:bold;'>Review in SHOC</a>
|
|
</div>";
|
|
|
|
await _emailSender.SendEmailAsync(dispatcherEmail, subject, body);
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|