mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
Merge remote-tracking branch 'origin/feat/ab/sh-292-notification-center' into feat/ab/sh-288-event-notifications
This commit is contained in:
commit
9784dcf895
4 changed files with 85 additions and 14 deletions
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue