2026-07-24 11:12:00 -03:00
|
|
|
using Data.SeaHavenIndustries;
|
2026-07-24 12:07:48 -03:00
|
|
|
using Microsoft.Extensions.Options;
|
2026-07-24 11:12:00 -03:00
|
|
|
using SeaHaven.DataServices.Interfaces;
|
2026-07-24 12:07:48 -03:00
|
|
|
using SeaHaven.Services.Configuration;
|
2026-07-24 11:12:00 -03:00
|
|
|
using SeaHaven.Services.DTOs;
|
|
|
|
|
using SeaHaven.Services.Interfaces;
|
|
|
|
|
|
|
|
|
|
namespace SeaHaven.Services.Implementation
|
|
|
|
|
{
|
|
|
|
|
public class VendorPortalService : IVendorPortalService
|
|
|
|
|
{
|
|
|
|
|
private readonly IVendorPortalTokenService _tokens;
|
|
|
|
|
private readonly IDispatchDataService _dispatchData;
|
|
|
|
|
private readonly IUpliftDataService _upliftData;
|
|
|
|
|
private readonly ICommentDataService _commentData;
|
|
|
|
|
private readonly IUserDataService _userData;
|
|
|
|
|
private readonly IEmailSender _emailSender;
|
2026-07-24 12:07:48 -03:00
|
|
|
private readonly FrontendOptions _frontendOptions;
|
|
|
|
|
private readonly ApprovalsOptions _approvalsOptions;
|
2026-07-24 11:12:00 -03:00
|
|
|
|
|
|
|
|
public VendorPortalService(
|
|
|
|
|
IVendorPortalTokenService tokens,
|
|
|
|
|
IDispatchDataService dispatchData,
|
|
|
|
|
IUpliftDataService upliftData,
|
|
|
|
|
ICommentDataService commentData,
|
|
|
|
|
IUserDataService userData,
|
|
|
|
|
IEmailSender emailSender,
|
2026-07-24 12:07:48 -03:00
|
|
|
IOptions<FrontendOptions> frontendOptions,
|
|
|
|
|
IOptions<ApprovalsOptions> approvalsOptions)
|
2026-07-24 11:12:00 -03:00
|
|
|
{
|
|
|
|
|
_tokens = tokens;
|
|
|
|
|
_dispatchData = dispatchData;
|
|
|
|
|
_upliftData = upliftData;
|
|
|
|
|
_commentData = commentData;
|
|
|
|
|
_userData = userData;
|
|
|
|
|
_emailSender = emailSender;
|
2026-07-24 12:07:48 -03:00
|
|
|
_frontendOptions = frontendOptions.Value;
|
|
|
|
|
_approvalsOptions = approvalsOptions.Value;
|
2026-07-24 11:12:00 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 12:07:48 -03:00
|
|
|
var tier1Max = _approvalsOptions.UpliftTier1MaxUsd ?? 2500m;
|
2026-07-24 11:12:00 -03:00
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
2026-07-24 12:07:48 -03:00
|
|
|
var frontendBase = _frontendOptions.FrontendBaseUrl?.TrimEnd('/') ?? "";
|
2026-07-24 11:12:00 -03:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|