mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
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.
This commit is contained in:
parent
da0b29f769
commit
aad3facaf1
4 changed files with 75 additions and 4 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -54,15 +54,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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue