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 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().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> 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.BadRequest("end must be after start"); var vendors = await _data.GetActiveVendorsAsync(cancellationToken); var preferences = locationId.HasValue ? await _data.GetSitePreferredVendorsAsync(locationId.Value, cancellationToken) : new List(); 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.Ok( new VendorAvailabilityResult { GeneratedAt = now, StaleAfterHours = 24, Data = result }); } public async Task> UpdateAvailabilityAsync( int vendorId, string? status, string actor, CancellationToken cancellationToken) { var normalized = status?.Trim(); if (normalized is not ("Available" or "Unavailable" or "Unknown")) return VendorOperationOutcome.BadRequest("status must be Available, Unavailable, or Unknown"); var vendor = await _data.GetVendorForUpdateAsync(vendorId, cancellationToken); if (vendor == null) return VendorOperationOutcome.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.Ok( new VendorAvailabilityUpdateResult { Id = vendor.Id, AvailabilityStatus = vendor.AvailabilityStatus, AvailabilityUpdatedAt = vendor.AvailabilityUpdatedAt }); } public async Task> 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>> ReplaceSitePreferencesAsync( int locationId, IReadOnlyList body, string actor, CancellationToken cancellationToken) { if (!await _data.LocationExistsAsync(locationId, cancellationToken)) return VendorOperationOutcome>.NotFound("Location not found"); if (body.GroupBy(item => new { item.VendorId, Trade = item.Trade?.Trim().ToLowerInvariant() }).Any(group => group.Count() > 1)) return VendorOperationOutcome>.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>.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>.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>.Ok(await GetSitePreferencesAsync(locationId, cancellationToken)); } public async Task> ReviewDocumentAsync( int documentId, DocumentReviewRequest body, string actor, CancellationToken cancellationToken) { if (body.ReviewStatus is not ("Approved" or "Rejected")) return VendorOperationOutcome.BadRequest("reviewStatus must be Approved or Rejected"); if (body.ReviewStatus == "Rejected" && string.IsNullOrWhiteSpace(body.RejectionReason)) return VendorOperationOutcome.BadRequest("A rejection reason is required"); var document = await _data.GetCompletionDocumentForUpdateAsync(documentId, cancellationToken); if (document == null) return VendorOperationOutcome.NotFound(); if (document.ScanStatus != "Passed") return VendorOperationOutcome.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.Ok( new DocumentReviewResult { Id = document.Id, ReviewStatus = document.ReviewStatus, RejectionReason = document.RejectionReason, ReviewedAt = document.ReviewedAt }); } public async Task> UpdateCommercialStatusAsync( int dispatchId, CommercialStatusRequest body, string actor, CancellationToken cancellationToken) { if (!InvoiceStatuses.Contains(body.InvoiceStatus)) return VendorOperationOutcome.BadRequest("Invalid invoice status"); if (!PaymentStatuses.Contains(body.PaymentStatus)) return VendorOperationOutcome.BadRequest("Invalid payment status"); var dispatch = await _data.GetDispatchForUpdateAsync(dispatchId, cancellationToken); if (dispatch == null) return VendorOperationOutcome.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.Ok( new CommercialStatusResult { Id = dispatch.Id, PONumber = dispatch.PONumber, InvoiceNumber = dispatch.InvoiceNumber, InvoiceStatus = dispatch.InvoiceStatus, PaymentStatus = dispatch.PaymentStatus, CommercialStatusUpdatedAt = dispatch.CommercialStatusUpdatedAt }); } public async Task> AssignVendorAsync( int workOrderId, VendorAssignmentRequest body, string actor, CancellationToken cancellationToken) { var workOrder = await _data.GetWorkOrderForUpdateAsync(workOrderId, cancellationToken); if (workOrder == null) return VendorOperationOutcome.NotFound("Work order not found"); var vendor = await _data.GetVendorForUpdateAsync(body.VendorId, cancellationToken); if (vendor == null || !vendor.IsActive || (vendor.IsDeleted ?? false)) return VendorOperationOutcome.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(); var conflicts = start.HasValue && end.HasValue ? activeDispatches.Where(dispatch => WindowsOverlap(start.Value, end.Value, dispatch.Start, dispatch.End)).ToList() : new List(); 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.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> 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 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); } }