mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 13:03:12 +00:00
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.
187 lines
9.3 KiB
C#
187 lines
9.3 KiB
C#
using System.Security.Claims;
|
|
using SeaHaven.DataServices.Interfaces;
|
|
using SeaHaven.DataServices.Models;
|
|
using SeaHaven.Services.DTOs;
|
|
using SeaHaven.Services.Exceptions;
|
|
using SeaHaven.Services.Helpers;
|
|
using SeaHaven.Services.Interfaces;
|
|
|
|
namespace SeaHaven.Services.Implementation
|
|
{
|
|
public class NotificationFeedService : INotificationFeedService
|
|
{
|
|
/// <summary>Most items returned per per-work-order section; section counts stay exact.</summary>
|
|
public const int SectionItemLimit = 50;
|
|
|
|
private static readonly TimeSpan NoVendorLeadTime = TimeSpan.FromHours(48);
|
|
|
|
private readonly INotificationFeedDataService _data;
|
|
private readonly IVendorOperationsDataService _vendorData;
|
|
private readonly IWorkOrderAccountResolver _accountResolver;
|
|
private readonly TimeProvider _timeProvider;
|
|
|
|
public NotificationFeedService(
|
|
INotificationFeedDataService data,
|
|
IVendorOperationsDataService vendorData,
|
|
IWorkOrderAccountResolver accountResolver,
|
|
TimeProvider timeProvider)
|
|
{
|
|
_data = data;
|
|
_vendorData = vendorData;
|
|
_accountResolver = accountResolver;
|
|
_timeProvider = timeProvider;
|
|
}
|
|
|
|
public async Task<NotificationFeedDto> GetFeedAsync(ClaimsPrincipal user, CancellationToken cancellationToken)
|
|
{
|
|
var accountId = _accountResolver.ResolveAccountFilter(user);
|
|
var (dispatcherId, seesUnassigned) = ResolveAudience(user);
|
|
var scope = new NotificationFeedScope(accountId, dispatcherId);
|
|
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
|
|
|
var sections = new List<(string Reason, int Count, IReadOnlyList<NotificationItemDto> Items)>();
|
|
if (seesUnassigned)
|
|
sections.Add(await UnassignedAsync(accountId, now, cancellationToken));
|
|
sections.Add(await NoVendorAsync(scope, now, cancellationToken));
|
|
sections.Add(await AvetaMissingAsync(scope, now, cancellationToken));
|
|
sections.Add(await VendorConflictsAsync(scope, cancellationToken));
|
|
|
|
return new NotificationFeedDto
|
|
{
|
|
GeneratedAt = now,
|
|
Sections = NotificationFeedOrdering.Order(sections)
|
|
};
|
|
}
|
|
|
|
// Dispatchers see their own work orders. Roles that already see every dispatcher's
|
|
// work on the dashboard see the whole account, including work nobody owns yet.
|
|
private static (string? DispatcherId, bool SeesUnassigned) ResolveAudience(ClaimsPrincipal user)
|
|
{
|
|
if (user.IsInRole("Dispatcher"))
|
|
{
|
|
var dispatcherId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
if (string.IsNullOrWhiteSpace(dispatcherId))
|
|
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
|
return (dispatcherId, false);
|
|
}
|
|
|
|
if (user.IsInRole("Admin") || user.IsInRole("Manager") || user.IsInRole("Scheduler"))
|
|
return (null, true);
|
|
|
|
throw new WorkOrderBoardValidationException("Forbidden", "Notifications require an authorized role.");
|
|
}
|
|
|
|
private async Task<(string, int, IReadOnlyList<NotificationItemDto>)> UnassignedAsync(
|
|
int? accountId, DateTime now, CancellationToken cancellationToken)
|
|
{
|
|
var summary = await _data.GetUnassignedSummaryAsync(accountId, cancellationToken);
|
|
if (summary.Count == 0)
|
|
return (NotificationReasons.Unassigned, 0, Array.Empty<NotificationItemDto>());
|
|
|
|
var item = new NotificationItemDto
|
|
{
|
|
Id = NotificationReasons.Unassigned,
|
|
Reason = NotificationReasons.Unassigned,
|
|
Severity = NotificationSeverities.High,
|
|
Title = summary.Count == 1
|
|
? "1 work order is unassigned"
|
|
: $"{summary.Count} work orders are unassigned",
|
|
Count = summary.Count,
|
|
TriggeredAt = summary.LatestCreatedAt ?? now,
|
|
Target = new NotificationTargetDto
|
|
{
|
|
Kind = NotificationTargetKinds.Queue,
|
|
Queue = NotificationQueues.Unassigned
|
|
}
|
|
};
|
|
return (NotificationReasons.Unassigned, summary.Count, new[] { item });
|
|
}
|
|
|
|
private async Task<(string, int, IReadOnlyList<NotificationItemDto>)> NoVendorAsync(
|
|
NotificationFeedScope scope, DateTime now, CancellationToken cancellationToken)
|
|
{
|
|
var page = await _data.GetNoVendorAsync(scope, now.Add(NoVendorLeadTime), SectionItemLimit, cancellationToken);
|
|
var items = page.Items.Select(candidate => new NotificationItemDto
|
|
{
|
|
Id = $"no-vendor-{candidate.Id}",
|
|
Reason = NotificationReasons.NoVendor,
|
|
Severity = NotificationSeverities.Medium,
|
|
Title = $"{WorkOrderLabel(candidate.Id, candidate.Number)} starts within 48h with no vendor assigned",
|
|
Count = 1,
|
|
TriggeredAt = candidate.ScheduledDate.Subtract(NoVendorLeadTime),
|
|
Target = WorkOrderTarget(candidate.Id, NotificationWorkOrderTabs.Info),
|
|
WorkOrders = new[] { new NotificationWorkOrderRefDto { Id = candidate.Id, Number = candidate.Number } }
|
|
}).ToList();
|
|
return (NotificationReasons.NoVendor, page.Total, items);
|
|
}
|
|
|
|
private async Task<(string, int, IReadOnlyList<NotificationItemDto>)> AvetaMissingAsync(
|
|
NotificationFeedScope scope, DateTime now, CancellationToken cancellationToken)
|
|
{
|
|
var today = NotificationBusinessTime.Today(now);
|
|
var page = await _data.GetAvetaMissingAsync(scope, today, SectionItemLimit, cancellationToken);
|
|
var items = page.Items.Select(candidate => new NotificationItemDto
|
|
{
|
|
Id = $"aveta-missing-{candidate.Id}",
|
|
Reason = NotificationReasons.AvetaMissing,
|
|
Severity = NotificationSeverities.Medium,
|
|
Title = $"{WorkOrderLabel(candidate.Id, candidate.Number)} is missing the Aveta document",
|
|
Count = 1,
|
|
// The one-day window opens the day before the scheduled date.
|
|
TriggeredAt = candidate.ScheduledDate.Date.AddDays(-1),
|
|
Target = WorkOrderTarget(candidate.Id, NotificationWorkOrderTabs.Extras),
|
|
WorkOrders = new[] { new NotificationWorkOrderRefDto { Id = candidate.Id, Number = candidate.Number } }
|
|
}).ToList();
|
|
return (NotificationReasons.AvetaMissing, page.Total, items);
|
|
}
|
|
|
|
private async Task<(string, int, IReadOnlyList<NotificationItemDto>)> VendorConflictsAsync(
|
|
NotificationFeedScope scope, CancellationToken cancellationToken)
|
|
{
|
|
var dispatches = await _vendorData.GetActiveScheduledDispatchesAsync(scope.AccountId, cancellationToken);
|
|
var pairs = VendorConflictPairs.Build(dispatches)
|
|
.Where(pair => scope.DispatcherId == null
|
|
|| pair.Left.AssignTo == scope.DispatcherId
|
|
|| pair.Right.AssignTo == scope.DispatcherId)
|
|
.ToList();
|
|
var workOrderCount = pairs
|
|
.SelectMany(pair => new[] { pair.Left.WorkOrderId, pair.Right.WorkOrderId })
|
|
.Distinct()
|
|
.Count();
|
|
|
|
// 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();
|
|
return (NotificationReasons.VendorConflict, workOrderCount, items);
|
|
}
|
|
|
|
private static NotificationTargetDto WorkOrderTarget(int workOrderId, string tab) => new()
|
|
{
|
|
Kind = NotificationTargetKinds.WorkOrder,
|
|
WorkOrderId = workOrderId,
|
|
Tab = tab
|
|
};
|
|
|
|
private static string WorkOrderLabel(int id, string? number)
|
|
=> $"WO #{(string.IsNullOrWhiteSpace(number) ? id.ToString() : number)}";
|
|
}
|
|
}
|