mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-05 15:22:08 +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);
|
.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]
|
[Fact]
|
||||||
public async Task Sections_OrderBySeverityThenFixedReasonOrder_AndItemsBySeverityThenRecency()
|
public async Task Sections_OrderBySeverityThenFixedReasonOrder_AndItemsBySeverityThenRecency()
|
||||||
{
|
{
|
||||||
|
|
@ -429,6 +454,39 @@ public class NotificationFeedServiceTests
|
||||||
unrelated.Sections.Should().BeEmpty();
|
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]
|
[Fact]
|
||||||
public async Task PerWorkOrderSections_AreBoundedButCountEveryWorkOrder()
|
public async Task PerWorkOrderSections_AreBoundedButCountEveryWorkOrder()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -60,15 +60,18 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
public Task<NotificationCandidatePage> GetNoVendorAsync(
|
public Task<NotificationCandidatePage> GetNoVendorAsync(
|
||||||
NotificationFeedScope scope, DateTime cutoff, int limit, CancellationToken cancellationToken)
|
NotificationFeedScope scope, DateTime cutoff, int limit, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// "Has a vendor" matches the vendor reminders: any non-terminal dispatch that
|
// "Has a vendor" matches the vendor reminders: any live non-terminal dispatch that
|
||||||
// points at the work order directly or links it through DispatchWorkOrders.
|
// 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)
|
var candidates = OpenWorkOrders(scope)
|
||||||
.Where(w => w.ScheduledDate.HasValue
|
.Where(w => w.ScheduledDate.HasValue
|
||||||
&& w.ScheduledDate.Value <= cutoff
|
&& w.ScheduledDate.Value <= cutoff
|
||||||
&& !_context.Dispatches.Any(d => d.WorkOrderId == w.Id
|
&& !_context.Dispatches.Any(d => d.WorkOrderId == w.Id
|
||||||
|
&& (d.IsDeleted == null || d.IsDeleted == false)
|
||||||
&& !TerminalDispatchStatuses.Contains(d.Status ?? ""))
|
&& !TerminalDispatchStatuses.Contains(d.Status ?? ""))
|
||||||
&& !_context.DispatchWorkOrders.Any(link => link.WorkOrderId == w.Id
|
&& !_context.DispatchWorkOrders.Any(link => link.WorkOrderId == w.Id
|
||||||
&& link.Dispatch != null
|
&& link.Dispatch != null
|
||||||
|
&& (link.Dispatch.IsDeleted == null || link.Dispatch.IsDeleted == false)
|
||||||
&& !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "")));
|
&& !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "")));
|
||||||
|
|
||||||
return PageAsync(candidates, limit, cancellationToken);
|
return PageAsync(candidates, limit, cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -23,12 +23,15 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
{
|
{
|
||||||
var directlyAssignedIds = await _context.Dispatches
|
var directlyAssignedIds = await _context.Dispatches
|
||||||
.AsNoTracking()
|
.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)
|
.Select(d => d.WorkOrderId!.Value)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
var multiplyAssignedIds = await _context.DispatchWorkOrders
|
var multiplyAssignedIds = await _context.DispatchWorkOrders
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(link => link.Dispatch != null
|
.Where(link => link.Dispatch != null
|
||||||
|
&& (link.Dispatch.IsDeleted == null || link.Dispatch.IsDeleted == false)
|
||||||
&& !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? ""))
|
&& !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? ""))
|
||||||
.Select(link => link.WorkOrderId)
|
.Select(link => link.WorkOrderId)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -198,21 +198,28 @@ namespace SeaHaven.Services.Implementation
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.Count();
|
.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.
|
||||||
Id = $"vendor-conflict-{pair.Left.Id}-{pair.Right.Id}",
|
var items = pairs
|
||||||
Reason = NotificationReasons.VendorConflict,
|
.OrderByDescending(pair => pair.Left.Start < pair.Right.Start ? pair.Left.Start : pair.Right.Start)
|
||||||
Severity = NotificationSeverities.Medium,
|
.ThenByDescending(pair => pair.Left.Id)
|
||||||
Title = $"{(string.IsNullOrWhiteSpace(pair.Left.VendorName) ? "Vendor" : pair.Left.VendorName)} has overlapping appointments",
|
.ThenByDescending(pair => pair.Right.Id)
|
||||||
Count = 2,
|
.Take(SectionItemLimit)
|
||||||
TriggeredAt = pair.Left.Start < pair.Right.Start ? pair.Left.Start : pair.Right.Start,
|
.Select(pair => new NotificationItemDto
|
||||||
Target = WorkOrderTarget(pair.Left.WorkOrderId, NotificationWorkOrderTabs.Info),
|
{
|
||||||
WorkOrders = new[]
|
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.Left.WorkOrderId, Number = pair.Left.WorkOrderNumber },
|
||||||
new NotificationWorkOrderRefDto { Id = pair.Right.WorkOrderId, Number = pair.Right.WorkOrderNumber }
|
new NotificationWorkOrderRefDto { Id = pair.Right.WorkOrderId, Number = pair.Right.WorkOrderNumber }
|
||||||
}
|
}
|
||||||
}).ToList();
|
}).ToList();
|
||||||
return (NotificationReasons.VendorConflict, workOrderCount, items);
|
return (NotificationReasons.VendorConflict, workOrderCount, items);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue