From aad3facaf1faf1bb5975bff8e5ba9558168bcd59 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 18 Sep 2026 12:59:56 -0300 Subject: [PATCH] fix(notifications): ignore soft-deleted dispatches and cap conflicts by recency No Vendor treated any non-terminal dispatch as an assigned vendor without checking IsDeleted, so a soft-deleted dispatch hid the work order from both No Vendor and Vendor Conflict (the conflict query already drops deleted dispatches). GetNoVendorAsync and the vendor-reminder assigned-work-order rule now skip soft-deleted dispatches, keeping the asserted parity between the feed and the reminders. Vendor Conflict applied the section cap in vendor-sweep order, so when overlaps exceeded the limit newer conflicts could be dropped while Count still counted every work order. VendorConflictsAsync now orders pairs by recency before the cap, matching the other per-work-order sections. --- .../NotificationFeedServiceTests.cs | 58 +++++++++++++++++++ .../NotificationFeedDataService.cs | 7 ++- .../VendorOperationsDataService.cs | 5 +- .../Implementation/NotificationFeedService.cs | 9 ++- 4 files changed, 75 insertions(+), 4 deletions(-) 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 7741550..3bef86c 100644 --- a/SeaHaven.DataServices/Implementation/NotificationFeedDataService.cs +++ b/SeaHaven.DataServices/Implementation/NotificationFeedDataService.cs @@ -54,15 +54,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 84932e4..2051e77 100644 --- a/SeaHaven.Services/Implementation/NotificationFeedService.cs +++ b/SeaHaven.Services/Implementation/NotificationFeedService.cs @@ -149,7 +149,14 @@ namespace SeaHaven.Services.Implementation .Distinct() .Count(); - var items = pairs.Take(SectionItemLimit).Select(pair => new NotificationItemDto + // 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,