shoc-backend/SeaHaven.Services/Implementation/VendorPortalService.cs

548 lines
25 KiB
C#

using Data.SeaHavenIndustries;
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 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 FrontendOptions _frontendOptions;
private readonly ApprovalsOptions _approvalsOptions;
public VendorPortalService(
IVendorPortalTokenService tokens,
IDispatchDataService dispatchData,
IUpliftDataService upliftData,
ICommentDataService commentData,
IUserDataService userData,
IEmailSender emailSender,
IOptions<FrontendOptions> frontendOptions,
IOptions<ApprovalsOptions> approvalsOptions)
{
_tokens = tokens;
_dispatchData = dispatchData;
_upliftData = upliftData;
_commentData = commentData;
_userData = userData;
_emailSender = emailSender;
_frontendOptions = frontendOptions.Value;
_approvalsOptions = approvalsOptions.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 };
}
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;
}
}
}