mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 18:53:12 +00:00
Reactive/Emergency work orders with a SEV 1-5 level are timed from their
creation against the SEV Respond deadline (2/4/8/24/72 hours, one backend
table). From 50% they are at risk: a dismissable High row in the "SLA at
Risk" section and an entry in the feed's slaAtRisk set with the server
clock (start, deadline, percent) for the banner and toast. From 100% they
are a Critical acknowledge row that only acknowledging removes.
POST /api/notifications/sla/{id}/acknowledge records who and when as a
work-order audit entry ("SLA breach acknowledged by <name>"), scoped to the
caller's feed audience: 404 outside it, 409 before the deadline, 204 when
recorded or already recorded. A later severity change is a new breach.
311 lines
16 KiB
C#
311 lines
16 KiB
C#
using Data.SeaHavenIndustries;
|
||
using Data.SeaHavenIndustries.Enums;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using SeaHaven.DataServices.Helpers;
|
||
using SeaHaven.DataServices.Interfaces;
|
||
using SeaHaven.DataServices.Models;
|
||
|
||
namespace SeaHaven.DataServices.Implementation
|
||
{
|
||
public class NotificationFeedDataService : INotificationFeedDataService
|
||
{
|
||
private static readonly string[] TerminalDispatchStatuses = { "Completed", "Verified", "Cancelled", "Canceled" };
|
||
|
||
// Uplift outcomes a requester is told about; "Denied" is the legacy spelling of Rejected.
|
||
private static readonly string[] DecisionStatuses = { "Approved", "Rejected", "Denied", "Revoked" };
|
||
|
||
private static readonly string[] SlaSeverities = { "1", "2", "3", "4", "5" };
|
||
|
||
private const string AssignToAuditField = "AssignTo";
|
||
private const string LegacyAssignedToAuditField = "AssignedTo";
|
||
|
||
private readonly ApplicationDbContext _context;
|
||
|
||
public NotificationFeedDataService(ApplicationDbContext context)
|
||
{
|
||
_context = context;
|
||
}
|
||
|
||
public async Task<UnassignedWorkOrdersSummary> GetUnassignedSummaryAsync(
|
||
int? accountId, CancellationToken cancellationToken)
|
||
{
|
||
var summary = await OpenWorkOrders(new NotificationFeedScope(accountId, null))
|
||
.Where(w => w.AssignTo == null || w.AssignTo == "")
|
||
.GroupBy(_ => 1)
|
||
.Select(group => new UnassignedWorkOrdersSummary
|
||
{
|
||
Count = group.Count(),
|
||
LatestCreatedAt = group.Max(w => w.CreatedDate)
|
||
})
|
||
.FirstOrDefaultAsync(cancellationToken);
|
||
|
||
return summary ?? new UnassignedWorkOrdersSummary();
|
||
}
|
||
|
||
public Task<NotificationCandidatePage> GetAvetaMissingAsync(
|
||
NotificationFeedScope scope, DateOnly today, int limit, CancellationToken cancellationToken)
|
||
{
|
||
// Same rule as the dashboard's Aveta Pending tile so the two counts agree.
|
||
var windowStart = today.ToDateTime(TimeOnly.MinValue);
|
||
var windowEnd = today.AddDays(2).ToDateTime(TimeOnly.MinValue);
|
||
var candidates = OpenWorkOrders(scope)
|
||
.Where(w => w.AvetaRequired
|
||
&& w.ScheduledDate >= windowStart
|
||
&& w.ScheduledDate < windowEnd
|
||
&& !w.workOrderAttachments!.Any(attachment =>
|
||
(attachment.IsDeleted == null || attachment.IsDeleted == false)
|
||
&& attachment.Category == WorkOrderMediaCategory.Aveta));
|
||
|
||
return PageAsync(candidates, limit, cancellationToken);
|
||
}
|
||
|
||
public Task<NotificationCandidatePage> GetNoVendorAsync(
|
||
NotificationFeedScope scope, DateTime cutoff, int limit, CancellationToken cancellationToken)
|
||
{
|
||
// "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);
|
||
}
|
||
|
||
public async Task<IReadOnlyList<NotificationAssignmentCandidate>> GetRecentAssignmentsAsync(
|
||
NotificationPersonalScope scope, DateTime since, int limit, CancellationToken cancellationToken)
|
||
{
|
||
// Board create and board edits log "AssignTo" with user ids; the legacy assignment endpoint
|
||
// logs "AssignedTo" with display names. Only the time and the actor are read, so both count.
|
||
var assignmentLogs = _context.WorkOrderAuditLogs.AsNoTracking()
|
||
.Where(log => log.FieldName == AssignToAuditField || log.FieldName == LegacyAssignedToAuditField);
|
||
|
||
// The latest assignment entry of each work order the user holds now.
|
||
var candidates =
|
||
from log in assignmentLogs
|
||
join w in OpenWorkOrders(new NotificationFeedScope(scope.AccountId, scope.UserId))
|
||
on log.WorkOrderId equals w.Id
|
||
where log.CreatedAt >= since
|
||
&& (log.UserId == null || log.UserId != scope.UserId)
|
||
&& !assignmentLogs.Any(later => later.WorkOrderId == log.WorkOrderId
|
||
&& (later.CreatedAt > log.CreatedAt || (later.CreatedAt == log.CreatedAt && later.Id > log.Id)))
|
||
orderby log.CreatedAt descending, w.Id descending
|
||
select new NotificationAssignmentCandidate
|
||
{
|
||
WorkOrderId = w.Id,
|
||
Number = w.InternalWONumber ?? w.WorkerOrderNumber,
|
||
AssignedAt = log.CreatedAt,
|
||
AssignedBy = log.UserId
|
||
};
|
||
|
||
return await candidates.Take(limit).ToListAsync(cancellationToken);
|
||
}
|
||
|
||
public async Task<IReadOnlyList<NotificationCommentCandidate>> GetUnansweredCommentsAsync(
|
||
NotificationPersonalScope scope, DateTime since, int limit, CancellationToken cancellationToken)
|
||
{
|
||
var userId = scope.UserId;
|
||
// SH-217 stores a mention as the token "@[userId:Display Name]" inside the comment text.
|
||
var mentionToken = "@[" + userId + ":";
|
||
var ownComments = _context.Comments.AsNoTracking()
|
||
.Where(own => own.UserId == userId && own.IsDeleted != true);
|
||
|
||
var candidates =
|
||
from c in _context.Comments.AsNoTracking()
|
||
join w in ScopedWorkOrders(scope.AccountId) on c.WorkerOrderId equals w.Id
|
||
where c.IsDeleted != true
|
||
&& c.CreatedDate != null
|
||
&& c.CreatedDate >= since
|
||
&& (c.UserId == null || c.UserId != userId)
|
||
&& (w.AssignTo == userId
|
||
|| (c.Commenttext != null && c.Commenttext.Contains(mentionToken))
|
||
|| ownComments.Any(own => own.WorkerOrderId == w.Id))
|
||
&& !ownComments.Any(own => own.WorkerOrderId == w.Id && own.CreatedDate >= c.CreatedDate)
|
||
orderby c.CreatedDate descending, c.Id descending
|
||
select new NotificationCommentCandidate
|
||
{
|
||
CommentId = c.Id,
|
||
WorkOrderId = w.Id,
|
||
WorkOrderNumber = w.InternalWONumber ?? w.WorkerOrderNumber,
|
||
AuthorFirstName = c.ApplicationUser != null ? c.ApplicationUser.FirstName : null,
|
||
AuthorLastName = c.ApplicationUser != null ? c.ApplicationUser.LastName : null,
|
||
Commenter = c.Commenter,
|
||
Text = c.Commenttext,
|
||
CreatedAt = c.CreatedDate!.Value
|
||
};
|
||
|
||
return await candidates.Take(limit).ToListAsync(cancellationToken);
|
||
}
|
||
|
||
public async Task<IReadOnlyList<NotificationUpliftDecisionCandidate>> GetUpliftDecisionsAsync(
|
||
NotificationPersonalScope scope, DateTime since, int limit, CancellationToken cancellationToken)
|
||
{
|
||
var userId = scope.UserId;
|
||
// Same work-order resolution as the uplift reads: the dispatch's own work order,
|
||
// otherwise the first work order linked through DispatchWorkOrders.
|
||
var candidates =
|
||
from u in _context.DispatchUpliftRequests.AsNoTracking()
|
||
join d in _context.Dispatches.AsNoTracking() on u.DispatchId equals d.Id
|
||
let workOrderId = d.WorkOrderId
|
||
?? d.DispatchWorkOrders!.Select(link => (int?)link.WorkOrderId).FirstOrDefault()
|
||
join w in ScopedWorkOrders(scope.AccountId) on workOrderId equals (int?)w.Id
|
||
where u.IsDeleted != true
|
||
&& d.IsDeleted != true
|
||
&& u.createdby == userId
|
||
&& u.DecidedAt != null
|
||
&& u.DecidedAt >= since
|
||
&& (u.DecidedByUserId == null || u.DecidedByUserId != userId)
|
||
&& DecisionStatuses.Contains(u.Status)
|
||
orderby u.DecidedAt descending, u.Id descending
|
||
select new NotificationUpliftDecisionCandidate
|
||
{
|
||
UpliftId = u.Id,
|
||
WorkOrderId = w.Id,
|
||
WorkOrderNumber = w.InternalWONumber ?? w.WorkerOrderNumber,
|
||
Status = u.Status,
|
||
Amount = u.RequestedNTE,
|
||
DecisionNote = u.DecisionNote,
|
||
DecidedAt = u.DecidedAt!.Value
|
||
};
|
||
|
||
return await candidates.Take(limit).ToListAsync(cancellationToken);
|
||
}
|
||
|
||
public async Task<NotificationSlaCandidates> GetSlaCandidatesAsync(
|
||
NotificationFeedScope scope,
|
||
NotificationSlaCutoffs atRisk,
|
||
NotificationSlaCutoffs breached,
|
||
int breachedLimit,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var eligible = SlaEligibleWorkOrders(scope);
|
||
|
||
// Bounded by the longest window: nothing created more than 72 hours ago is still at risk.
|
||
var atRiskItems = await ProjectSla(CreatedAfter(CreatedAtOrBefore(eligible, atRisk), breached))
|
||
.OrderBy(candidate => candidate.CreatedAt)
|
||
.ThenBy(candidate => candidate.Id)
|
||
.ToListAsync(cancellationToken);
|
||
|
||
// Breaches stay until acknowledged, so legacy open work orders can make this set large.
|
||
var unacknowledged = CreatedAtOrBefore(eligible, breached)
|
||
.Where(w => !_context.WorkOrderAuditLogs.Any(log => log.WorkOrderId == w.Id
|
||
&& log.FieldName == SlaBreachAcknowledgementAudit.FieldName
|
||
&& log.NewValue == w.Severity));
|
||
var breachedTotal = await unacknowledged.CountAsync(cancellationToken);
|
||
var breachedItems = breachedTotal == 0
|
||
? new List<NotificationSlaCandidate>()
|
||
: await ProjectSla(unacknowledged)
|
||
.OrderByDescending(candidate => candidate.CreatedAt)
|
||
.ThenByDescending(candidate => candidate.Id)
|
||
.Take(breachedLimit)
|
||
.ToListAsync(cancellationToken);
|
||
|
||
return new NotificationSlaCandidates
|
||
{
|
||
AtRisk = atRiskItems,
|
||
Breached = breachedItems,
|
||
BreachedTotal = breachedTotal
|
||
};
|
||
}
|
||
|
||
public Task<NotificationSlaCandidate?> GetSlaCandidateAsync(
|
||
NotificationFeedScope scope, int workOrderId, CancellationToken cancellationToken)
|
||
=> ProjectSla(SlaEligibleWorkOrders(scope).Where(w => w.Id == workOrderId))
|
||
.FirstOrDefaultAsync(cancellationToken);
|
||
|
||
// Same rule as the board's severity facet: Reactive or Emergency with a stored SEV level "1"–"5".
|
||
private IQueryable<WorkOrder> SlaEligibleWorkOrders(NotificationFeedScope scope)
|
||
=> OpenWorkOrders(scope).Where(w =>
|
||
(w.WorkOrderType == WorkOrderType.Reactive || w.WorkOrderType == WorkOrderType.Emergency)
|
||
&& SlaSeverities.Contains(w.Severity ?? "")
|
||
&& w.CreatedDate != null);
|
||
|
||
private static IQueryable<WorkOrder> CreatedAtOrBefore(IQueryable<WorkOrder> workOrders, NotificationSlaCutoffs cutoffs)
|
||
{
|
||
var (sev1, sev2, sev3, sev4, sev5) = (cutoffs.Sev1, cutoffs.Sev2, cutoffs.Sev3, cutoffs.Sev4, cutoffs.Sev5);
|
||
return workOrders.Where(w =>
|
||
(w.Severity == "1" && w.CreatedDate <= sev1)
|
||
|| (w.Severity == "2" && w.CreatedDate <= sev2)
|
||
|| (w.Severity == "3" && w.CreatedDate <= sev3)
|
||
|| (w.Severity == "4" && w.CreatedDate <= sev4)
|
||
|| (w.Severity == "5" && w.CreatedDate <= sev5));
|
||
}
|
||
|
||
private static IQueryable<WorkOrder> CreatedAfter(IQueryable<WorkOrder> workOrders, NotificationSlaCutoffs cutoffs)
|
||
{
|
||
var (sev1, sev2, sev3, sev4, sev5) = (cutoffs.Sev1, cutoffs.Sev2, cutoffs.Sev3, cutoffs.Sev4, cutoffs.Sev5);
|
||
return workOrders.Where(w =>
|
||
(w.Severity == "1" && w.CreatedDate > sev1)
|
||
|| (w.Severity == "2" && w.CreatedDate > sev2)
|
||
|| (w.Severity == "3" && w.CreatedDate > sev3)
|
||
|| (w.Severity == "4" && w.CreatedDate > sev4)
|
||
|| (w.Severity == "5" && w.CreatedDate > sev5));
|
||
}
|
||
|
||
private IQueryable<NotificationSlaCandidate> ProjectSla(IQueryable<WorkOrder> workOrders)
|
||
=> workOrders.Select(w => new NotificationSlaCandidate
|
||
{
|
||
Id = w.Id,
|
||
Number = w.InternalWONumber ?? w.WorkerOrderNumber,
|
||
Severity = w.Severity!,
|
||
CreatedAt = w.CreatedDate!.Value,
|
||
BreachAcknowledged = _context.WorkOrderAuditLogs.Any(log => log.WorkOrderId == w.Id
|
||
&& log.FieldName == SlaBreachAcknowledgementAudit.FieldName
|
||
&& log.NewValue == w.Severity)
|
||
});
|
||
|
||
private IQueryable<WorkOrder> ScopedWorkOrders(int? accountId)
|
||
{
|
||
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||
return accountId is int id
|
||
? WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, id)
|
||
: workOrders;
|
||
}
|
||
|
||
private IQueryable<WorkOrder> OpenWorkOrders(NotificationFeedScope scope)
|
||
{
|
||
var workOrders = ScopedWorkOrders(scope.AccountId);
|
||
if (scope.DispatcherId != null)
|
||
workOrders = workOrders.Where(w => w.AssignTo == scope.DispatcherId);
|
||
|
||
return workOrders.Where(w => w.LifecycleStatus != LifecycleStatus.Completed
|
||
&& w.LifecycleStatus != LifecycleStatus.Canceled
|
||
&& (w.LifecycleStatus != null
|
||
|| ((w.LegacyStatus ?? w.Status) != "Done"
|
||
&& (w.LegacyStatus ?? w.Status) != "Completed"
|
||
&& (w.LegacyStatus ?? w.Status) != "Complete"
|
||
&& (w.LegacyStatus ?? w.Status) != "Closed"
|
||
&& (w.LegacyStatus ?? w.Status) != "Canceled"
|
||
&& (w.LegacyStatus ?? w.Status) != "Cancelled")));
|
||
}
|
||
|
||
private static async Task<NotificationCandidatePage> PageAsync(
|
||
IQueryable<WorkOrder> candidates, int limit, CancellationToken cancellationToken)
|
||
{
|
||
var total = await candidates.CountAsync(cancellationToken);
|
||
if (total == 0)
|
||
return new NotificationCandidatePage();
|
||
|
||
var items = await candidates
|
||
.OrderByDescending(w => w.ScheduledDate)
|
||
.ThenByDescending(w => w.Id)
|
||
.Take(limit)
|
||
.Select(w => new NotificationWorkOrderCandidate
|
||
{
|
||
Id = w.Id,
|
||
Number = w.InternalWONumber ?? w.WorkerOrderNumber,
|
||
ScheduledDate = w.ScheduledDate!.Value
|
||
})
|
||
.ToListAsync(cancellationToken);
|
||
|
||
return new NotificationCandidatePage { Total = total, Items = items };
|
||
}
|
||
}
|
||
}
|