using System.Security.Claims; using System.Text; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Interfaces; namespace Api.SeaHavenIndustries.Controllers { [Authorize] [ApiController] [Route("api/vendor-operations")] public class VendorOperationsController : ControllerBase { private static readonly string[] TerminalDispatchStatuses = { "Completed", "Verified", "Cancelled", "Canceled" }; private readonly ApplicationDbContext _db; private readonly IDispatchDataService _dispatchDataService; private readonly TimeProvider _timeProvider; public VendorOperationsController( ApplicationDbContext db, IDispatchDataService dispatchDataService, TimeProvider timeProvider) { _db = db; _dispatchDataService = dispatchDataService; _timeProvider = timeProvider; } [HttpGet("notifications")] public async Task GetNotifications() { var now = _timeProvider.GetUtcNow().UtcDateTime; var cutoff = now.AddHours(48); var directlyAssignedIds = await _db.Dispatches .AsNoTracking() .Where(d => !TerminalDispatchStatuses.Contains(d.Status ?? "") && d.WorkOrderId.HasValue) .Select(d => d.WorkOrderId!.Value) .ToListAsync(); var multiplyAssignedIds = await _db.DispatchWorkOrders .AsNoTracking() .Where(link => link.Dispatch != null && !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "")) .Select(link => link.WorkOrderId) .ToListAsync(); var assignedWorkOrderIds = directlyAssignedIds.Concat(multiplyAssignedIds).Distinct().ToList(); var noVendor = await _db.workOrders .AsNoTracking() .Where(wo => wo.ScheduledDate.HasValue && wo.ScheduledDate.Value <= cutoff && wo.LifecycleStatus != LifecycleStatus.Completed && wo.LifecycleStatus != LifecycleStatus.Canceled && !assignedWorkOrderIds.Contains(wo.Id)) .OrderBy(wo => wo.ScheduledDate) .Select(wo => new { 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, Action = new { Label = "Choose vendor", Href = $"/workorders/{wo.Id}?assignVendor=1" } }) .ToListAsync(); var dispatches = await ActiveScheduledDispatches().ToListAsync(); var conflicts = BuildConflictPairs(dispatches) .Select(pair => new { 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 { pair.Left.WorkOrderId, pair.Left.WorkOrderNumber, pair.Left.Start, pair.Left.End }, new { pair.Right.WorkOrderId, pair.Right.WorkOrderNumber, pair.Right.Start, pair.Right.End } }, TriggeredAt = pair.Left.Start < pair.Right.Start ? pair.Left.Start : pair.Right.Start, Actions = new[] { new { Label = pair.Left.WorkOrderNumber ?? "Open work order", Href = $"/workorders/{pair.Left.WorkOrderId}" }, new { Label = pair.Right.WorkOrderNumber ?? "Open work order", Href = $"/workorders/{pair.Right.WorkOrderId}" } } }) .ToList(); return Ok(new { GeneratedAt = now, Items = noVendor.Cast().Concat(conflicts).OrderBy(item => item.GetType().GetProperty("TriggeredAt")?.GetValue(item)).ToList() }); } [HttpGet("availability")] public async Task GetAvailability( [FromQuery] DateTime? start, [FromQuery] DateTime? end, [FromQuery] int? locationId, [FromQuery] string? trade) { var now = _timeProvider.GetUtcNow().UtcDateTime; var requestedStart = start ?? now; var requestedEnd = end ?? requestedStart.AddHours(2); if (requestedEnd <= requestedStart) return BadRequest(new Response { Status = "Error", Message = "end must be after start" }); var vendors = await _db.Vendors.AsNoTracking() .Where(v => v.IsActive && (v.IsDeleted == null || v.IsDeleted == false)) .ToListAsync(); var preferences = locationId.HasValue ? await _db.SitePreferredVendors.AsNoTracking() .Where(p => p.LocationId == locationId.Value) .ToListAsync() : new List(); var dispatches = await ActiveScheduledDispatches().ToListAsync(); 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 && new[] { "Available", "Unavailable" }.Contains(vendor.AvailabilityStatus) ? vendor.AvailabilityStatus : "Unknown"; return new { vendor.Id, vendor.CompanyName, vendor.ContactName, vendor.TradeSpecialties, AvailabilityStatus = status, vendor.AvailabilityUpdatedAt, IsPreferred = matchingPreference != null, PreferenceRank = matchingPreference?.SortOrder, Conflicts = matchingDispatches.Select(dispatch => new { dispatch.WorkOrderId, dispatch.WorkOrderNumber, dispatch.Start, dispatch.End }) }; }) .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 Ok(new { GeneratedAt = now, StaleAfterHours = 24, Data = result }); } [HttpPut("vendors/{vendorId:int}/availability")] public async Task UpdateAvailability(int vendorId, [FromBody] AvailabilityRequest body) { var normalized = body.Status?.Trim(); if (normalized is not ("Available" or "Unavailable" or "Unknown")) return BadRequest(new Response { Status = "Error", Message = "status must be Available, Unavailable, or Unknown" }); var vendor = await _db.Vendors.FindAsync(vendorId); if (vendor == null) return NotFound(); var now = _timeProvider.GetUtcNow().UtcDateTime; var actor = Actor(); var previous = vendor.AvailabilityStatus; vendor.AvailabilityStatus = normalized; vendor.AvailabilityUpdatedAt = now; vendor.LastModificationTime = now; _db.VendorAuditLogs.Add(new VendorAuditLog { VendorId = vendorId, FieldName = nameof(Vendor.AvailabilityStatus), OldValue = previous, NewValue = normalized, Actor = actor, CreatedAt = now }); await _db.SaveChangesAsync(); return Ok(new { vendor.Id, vendor.AvailabilityStatus, vendor.AvailabilityUpdatedAt }); } [HttpGet("sites/{locationId:int}/preferred-vendors")] public async Task GetSitePreferences(int locationId) { var preferences = await _db.SitePreferredVendors .AsNoTracking() .Where(p => p.LocationId == locationId) .Include(p => p.Vendor) .OrderBy(p => p.SortOrder) .Select(p => new { p.Id, p.LocationId, p.VendorId, VendorName = p.Vendor!.CompanyName, p.Trade, p.SortOrder, IsStale = !p.Vendor.IsActive || (p.Vendor.IsDeleted ?? false), p.LastModificationTime, p.UpdatedBy }) .ToListAsync(); return Ok(preferences); } [HttpPut("sites/{locationId:int}/preferred-vendors")] public async Task ReplaceSitePreferences(int locationId, [FromBody] List body) { if (!await _db.Locations.AnyAsync(location => location.Id == locationId)) return NotFound(new Response { Status = "Error", Message = "Location not found" }); if (body.GroupBy(item => new { item.VendorId, Trade = item.Trade?.Trim().ToLowerInvariant() }).Any(group => group.Count() > 1)) return BadRequest(new Response { Status = "Error", Message = "Duplicate vendor/trade preference" }); var vendorIds = body.Select(item => item.VendorId).Distinct().ToList(); var vendors = await _db.Vendors.Where(vendor => vendorIds.Contains(vendor.Id)).ToDictionaryAsync(vendor => vendor.Id); foreach (var item in body) { if (!vendors.TryGetValue(item.VendorId, out var vendor) || !vendor.IsActive || (vendor.IsDeleted ?? false)) return BadRequest(new Response { Status = "Error", Message = $"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 BadRequest(new Response { Status = "Error", Message = $"Vendor {item.VendorId} does not support trade '{item.Trade}'" }); } var now = _timeProvider.GetUtcNow().UtcDateTime; var actor = Actor(); var existing = await _db.SitePreferredVendors.Where(p => p.LocationId == locationId).ToListAsync(); _db.SitePreferredVendors.RemoveRange(existing); _db.SitePreferredVendors.AddRange(body.Select((item, index) => 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 })); foreach (var vendorId in existing.Select(p => p.VendorId).Concat(vendorIds).Distinct()) { _db.VendorAuditLogs.Add(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 }); } await _db.SaveChangesAsync(); return await GetSitePreferences(locationId); } [HttpPut("documents/{documentId:int}/review")] public async Task ReviewDocument(int documentId, [FromBody] DocumentReviewRequest body) { if (body.ReviewStatus is not ("Approved" or "Rejected")) return BadRequest(new Response { Status = "Error", Message = "reviewStatus must be Approved or Rejected" }); if (body.ReviewStatus == "Rejected" && string.IsNullOrWhiteSpace(body.RejectionReason)) return BadRequest(new Response { Status = "Error", Message = "A rejection reason is required" }); var document = await _db.VendorCompletionDocuments.FindAsync(documentId); if (document == null) return NotFound(); if (document.ScanStatus != "Passed") return Conflict(new Response { Status = "Conflict", Message = "A document cannot be approved or reviewed until malware scanning passes" }); var now = _timeProvider.GetUtcNow().UtcDateTime; var previous = document.ReviewStatus; document.ReviewStatus = body.ReviewStatus; document.RejectionReason = body.ReviewStatus == "Rejected" ? body.RejectionReason?.Trim() : null; document.ReviewedAt = now; document.LastModificationTime = now; _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog { WorkOrderId = document.WorkOrderId, UserId = Actor(), FieldName = $"Completion Document {document.OriginalFileName}", OldValue = previous, NewValue = document.ReviewStatus, Action = "completion_document_reviewed", CreatedAt = now }); await _db.SaveChangesAsync(); return Ok(new { document.Id, document.ReviewStatus, document.RejectionReason, document.ReviewedAt }); } [HttpPut("dispatches/{dispatchId:int}/commercial-status")] public async Task UpdateCommercialStatus(int dispatchId, [FromBody] CommercialStatusRequest body) { var invoiceStatuses = new[] { "Not Submitted", "Processing", "Confirmed", "Rejected" }; var paymentStatuses = new[] { "Unavailable", "Pending", "Scheduled", "Paid", "On Hold" }; if (!invoiceStatuses.Contains(body.InvoiceStatus)) return BadRequest(new Response { Status = "Error", Message = "Invalid invoice status" }); if (!paymentStatuses.Contains(body.PaymentStatus)) return BadRequest(new Response { Status = "Error", Message = "Invalid payment status" }); var dispatch = await _db.Dispatches.FindAsync(dispatchId); if (dispatch == null) return NotFound(); var now = _timeProvider.GetUtcNow().UtcDateTime; 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; _db.WorkOrderAuditLogs.Add(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 }); await _db.SaveChangesAsync(); return Ok(new { dispatch.Id, dispatch.PONumber, dispatch.InvoiceNumber, dispatch.InvoiceStatus, dispatch.PaymentStatus, dispatch.CommercialStatusUpdatedAt }); } [HttpPut("work-orders/{workOrderId:int}/assignment")] public async Task AssignVendor(int workOrderId, [FromBody] VendorAssignmentRequest body) { var workOrder = await _db.workOrders.FindAsync(workOrderId); if (workOrder == null) return NotFound(new Response { Status = "Error", Message = "Work order not found" }); var vendor = await _db.Vendors.FindAsync(body.VendorId); if (vendor == null || !vendor.IsActive || (vendor.IsDeleted ?? false)) return BadRequest(new Response { Status = "Error", Message = "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 scheduledDispatches = start.HasValue && end.HasValue ? await ActiveScheduledDispatches() .Where(dispatch => dispatch.VendorId == vendor.Id && dispatch.WorkOrderId != workOrderId) .ToListAsync() : new List(); var conflicts = start.HasValue && end.HasValue ? scheduledDispatches.Where(dispatch => WindowsOverlap(start.Value, end.Value, dispatch.Start, dispatch.End)).ToList() : new List(); var linkedDispatchIds = _db.DispatchWorkOrders .Where(link => link.WorkOrderId == workOrderId) .Select(link => link.DispatchId); var dispatch = await _db.Dispatches .Where(item => (item.WorkOrderId == workOrderId || linkedDispatchIds.Contains(item.Id)) && !TerminalDispatchStatuses.Contains(item.Status ?? "")) .OrderByDescending(item => item.CreatedDate) .FirstOrDefaultAsync(); var now = _timeProvider.GetUtcNow().UtcDateTime; var previousVendorId = dispatch?.VendorId; if (dispatch == null) { dispatch = new Dispatch { WorkOrderId = workOrderId, VendorId = vendor.Id, DispatchNumber = await _dispatchDataService.GenerateNextDispatchNumberAsync(), PONumber = await _dispatchDataService.GenerateNextPONumberAsync(), Status = "Sent", ScheduledDate = start, EstimatedArrivalAt = body.EstimatedArrivalAt, EtaManualOverride = body.EtaManualOverride, DispatchedAt = now, CreatedDate = now, createdby = Actor() }; _db.Dispatches.Add(dispatch); } 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; _db.WorkOrderAuditLogs.Add(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 }); await _db.SaveChangesAsync(); return Ok(new { dispatch.Id, dispatch.VendorId, VendorName = vendor.CompanyName, dispatch.EstimatedArrivalAt, dispatch.EtaManualOverride, AvailabilityStatus = conflicts.Count > 0 ? "Unavailable" : vendor.AvailabilityUpdatedAt >= now.AddHours(-24) ? vendor.AvailabilityStatus : "Unknown", Conflicts = conflicts.Select(conflict => new { conflict.WorkOrderId, conflict.WorkOrderNumber, conflict.Start, conflict.End }) }); } [HttpGet("insights")] public async Task GetInsights( [FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] int? locationId, [FromQuery] string? trade, [FromQuery] int? vendorId) { var rows = await BuildInsightRows(from, to, locationId, trade, vendorId); return Ok(new { GeneratedAt = _timeProvider.GetUtcNow().UtcDateTime, Filters = new { from, to, locationId, trade, vendorId }, MetricDefinitions = MetricDefinitions(), Data = rows }); } [HttpGet("insights.csv")] public async Task ExportInsightsCsv( [FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] int? locationId, [FromQuery] string? trade, [FromQuery] int? vendorId) { var rows = await BuildInsightRows(from, to, locationId, trade, vendorId); var csv = new StringBuilder("VendorId,Vendor,CompletedJobs,CancellationRefusalRate,OnTimeArrivalRate,OnTimeCompletionRate,AverageCycleHours\n"); foreach (var row in rows) { csv.AppendLine(string.Join(",", row.VendorId, Csv(row.Vendor), row.CompletedJobs, row.CancellationRefusalRate.ToString("0.####"), row.OnTimeArrivalRate.ToString("0.####"), row.OnTimeCompletionRate.ToString("0.####"), row.AverageCycleHours?.ToString("0.##") ?? "")); } return File(Encoding.UTF8.GetBytes(csv.ToString()), "text/csv", $"vendor-insights-{DateTime.UtcNow:yyyyMMddHHmmss}.csv"); } [HttpGet("insights.pdf")] public async Task ExportInsightsPdf( [FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] int? locationId, [FromQuery] string? trade, [FromQuery] int? vendorId) { var rows = await BuildInsightRows(from, to, locationId, trade, vendorId); var lines = new List { "Vendor Performance Insights", $"Generated: {_timeProvider.GetUtcNow():u}", $"Filters: from={from:u}; to={to:u}; site={locationId}; trade={trade}; vendor={vendorId}", "Definitions: completed jobs; cancellation/refusal rate; on-time acknowledgement; on-time completion; average dispatch-to-completion hours." }; lines.AddRange(rows.Take(35).Select(row => $"{row.Vendor}: completed {row.CompletedJobs}; cancel/refusal {row.CancellationRefusalRate:P1}; arrival {row.OnTimeArrivalRate:P1}; completion {row.OnTimeCompletionRate:P1}; cycle {row.AverageCycleHours:0.0}h")); return File(SimplePdf(lines), "application/pdf", $"vendor-insights-{DateTime.UtcNow:yyyyMMddHHmmss}.pdf"); } private IQueryable ActiveScheduledDispatches() { var direct = _db.Dispatches.AsNoTracking() .Where(dispatch => !TerminalDispatchStatuses.Contains(dispatch.Status ?? "") && (dispatch.IsDeleted == null || dispatch.IsDeleted == false) && dispatch.WorkOrderId.HasValue && (dispatch.ScheduledDate.HasValue || (dispatch.WorkOrder != null && dispatch.WorkOrder.ScheduledStart.HasValue))) .Select(dispatch => new ScheduledDispatch { Id = dispatch.Id, VendorId = dispatch.VendorId, VendorName = dispatch.Vendor!.CompanyName, WorkOrderId = dispatch.WorkOrderId!.Value, WorkOrderNumber = dispatch.WorkOrder!.InternalWONumber ?? dispatch.WorkOrder.WorkerOrderNumber, Start = dispatch.WorkOrder!.ScheduledStart ?? dispatch.ScheduledDate!.Value, End = dispatch.WorkOrder.ScheduledEnd ?? (dispatch.WorkOrder.ScheduledStart ?? dispatch.ScheduledDate!.Value).AddHours(2) }); var linked = _db.DispatchWorkOrders.AsNoTracking() .Where(link => link.Dispatch != null && link.WorkOrder != null && link.Dispatch.WorkOrderId != link.WorkOrderId && !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "") && (link.Dispatch.IsDeleted == null || link.Dispatch.IsDeleted == false) && (link.Dispatch.ScheduledDate.HasValue || link.WorkOrder.ScheduledStart.HasValue)) .Select(link => new ScheduledDispatch { Id = link.DispatchId, VendorId = link.Dispatch!.VendorId, VendorName = link.Dispatch.Vendor!.CompanyName, WorkOrderId = link.WorkOrderId, WorkOrderNumber = link.WorkOrder!.InternalWONumber ?? link.WorkOrder.WorkerOrderNumber, Start = link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value, End = link.WorkOrder.ScheduledEnd ?? (link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value).AddHours(2) }); return direct.Concat(linked); } private static IEnumerable<(ScheduledDispatch Left, ScheduledDispatch Right)> BuildConflictPairs(List dispatches) { for (var leftIndex = 0; leftIndex < dispatches.Count; leftIndex++) { for (var rightIndex = leftIndex + 1; rightIndex < dispatches.Count; rightIndex++) { var left = dispatches[leftIndex]; var right = dispatches[rightIndex]; if (left.VendorId == right.VendorId && left.WorkOrderId != right.WorkOrderId && WindowsOverlap(left.Start, left.End, right.Start, right.End)) yield return (left, right); } } } private async Task> BuildInsightRows( DateTime? from, DateTime? to, int? locationId, string? trade, int? vendorId) { var query = _db.Dispatches.AsNoTracking() .Include(dispatch => dispatch.Vendor) .Include(dispatch => dispatch.WorkOrder) .Where(dispatch => dispatch.Vendor != null); if (from.HasValue) query = query.Where(dispatch => (dispatch.DispatchedAt ?? dispatch.CreatedDate) >= from); if (to.HasValue) query = query.Where(dispatch => (dispatch.DispatchedAt ?? dispatch.CreatedDate) <= to); if (locationId.HasValue) query = query.Where(dispatch => dispatch.WorkOrder!.LocationId == locationId); if (!string.IsNullOrWhiteSpace(trade)) query = query.Where(dispatch => dispatch.WorkOrder!.Trade == trade); if (vendorId.HasValue) query = query.Where(dispatch => dispatch.VendorId == vendorId); var dispatches = await query.ToListAsync(); 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.WorkOrder?.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().Vendor?.CompanyName ?? $"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.WorkOrder!.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(); } private string Actor() => User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.Identity?.Name ?? "unknown"; 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); private static string Csv(string value) { var spreadsheetSafe = value.Length > 0 && "=+-@\t\r".Contains(value[0]) ? $"'{value}" : value; return $"\"{spreadsheetSafe.Replace("\"", "\"\"")}\""; } private static object MetricDefinitions() => new { CompletedJobs = "Dispatches with Completed or Verified status.", CancellationRefusalRate = "Cancelled, Canceled, or Refused dispatches divided by all dispatches.", OnTimeArrivalRate = "Acknowledgements at or before scheduled time divided by dispatches with both timestamps.", OnTimeCompletionRate = "Completions at or before the work-order due date divided by completed dispatches with a due date.", AverageCycleHours = "Average elapsed hours from dispatch to completion." }; private static byte[] SimplePdf(IEnumerable lines) { var escaped = lines.Select(line => line.Replace("\\", "\\\\").Replace("(", "\\(").Replace(")", "\\)")); var content = "BT /F1 10 Tf 40 760 Td 13 TL " + string.Join(" T* ", escaped.Select(line => $"({line}) Tj")) + " ET"; var objects = new[] { "<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>", $"<< /Length {Encoding.ASCII.GetByteCount(content)} >>\nstream\n{content}\nendstream", "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>" }; var builder = new StringBuilder("%PDF-1.4\n"); var offsets = new List { 0 }; for (var index = 0; index < objects.Length; index++) { offsets.Add(Encoding.ASCII.GetByteCount(builder.ToString())); builder.Append($"{index + 1} 0 obj\n{objects[index]}\nendobj\n"); } var xrefOffset = Encoding.ASCII.GetByteCount(builder.ToString()); builder.Append($"xref\n0 {objects.Length + 1}\n0000000000 65535 f \n"); foreach (var offset in offsets.Skip(1)) builder.Append($"{offset:D10} 00000 n \n"); builder.Append($"trailer << /Size {objects.Length + 1} /Root 1 0 R >>\nstartxref\n{xrefOffset}\n%%EOF"); return Encoding.ASCII.GetBytes(builder.ToString()); } public sealed class AvailabilityRequest { public string? Status { get; set; } } public sealed class SitePreferenceRequest { public int VendorId { get; set; } public string? Trade { get; set; } public int? SortOrder { get; set; } } public sealed class DocumentReviewRequest { public string ReviewStatus { get; set; } = ""; public string? RejectionReason { get; set; } } public sealed class CommercialStatusRequest { public string InvoiceStatus { get; set; } = "Not Submitted"; public string PaymentStatus { get; set; } = "Unavailable"; public string? InvoiceNumber { get; set; } } public sealed class VendorAssignmentRequest { public int VendorId { get; set; } public DateTime? ScheduledStart { get; set; } public DateTime? ScheduledEnd { get; set; } public DateTime? EstimatedArrivalAt { get; set; } public bool EtaManualOverride { get; set; } } private sealed class ScheduledDispatch { public int Id { get; set; } public int VendorId { get; set; } public string? VendorName { get; set; } public int WorkOrderId { get; set; } public string? WorkOrderNumber { get; set; } public DateTime Start { get; set; } public DateTime End { get; set; } } private sealed class VendorInsightRow { public int VendorId { get; set; } public string Vendor { get; set; } = ""; public int CompletedJobs { get; set; } public decimal CancellationRefusalRate { get; set; } public decimal OnTimeArrivalRate { get; set; } public decimal OnTimeCompletionRate { get; set; } public double? AverageCycleHours { get; set; } } } }