diff --git a/Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs b/Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs index cb702a4..9e8910e 100644 --- a/Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs @@ -301,6 +301,31 @@ public class NotificationFeedServiceTests .Should().BeEquivalentTo(reminderIds); } + [Fact] + public async Task NoVendor_TreatsSoftDeletedDispatchesAsNoVendor() + { + using var context = NewContext(); + var deletedDirect = Open(1, "dispatcher-1", Now.AddHours(20), "NV-DEL-DIRECT"); + var deletedLinked = Open(1, "dispatcher-1", Now.AddHours(22), "NV-DEL-LINKED"); + context.workOrders.AddRange(deletedDirect, deletedLinked); + await context.SaveChangesAsync(); + var vendor = new Vendor { CompanyName = "Removed", IsActive = true }; + context.Vendors.Add(vendor); + await context.SaveChangesAsync(); + var direct = new Dispatch { VendorId = vendor.Id, WorkOrderId = deletedDirect.Id, Status = "Sent", IsDeleted = true }; + var linked = new Dispatch { VendorId = vendor.Id, WorkOrderId = deletedDirect.Id, Status = "Sent", IsDeleted = true }; + context.Dispatches.AddRange(direct, linked); + await context.SaveChangesAsync(); + context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = linked.Id, WorkOrderId = deletedLinked.Id }); + await context.SaveChangesAsync(); + + var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None); + + Section(feed, NotificationReasons.NoVendor).Items + .Select(item => item.WorkOrders.Single().Number) + .Should().BeEquivalentTo("NV-DEL-DIRECT", "NV-DEL-LINKED"); + } + [Fact] public async Task Sections_OrderBySeverityThenFixedReasonOrder_AndItemsBySeverityThenRecency() { @@ -429,6 +454,39 @@ public class NotificationFeedServiceTests unrelated.Sections.Should().BeEmpty(); } + [Fact] + public async Task VendorConflict_CapsByRecencyKeepingNewerOverlaps() + { + using var context = NewContext(); + // One vendor whose older cluster produces more overlapping pairs than the cap, plus a + // single newer overlap. Build sweeps a vendor's windows in start order, so the newer pair + // is emitted last and is exactly what an unsorted Take(limit) would drop. + var oldStart = Now.AddDays(3); + var oldOrders = new List(); + for (var index = 0; index < 11; index++) + oldOrders.Add(Open(1, "dispatcher-1", oldStart, $"OLD-{index}")); + var newerLeft = Open(1, "dispatcher-1", Now.AddDays(10), "NEW-A"); + var newerRight = Open(1, "dispatcher-1", Now.AddDays(10), "NEW-B"); + context.workOrders.AddRange(oldOrders); + context.workOrders.AddRange(newerLeft, newerRight); + await context.SaveChangesAsync(); + + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + context.Vendors.Add(vendor); + await context.SaveChangesAsync(); + foreach (var workOrder in oldOrders.Append(newerLeft).Append(newerRight)) + context.Dispatches.Add(new Dispatch { VendorId = vendor.Id, WorkOrderId = workOrder.Id, Status = "Sent", ScheduledDate = workOrder.ScheduledDate }); + await context.SaveChangesAsync(); + + var feed = await NewService(context).GetFeedAsync(User(1, "Scheduler"), CancellationToken.None); + + var section = Section(feed, NotificationReasons.VendorConflict); + section.Items.Should().HaveCount(NotificationFeedService.SectionItemLimit); + section.Items.Should().Contain(item => + item.WorkOrders.Any(workOrder => workOrder.Number == "NEW-A") + && item.WorkOrders.Any(workOrder => workOrder.Number == "NEW-B")); + } + [Fact] public async Task PerWorkOrderSections_AreBoundedButCountEveryWorkOrder() { diff --git a/SeaHaven.DataServices/Implementation/NotificationFeedDataService.cs b/SeaHaven.DataServices/Implementation/NotificationFeedDataService.cs index eba9cb0..5b5748f 100644 --- a/SeaHaven.DataServices/Implementation/NotificationFeedDataService.cs +++ b/SeaHaven.DataServices/Implementation/NotificationFeedDataService.cs @@ -60,15 +60,18 @@ namespace SeaHaven.DataServices.Implementation public Task GetNoVendorAsync( NotificationFeedScope scope, DateTime cutoff, int limit, CancellationToken cancellationToken) { - // "Has a vendor" matches the vendor reminders: any non-terminal dispatch that - // points at the work order directly or links it through DispatchWorkOrders. + // "Has a vendor" matches the vendor reminders: any live non-terminal dispatch that + // points at the work order directly or links it through DispatchWorkOrders. Soft-deleted + // dispatches are ignored so a removed dispatch does not keep the row out of No Vendor. var candidates = OpenWorkOrders(scope) .Where(w => w.ScheduledDate.HasValue && w.ScheduledDate.Value <= cutoff && !_context.Dispatches.Any(d => d.WorkOrderId == w.Id + && (d.IsDeleted == null || d.IsDeleted == false) && !TerminalDispatchStatuses.Contains(d.Status ?? "")) && !_context.DispatchWorkOrders.Any(link => link.WorkOrderId == w.Id && link.Dispatch != null + && (link.Dispatch.IsDeleted == null || link.Dispatch.IsDeleted == false) && !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? ""))); return PageAsync(candidates, limit, cancellationToken); diff --git a/SeaHaven.DataServices/Implementation/VendorOperationsDataService.cs b/SeaHaven.DataServices/Implementation/VendorOperationsDataService.cs index 0468f43..4fabfba 100644 --- a/SeaHaven.DataServices/Implementation/VendorOperationsDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorOperationsDataService.cs @@ -23,12 +23,15 @@ namespace SeaHaven.DataServices.Implementation { var directlyAssignedIds = await _context.Dispatches .AsNoTracking() - .Where(d => !TerminalDispatchStatuses.Contains(d.Status ?? "") && d.WorkOrderId.HasValue) + .Where(d => !TerminalDispatchStatuses.Contains(d.Status ?? "") + && (d.IsDeleted == null || d.IsDeleted == false) + && d.WorkOrderId.HasValue) .Select(d => d.WorkOrderId!.Value) .ToListAsync(cancellationToken); var multiplyAssignedIds = await _context.DispatchWorkOrders .AsNoTracking() .Where(link => link.Dispatch != null + && (link.Dispatch.IsDeleted == null || link.Dispatch.IsDeleted == false) && !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "")) .Select(link => link.WorkOrderId) .ToListAsync(cancellationToken); diff --git a/SeaHaven.Services/Implementation/NotificationFeedService.cs b/SeaHaven.Services/Implementation/NotificationFeedService.cs index 695d40f..ff12e45 100644 --- a/SeaHaven.Services/Implementation/NotificationFeedService.cs +++ b/SeaHaven.Services/Implementation/NotificationFeedService.cs @@ -198,21 +198,28 @@ namespace SeaHaven.Services.Implementation .Distinct() .Count(); - var items = pairs.Take(SectionItemLimit).Select(pair => new NotificationItemDto - { - Id = $"vendor-conflict-{pair.Left.Id}-{pair.Right.Id}", - Reason = NotificationReasons.VendorConflict, - Severity = NotificationSeverities.Medium, - Title = $"{(string.IsNullOrWhiteSpace(pair.Left.VendorName) ? "Vendor" : pair.Left.VendorName)} has overlapping appointments", - Count = 2, - TriggeredAt = pair.Left.Start < pair.Right.Start ? pair.Left.Start : pair.Right.Start, - Target = WorkOrderTarget(pair.Left.WorkOrderId, NotificationWorkOrderTabs.Info), - WorkOrders = new[] + // Cap by recency like the other sections: keep the newest overlaps so a section that + // exceeds the limit never drops a more recent conflict in favour of an older one. + var items = pairs + .OrderByDescending(pair => pair.Left.Start < pair.Right.Start ? pair.Left.Start : pair.Right.Start) + .ThenByDescending(pair => pair.Left.Id) + .ThenByDescending(pair => pair.Right.Id) + .Take(SectionItemLimit) + .Select(pair => new NotificationItemDto + { + Id = $"vendor-conflict-{pair.Left.Id}-{pair.Right.Id}", + Reason = NotificationReasons.VendorConflict, + Severity = NotificationSeverities.Medium, + Title = $"{(string.IsNullOrWhiteSpace(pair.Left.VendorName) ? "Vendor" : pair.Left.VendorName)} has overlapping appointments", + Count = 2, + TriggeredAt = pair.Left.Start < pair.Right.Start ? pair.Left.Start : pair.Right.Start, + Target = WorkOrderTarget(pair.Left.WorkOrderId, NotificationWorkOrderTabs.Info), + WorkOrders = new[] { new NotificationWorkOrderRefDto { Id = pair.Left.WorkOrderId, Number = pair.Left.WorkOrderNumber }, new NotificationWorkOrderRefDto { Id = pair.Right.WorkOrderId, Number = pair.Right.WorkOrderNumber } } - }).ToList(); + }).ToList(); return (NotificationReasons.VendorConflict, workOrderCount, items); }