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
469 lines
26 KiB
C#
469 lines
26 KiB
C#
using Data.SeaHavenIndustries;
|
|
using Data.SeaHavenIndustries.Enums;
|
|
using SeaHaven.DataServices.Interfaces;
|
|
using SeaHaven.DataServices.Models;
|
|
using SeaHaven.Services.DTOs;
|
|
using SeaHaven.Services.Interfaces;
|
|
|
|
namespace SeaHaven.Services.Implementation
|
|
{
|
|
public class VendorOperationsService : IVendorOperationsService
|
|
{
|
|
private static readonly string[] ValidAvailabilityStatuses = { "Available", "Unavailable", "Unknown" };
|
|
private static readonly string[] InvoiceStatuses = { "Not Submitted", "Processing", "Confirmed", "Rejected" };
|
|
private static readonly string[] PaymentStatuses = { "Unavailable", "Pending", "Scheduled", "Paid", "On Hold" };
|
|
|
|
private readonly IVendorOperationsDataService _data;
|
|
private readonly TimeProvider _timeProvider;
|
|
|
|
public VendorOperationsService(IVendorOperationsDataService data, TimeProvider timeProvider)
|
|
{
|
|
_data = data;
|
|
_timeProvider = timeProvider;
|
|
}
|
|
|
|
public DateTime UtcNow => _timeProvider.GetUtcNow().UtcDateTime;
|
|
|
|
public async Task<VendorNotificationsResult> GetNotificationsAsync(CancellationToken cancellationToken)
|
|
{
|
|
var now = UtcNow;
|
|
var cutoff = now.AddHours(48);
|
|
|
|
var assignedWorkOrderIds = await _data.GetAssignedWorkOrderIdsAsync(cancellationToken);
|
|
var candidates = await _data.GetNoVendorWorkOrdersAsync(cutoff, assignedWorkOrderIds, cancellationToken);
|
|
|
|
var noVendor = candidates.Select(wo => new NoVendorNotification
|
|
{
|
|
Id = $"no-vendor-{wo.Id}",
|
|
Type = "NoVendor",
|
|
Severity = "Medium",
|
|
Group = "No Vendor",
|
|
Title = "No vendor assigned — 48h to service",
|
|
WorkOrderId = wo.Id,
|
|
WorkOrderNumber = wo.InternalWONumber ?? wo.WorkerOrderNumber,
|
|
TriggeredAt = wo.ScheduledDate!.Value.AddHours(-48),
|
|
ScheduledAt = wo.ScheduledDate!.Value,
|
|
Action = new NotificationActionDto { Label = "Choose vendor", Href = $"/workorders/{wo.Id}?assignVendor=1" }
|
|
}).Cast<VendorNotificationItem>().ToList();
|
|
|
|
var dispatches = await _data.GetActiveScheduledDispatchesAsync(cancellationToken);
|
|
var conflicts = BuildConflictPairs(dispatches)
|
|
.Select(pair => (VendorNotificationItem)new VendorConflictNotification
|
|
{
|
|
Id = $"vendor-conflict-{pair.Left.Id}-{pair.Right.Id}",
|
|
Type = "VendorConflict",
|
|
Severity = "Medium",
|
|
Group = "Vendor Conflict",
|
|
Title = "Vendor has overlapping appointments",
|
|
VendorId = pair.Left.VendorId,
|
|
VendorName = pair.Left.VendorName,
|
|
WorkOrders = new[]
|
|
{
|
|
new ConflictWorkOrderDto { WorkOrderId = pair.Left.WorkOrderId, WorkOrderNumber = pair.Left.WorkOrderNumber, Start = pair.Left.Start, End = pair.Left.End },
|
|
new ConflictWorkOrderDto { WorkOrderId = pair.Right.WorkOrderId, WorkOrderNumber = pair.Right.WorkOrderNumber, Start = pair.Right.Start, End = pair.Right.End }
|
|
},
|
|
TriggeredAt = pair.Left.Start < pair.Right.Start ? pair.Left.Start : pair.Right.Start,
|
|
Actions = new[]
|
|
{
|
|
new NotificationActionDto { Label = pair.Left.WorkOrderNumber ?? "Open work order", Href = $"/workorders/{pair.Left.WorkOrderId}" },
|
|
new NotificationActionDto { Label = pair.Right.WorkOrderNumber ?? "Open work order", Href = $"/workorders/{pair.Right.WorkOrderId}" }
|
|
}
|
|
})
|
|
.ToList();
|
|
|
|
var items = noVendor.Concat(conflicts).OrderBy(item => item.TriggeredAt).ToList();
|
|
return new VendorNotificationsResult { GeneratedAt = now, Items = items };
|
|
}
|
|
|
|
public async Task<VendorOperationOutcome<VendorAvailabilityResult>> GetAvailabilityAsync(
|
|
DateTime? start, DateTime? end, int? locationId, string? trade, CancellationToken cancellationToken)
|
|
{
|
|
var now = UtcNow;
|
|
var requestedStart = start ?? now;
|
|
var requestedEnd = end ?? requestedStart.AddHours(2);
|
|
if (requestedEnd <= requestedStart)
|
|
return VendorOperationOutcome<VendorAvailabilityResult>.BadRequest("end must be after start");
|
|
|
|
var vendors = await _data.GetActiveVendorsAsync(cancellationToken);
|
|
var preferences = locationId.HasValue
|
|
? await _data.GetSitePreferredVendorsAsync(locationId.Value, cancellationToken)
|
|
: new List<SitePreferredVendor>();
|
|
var dispatches = await _data.GetActiveScheduledDispatchesAsync(cancellationToken);
|
|
|
|
var result = vendors.Select(vendor =>
|
|
{
|
|
var matchingDispatches = dispatches
|
|
.Where(dispatch => dispatch.VendorId == vendor.Id
|
|
&& WindowsOverlap(requestedStart, requestedEnd, dispatch.Start, dispatch.End))
|
|
.ToList();
|
|
var matchingPreference = preferences
|
|
.Where(preference => preference.VendorId == vendor.Id
|
|
&& (string.IsNullOrWhiteSpace(preference.Trade)
|
|
|| string.Equals(preference.Trade, trade, StringComparison.OrdinalIgnoreCase)))
|
|
.OrderBy(preference => preference.SortOrder)
|
|
.FirstOrDefault();
|
|
var signalIsFresh = vendor.AvailabilityUpdatedAt.HasValue
|
|
&& vendor.AvailabilityUpdatedAt.Value >= now.AddHours(-24);
|
|
var status = matchingDispatches.Count > 0
|
|
? "Unavailable"
|
|
: signalIsFresh && ValidAvailabilityStatuses.Contains(vendor.AvailabilityStatus ?? "")
|
|
? vendor.AvailabilityStatus
|
|
: "Unknown";
|
|
|
|
return new VendorAvailabilityRow
|
|
{
|
|
Id = vendor.Id,
|
|
CompanyName = vendor.CompanyName,
|
|
ContactName = vendor.ContactName,
|
|
TradeSpecialties = vendor.TradeSpecialties,
|
|
AvailabilityStatus = status,
|
|
AvailabilityUpdatedAt = vendor.AvailabilityUpdatedAt,
|
|
IsPreferred = matchingPreference != null,
|
|
PreferenceRank = matchingPreference?.SortOrder,
|
|
Conflicts = matchingDispatches.Select(dispatch => new AvailabilityConflictDto
|
|
{
|
|
WorkOrderId = dispatch.WorkOrderId,
|
|
WorkOrderNumber = dispatch.WorkOrderNumber,
|
|
Start = dispatch.Start,
|
|
End = dispatch.End
|
|
}).ToList()
|
|
};
|
|
})
|
|
.OrderBy(vendor => vendor.IsPreferred ? 0 : 1)
|
|
.ThenBy(vendor => vendor.PreferenceRank ?? int.MaxValue)
|
|
.ThenBy(vendor => vendor.AvailabilityStatus == "Available" ? 0
|
|
: vendor.AvailabilityStatus == "Unknown" ? 1 : 2)
|
|
.ThenBy(vendor => vendor.CompanyName)
|
|
.ToList();
|
|
|
|
return VendorOperationOutcome<VendorAvailabilityResult>.Ok(
|
|
new VendorAvailabilityResult { GeneratedAt = now, StaleAfterHours = 24, Data = result });
|
|
}
|
|
|
|
public async Task<VendorOperationOutcome<VendorAvailabilityUpdateResult>> UpdateAvailabilityAsync(
|
|
int vendorId, string? status, string actor, CancellationToken cancellationToken)
|
|
{
|
|
var normalized = status?.Trim();
|
|
if (normalized is not ("Available" or "Unavailable" or "Unknown"))
|
|
return VendorOperationOutcome<VendorAvailabilityUpdateResult>.BadRequest("status must be Available, Unavailable, or Unknown");
|
|
|
|
var vendor = await _data.GetVendorForUpdateAsync(vendorId, cancellationToken);
|
|
if (vendor == null)
|
|
return VendorOperationOutcome<VendorAvailabilityUpdateResult>.NotFound();
|
|
|
|
var now = UtcNow;
|
|
var previous = vendor.AvailabilityStatus;
|
|
vendor.AvailabilityStatus = normalized;
|
|
vendor.AvailabilityUpdatedAt = now;
|
|
vendor.LastModificationTime = now;
|
|
await _data.StageVendorAuditAsync(new VendorAuditLog
|
|
{
|
|
VendorId = vendorId,
|
|
FieldName = nameof(Vendor.AvailabilityStatus),
|
|
OldValue = previous,
|
|
NewValue = normalized,
|
|
Actor = actor,
|
|
CreatedAt = now
|
|
}, cancellationToken);
|
|
await _data.SaveChangesAsync(cancellationToken);
|
|
return VendorOperationOutcome<VendorAvailabilityUpdateResult>.Ok(
|
|
new VendorAvailabilityUpdateResult { Id = vendor.Id, AvailabilityStatus = vendor.AvailabilityStatus, AvailabilityUpdatedAt = vendor.AvailabilityUpdatedAt });
|
|
}
|
|
|
|
public async Task<IReadOnlyList<SitePreferenceRow>> GetSitePreferencesAsync(int locationId, CancellationToken cancellationToken)
|
|
{
|
|
var preferences = await _data.GetSitePreferredVendorsAsync(locationId, cancellationToken);
|
|
return preferences.Select(p => new SitePreferenceRow
|
|
{
|
|
Id = p.Id,
|
|
LocationId = p.LocationId,
|
|
VendorId = p.VendorId,
|
|
VendorName = p.Vendor!.CompanyName,
|
|
Trade = p.Trade,
|
|
SortOrder = p.SortOrder,
|
|
IsStale = !p.Vendor.IsActive || (p.Vendor.IsDeleted ?? false),
|
|
LastModificationTime = p.LastModificationTime,
|
|
UpdatedBy = p.UpdatedBy
|
|
}).ToList();
|
|
}
|
|
|
|
public async Task<VendorOperationOutcome<IReadOnlyList<SitePreferenceRow>>> ReplaceSitePreferencesAsync(
|
|
int locationId, IReadOnlyList<SitePreferenceRequest> body, string actor, CancellationToken cancellationToken)
|
|
{
|
|
if (!await _data.LocationExistsAsync(locationId, cancellationToken))
|
|
return VendorOperationOutcome<IReadOnlyList<SitePreferenceRow>>.NotFound("Location not found");
|
|
if (body.GroupBy(item => new { item.VendorId, Trade = item.Trade?.Trim().ToLowerInvariant() }).Any(group => group.Count() > 1))
|
|
return VendorOperationOutcome<IReadOnlyList<SitePreferenceRow>>.BadRequest("Duplicate vendor/trade preference");
|
|
|
|
var vendorIds = body.Select(item => item.VendorId).Distinct().ToList();
|
|
var vendors = await _data.GetVendorsByIdsAsync(vendorIds, cancellationToken);
|
|
foreach (var item in body)
|
|
{
|
|
if (!vendors.TryGetValue(item.VendorId, out var vendor) || !vendor.IsActive || (vendor.IsDeleted ?? false))
|
|
return VendorOperationOutcome<IReadOnlyList<SitePreferenceRow>>.BadRequest($"Vendor {item.VendorId} is inactive or missing");
|
|
if (!string.IsNullOrWhiteSpace(item.Trade)
|
|
&& !(vendor.TradeSpecialties ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
|
|
.Contains(item.Trade.Trim(), StringComparer.OrdinalIgnoreCase))
|
|
return VendorOperationOutcome<IReadOnlyList<SitePreferenceRow>>.BadRequest($"Vendor {item.VendorId} does not support trade '{item.Trade}'");
|
|
}
|
|
|
|
var now = UtcNow;
|
|
var existing = await _data.GetSitePreferredVendorsForReplaceAsync(locationId, cancellationToken);
|
|
await _data.RemoveSitePreferredVendorsAsync(existing, cancellationToken);
|
|
foreach (var (item, index) in body.Select((item, index) => (item, index)))
|
|
{
|
|
await _data.AddSitePreferredVendorAsync(new SitePreferredVendor
|
|
{
|
|
LocationId = locationId,
|
|
VendorId = item.VendorId,
|
|
Trade = string.IsNullOrWhiteSpace(item.Trade) ? null : item.Trade.Trim(),
|
|
SortOrder = item.SortOrder ?? index,
|
|
CreatedDate = now,
|
|
LastModificationTime = now,
|
|
createdby = actor,
|
|
UpdatedBy = actor
|
|
}, cancellationToken);
|
|
}
|
|
foreach (var vendorId in existing.Select(p => p.VendorId).Concat(vendorIds).Distinct())
|
|
{
|
|
await _data.StageVendorAuditAsync(new VendorAuditLog
|
|
{
|
|
VendorId = vendorId,
|
|
FieldName = $"SitePreference:{locationId}",
|
|
OldValue = string.Join(",", existing.Where(p => p.VendorId == vendorId).Select(p => $"{p.Trade}:{p.SortOrder}")),
|
|
NewValue = string.Join(",", body.Where(p => p.VendorId == vendorId).Select(p => $"{p.Trade}:{p.SortOrder}")),
|
|
Actor = actor,
|
|
CreatedAt = now
|
|
}, cancellationToken);
|
|
}
|
|
await _data.SaveChangesAsync(cancellationToken);
|
|
return VendorOperationOutcome<IReadOnlyList<SitePreferenceRow>>.Ok(await GetSitePreferencesAsync(locationId, cancellationToken));
|
|
}
|
|
|
|
public async Task<VendorOperationOutcome<DocumentReviewResult>> ReviewDocumentAsync(
|
|
int documentId, DocumentReviewRequest body, string actor, CancellationToken cancellationToken)
|
|
{
|
|
if (body.ReviewStatus is not ("Approved" or "Rejected"))
|
|
return VendorOperationOutcome<DocumentReviewResult>.BadRequest("reviewStatus must be Approved or Rejected");
|
|
if (body.ReviewStatus == "Rejected" && string.IsNullOrWhiteSpace(body.RejectionReason))
|
|
return VendorOperationOutcome<DocumentReviewResult>.BadRequest("A rejection reason is required");
|
|
|
|
var document = await _data.GetCompletionDocumentForUpdateAsync(documentId, cancellationToken);
|
|
if (document == null)
|
|
return VendorOperationOutcome<DocumentReviewResult>.NotFound();
|
|
if (document.ScanStatus != "Passed")
|
|
return VendorOperationOutcome<DocumentReviewResult>.Conflict("A document cannot be approved or reviewed until malware scanning passes");
|
|
|
|
var now = UtcNow;
|
|
var previous = document.ReviewStatus;
|
|
document.ReviewStatus = body.ReviewStatus;
|
|
document.RejectionReason = body.ReviewStatus == "Rejected" ? body.RejectionReason?.Trim() : null;
|
|
document.ReviewedAt = now;
|
|
document.LastModificationTime = now;
|
|
await _data.StageWorkOrderAuditAsync(new WorkOrderAuditLog
|
|
{
|
|
WorkOrderId = document.WorkOrderId,
|
|
UserId = actor,
|
|
FieldName = $"Completion Document {document.OriginalFileName}",
|
|
OldValue = previous,
|
|
NewValue = document.ReviewStatus,
|
|
Action = "completion_document_reviewed",
|
|
CreatedAt = now
|
|
}, cancellationToken);
|
|
await _data.SaveChangesAsync(cancellationToken);
|
|
return VendorOperationOutcome<DocumentReviewResult>.Ok(
|
|
new DocumentReviewResult { Id = document.Id, ReviewStatus = document.ReviewStatus, RejectionReason = document.RejectionReason, ReviewedAt = document.ReviewedAt });
|
|
}
|
|
|
|
public async Task<VendorOperationOutcome<CommercialStatusResult>> UpdateCommercialStatusAsync(
|
|
int dispatchId, CommercialStatusRequest body, string actor, CancellationToken cancellationToken)
|
|
{
|
|
if (!InvoiceStatuses.Contains(body.InvoiceStatus))
|
|
return VendorOperationOutcome<CommercialStatusResult>.BadRequest("Invalid invoice status");
|
|
if (!PaymentStatuses.Contains(body.PaymentStatus))
|
|
return VendorOperationOutcome<CommercialStatusResult>.BadRequest("Invalid payment status");
|
|
|
|
var dispatch = await _data.GetDispatchForUpdateAsync(dispatchId, cancellationToken);
|
|
if (dispatch == null)
|
|
return VendorOperationOutcome<CommercialStatusResult>.NotFound();
|
|
var now = UtcNow;
|
|
var oldValue = $"{dispatch.InvoiceStatus}|{dispatch.PaymentStatus}|{dispatch.InvoiceNumber}";
|
|
dispatch.InvoiceStatus = body.InvoiceStatus;
|
|
dispatch.PaymentStatus = body.PaymentStatus;
|
|
dispatch.InvoiceNumber = string.IsNullOrWhiteSpace(body.InvoiceNumber) ? null : body.InvoiceNumber.Trim();
|
|
dispatch.CommercialStatusUpdatedAt = now;
|
|
dispatch.LastModificationTime = now;
|
|
await _data.StageWorkOrderAuditAsync(new WorkOrderAuditLog
|
|
{
|
|
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
|
UserId = actor,
|
|
FieldName = $"Dispatch {dispatch.DispatchNumber} Commercial Status",
|
|
OldValue = oldValue,
|
|
NewValue = $"{dispatch.InvoiceStatus}|{dispatch.PaymentStatus}|{dispatch.InvoiceNumber}",
|
|
Action = "commercial_status_updated",
|
|
CreatedAt = now
|
|
}, cancellationToken);
|
|
await _data.SaveChangesAsync(cancellationToken);
|
|
return VendorOperationOutcome<CommercialStatusResult>.Ok(
|
|
new CommercialStatusResult
|
|
{
|
|
Id = dispatch.Id,
|
|
PONumber = dispatch.PONumber,
|
|
InvoiceNumber = dispatch.InvoiceNumber,
|
|
InvoiceStatus = dispatch.InvoiceStatus,
|
|
PaymentStatus = dispatch.PaymentStatus,
|
|
CommercialStatusUpdatedAt = dispatch.CommercialStatusUpdatedAt
|
|
});
|
|
}
|
|
|
|
public async Task<VendorOperationOutcome<VendorAssignmentResult>> AssignVendorAsync(
|
|
int workOrderId, VendorAssignmentRequest body, string actor, CancellationToken cancellationToken)
|
|
{
|
|
var workOrder = await _data.GetWorkOrderForUpdateAsync(workOrderId, cancellationToken);
|
|
if (workOrder == null)
|
|
return VendorOperationOutcome<VendorAssignmentResult>.NotFound("Work order not found");
|
|
var vendor = await _data.GetVendorForUpdateAsync(body.VendorId, cancellationToken);
|
|
if (vendor == null || !vendor.IsActive || (vendor.IsDeleted ?? false))
|
|
return VendorOperationOutcome<VendorAssignmentResult>.BadRequest("Only an active vendor can be assigned");
|
|
|
|
var start = body.ScheduledStart ?? workOrder.ScheduledStart ?? workOrder.ScheduledDate;
|
|
var end = body.ScheduledEnd ?? workOrder.ScheduledEnd ?? start?.AddHours(2);
|
|
var activeDispatches = start.HasValue && end.HasValue
|
|
? (await _data.GetActiveScheduledDispatchesAsync(cancellationToken))
|
|
.Where(dispatch => dispatch.VendorId == vendor.Id && dispatch.WorkOrderId != workOrderId).ToList()
|
|
: new List<ScheduledDispatchReadModel>();
|
|
var conflicts = start.HasValue && end.HasValue
|
|
? activeDispatches.Where(dispatch => WindowsOverlap(start.Value, end.Value, dispatch.Start, dispatch.End)).ToList()
|
|
: new List<ScheduledDispatchReadModel>();
|
|
|
|
var dispatch = await _data.GetExistingActiveDispatchForWorkOrderAsync(workOrderId, cancellationToken);
|
|
var now = UtcNow;
|
|
var previousVendorId = dispatch?.VendorId;
|
|
if (dispatch == null)
|
|
{
|
|
dispatch = new Dispatch
|
|
{
|
|
WorkOrderId = workOrderId,
|
|
VendorId = vendor.Id,
|
|
DispatchNumber = await _data.GenerateNextDispatchNumberAsync(cancellationToken),
|
|
PONumber = await _data.GenerateNextPONumberAsync(cancellationToken),
|
|
Status = "Sent",
|
|
ScheduledDate = start,
|
|
EstimatedArrivalAt = body.EstimatedArrivalAt,
|
|
EtaManualOverride = body.EtaManualOverride,
|
|
DispatchedAt = now,
|
|
CreatedDate = now,
|
|
createdby = actor
|
|
};
|
|
await _data.AddDispatchAsync(dispatch, cancellationToken);
|
|
}
|
|
else
|
|
{
|
|
dispatch.VendorId = vendor.Id;
|
|
dispatch.ScheduledDate = start;
|
|
dispatch.EstimatedArrivalAt = body.EstimatedArrivalAt;
|
|
dispatch.EtaManualOverride = body.EtaManualOverride;
|
|
dispatch.LastModificationTime = now;
|
|
}
|
|
|
|
workOrder.ScheduledStart = body.ScheduledStart ?? workOrder.ScheduledStart;
|
|
workOrder.ScheduledEnd = body.ScheduledEnd ?? workOrder.ScheduledEnd;
|
|
workOrder.ScheduledDate = start ?? workOrder.ScheduledDate;
|
|
workOrder.LastModificationTime = now;
|
|
await _data.StageWorkOrderAuditAsync(new WorkOrderAuditLog
|
|
{
|
|
WorkOrderId = workOrderId,
|
|
UserId = actor,
|
|
FieldName = "Vendor Assignment",
|
|
OldValue = previousVendorId?.ToString(),
|
|
NewValue = vendor.Id.ToString(),
|
|
Action = previousVendorId.HasValue ? "vendor_assignment_changed" : "vendor_assigned",
|
|
CreatedAt = now
|
|
}, cancellationToken);
|
|
await _data.SaveChangesAsync(cancellationToken);
|
|
|
|
return VendorOperationOutcome<VendorAssignmentResult>.Ok(new VendorAssignmentResult
|
|
{
|
|
Id = dispatch.Id,
|
|
VendorId = dispatch.VendorId,
|
|
VendorName = vendor.CompanyName,
|
|
EstimatedArrivalAt = dispatch.EstimatedArrivalAt,
|
|
EtaManualOverride = dispatch.EtaManualOverride,
|
|
AvailabilityStatus = conflicts.Count > 0 ? "Unavailable"
|
|
: vendor.AvailabilityUpdatedAt >= now.AddHours(-24) ? vendor.AvailabilityStatus : "Unknown",
|
|
Conflicts = conflicts.Select(conflict => new AvailabilityConflictDto
|
|
{
|
|
WorkOrderId = conflict.WorkOrderId,
|
|
WorkOrderNumber = conflict.WorkOrderNumber,
|
|
Start = conflict.Start,
|
|
End = conflict.End
|
|
}).ToList()
|
|
});
|
|
}
|
|
|
|
public async Task<IReadOnlyList<VendorInsightRow>> GetInsightRowsAsync(
|
|
DateTime? from, DateTime? to, int? locationId, string? trade, int? vendorId, CancellationToken cancellationToken)
|
|
{
|
|
var dispatches = await _data.GetInsightDispatchDataAsync(from, to, locationId, trade, vendorId, cancellationToken);
|
|
return dispatches.GroupBy(dispatch => dispatch.VendorId)
|
|
.Select(group =>
|
|
{
|
|
var completed = group.Where(dispatch => dispatch.Status is "Completed" or "Verified").ToList();
|
|
var canceled = group.Count(dispatch => dispatch.Status is "Cancelled" or "Canceled" or "Refused");
|
|
var acknowledgedWithSchedule = group.Where(dispatch => dispatch.AcknowledgedAt.HasValue && dispatch.ScheduledDate.HasValue).ToList();
|
|
var completedWithDue = completed.Where(dispatch => dispatch.CompletedDate.HasValue && dispatch.DueDate != null).ToList();
|
|
var cycleRows = completed.Where(dispatch => dispatch.CompletedDate.HasValue && (dispatch.DispatchedAt ?? dispatch.CreatedDate).HasValue).ToList();
|
|
return new VendorInsightRow
|
|
{
|
|
VendorId = group.Key,
|
|
Vendor = group.First().VendorName ?? $"Vendor {group.Key}",
|
|
CompletedJobs = completed.Count,
|
|
CancellationRefusalRate = Rate(canceled, group.Count()),
|
|
OnTimeArrivalRate = Rate(
|
|
acknowledgedWithSchedule.Count(dispatch => dispatch.AcknowledgedAt <= dispatch.ScheduledDate),
|
|
acknowledgedWithSchedule.Count),
|
|
OnTimeCompletionRate = Rate(
|
|
completedWithDue.Count(dispatch => dispatch.CompletedDate <= dispatch.DueDate),
|
|
completedWithDue.Count),
|
|
AverageCycleHours = cycleRows.Count == 0 ? null : cycleRows.Average(dispatch =>
|
|
(dispatch.CompletedDate!.Value - (dispatch.DispatchedAt ?? dispatch.CreatedDate)!.Value).TotalHours)
|
|
};
|
|
})
|
|
.OrderBy(row => row.Vendor)
|
|
.ToList();
|
|
}
|
|
|
|
// Group by vendor so cross-vendor intervals are never compared, then sweep the
|
|
// start-sorted windows within a vendor. Every overlapping pair is still emitted;
|
|
// output is O(n log n + p) where p is the number of emitted pairs, so the worst
|
|
// case stays quadratic only when a single vendor has many mutually-overlapping
|
|
// windows, which is inherent to enumerating all such pairs.
|
|
private static IEnumerable<(ScheduledDispatchReadModel Left, ScheduledDispatchReadModel Right)> BuildConflictPairs(
|
|
IReadOnlyList<ScheduledDispatchReadModel> dispatches)
|
|
{
|
|
foreach (var group in dispatches.GroupBy(dispatch => dispatch.VendorId))
|
|
{
|
|
var sorted = group.OrderBy(dispatch => dispatch.Start).ToList();
|
|
for (var leftIndex = 0; leftIndex < sorted.Count; leftIndex++)
|
|
{
|
|
var left = sorted[leftIndex];
|
|
for (var rightIndex = leftIndex + 1; rightIndex < sorted.Count; rightIndex++)
|
|
{
|
|
var right = sorted[rightIndex];
|
|
// Sorted by start: once a window starts at or after left's end, none that follow can overlap it.
|
|
if (right.Start >= left.End)
|
|
break;
|
|
if (left.WorkOrderId != right.WorkOrderId)
|
|
yield return (left, right);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static bool WindowsOverlap(DateTime leftStart, DateTime leftEnd, DateTime rightStart, DateTime rightEnd)
|
|
=> leftStart < rightEnd && rightStart < leftEnd;
|
|
|
|
private static decimal Rate(int numerator, int denominator)
|
|
=> denominator == 0 ? 0 : Math.Round((decimal)numerator / denominator, 4);
|
|
}
|
|
}
|