Merge remote-tracking branch 'origin/feat/ab/sh-292-notification-center' into feat/ab/sh-288-event-notifications

This commit is contained in:
Alexandre Brandizzi 2026-09-18 13:20:22 -03:00
commit 9784dcf895
4 changed files with 85 additions and 14 deletions

View file

@ -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<WorkOrder>();
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()
{

View file

@ -60,15 +60,18 @@ namespace SeaHaven.DataServices.Implementation
public Task<NotificationCandidatePage> 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);

View file

@ -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);

View file

@ -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);
}