190 lines
7.2 KiB
C#
190 lines
7.2 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
|
|
|
|
/// <summary>
|
|
/// Provides filtered, sorted work queue views for each workflow stage.
|
|
/// All queues sort by UpdatedAt ASC to enforce FIFO processing — the oldest
|
|
/// pending item is always at the top of the queue.
|
|
/// </summary>
|
|
public class WorkQueueService : IWorkQueueService
|
|
{
|
|
private readonly AppDbContext _db;
|
|
private readonly ILogger<WorkQueueService> _logger;
|
|
|
|
public WorkQueueService(AppDbContext db, ILogger<WorkQueueService> logger)
|
|
{
|
|
_db = db;
|
|
_logger = logger;
|
|
}
|
|
|
|
private static readonly HashSet<string> _allowedSortFields = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"createdAt", "updatedAt", "status", "batchType", "track"
|
|
};
|
|
|
|
public async Task<WorkQueueResponse> GetVerificationQueueAsync(
|
|
int page, int pageSize, string sortBy = "updatedAt", string sortDirection = "asc")
|
|
{
|
|
var query = _db.DigitizationBatches
|
|
.Where(b => b.Status == BatchStatus.PendingVerification);
|
|
|
|
return await BuildQueueResponseAsync("verification", query, page, pageSize, sortBy, sortDirection);
|
|
}
|
|
|
|
public async Task<WorkQueueResponse> GetEntryQueueAsync(
|
|
int page, int pageSize, string sortBy = "updatedAt", string sortDirection = "asc")
|
|
{
|
|
var entryStatuses = new[]
|
|
{
|
|
BatchStatus.InEntry,
|
|
BatchStatus.Rejected
|
|
};
|
|
|
|
var query = _db.DigitizationBatches
|
|
.Where(b => entryStatuses.Contains(b.Status));
|
|
|
|
return await BuildQueueResponseAsync("entry", query, page, pageSize, sortBy, sortDirection);
|
|
}
|
|
|
|
public async Task<WorkQueueResponse> GetClinicalApprovalQueueAsync(
|
|
int page, int pageSize, string sortBy = "updatedAt", string sortDirection = "asc")
|
|
{
|
|
var query = _db.DigitizationBatches
|
|
.Where(b => b.Status == BatchStatus.AwaitingClinicalApproval);
|
|
|
|
return await BuildQueueResponseAsync("clinical-approval", query, page, pageSize, sortBy, sortDirection);
|
|
}
|
|
|
|
private static IOrderedQueryable<DigitizationBatch> ApplySort(
|
|
IQueryable<DigitizationBatch> query, string sortBy, string sortDirection)
|
|
{
|
|
var desc = sortDirection.Equals("desc", StringComparison.OrdinalIgnoreCase);
|
|
|
|
return sortBy.ToLowerInvariant() switch
|
|
{
|
|
"createdat" => desc ? query.OrderByDescending(b => b.CreatedAt) : query.OrderBy(b => b.CreatedAt),
|
|
"updatedat" => desc ? query.OrderByDescending(b => b.UpdatedAt) : query.OrderBy(b => b.UpdatedAt),
|
|
"status" => desc ? query.OrderByDescending(b => b.Status) : query.OrderBy(b => b.Status),
|
|
"batchtype" => desc ? query.OrderByDescending(b => b.BatchType) : query.OrderBy(b => b.BatchType),
|
|
"track" => desc ? query.OrderByDescending(b => b.Track) : query.OrderBy(b => b.Track),
|
|
_ => query.OrderBy(b => b.UpdatedAt)
|
|
};
|
|
}
|
|
|
|
private async Task<WorkQueueResponse> BuildQueueResponseAsync(
|
|
string queueName,
|
|
IQueryable<DigitizationBatch> query,
|
|
int page,
|
|
int pageSize,
|
|
string sortBy,
|
|
string sortDirection)
|
|
{
|
|
if (!_allowedSortFields.Contains(sortBy))
|
|
throw new ValidationException(
|
|
$"Invalid sortBy field '{sortBy}'. Allowed: {string.Join(", ", _allowedSortFields)}.",
|
|
"INVALID_SORT_FIELD");
|
|
|
|
var totalCount = await query.CountAsync();
|
|
var totalPages = (int)Math.Ceiling((double)totalCount / pageSize);
|
|
|
|
var sorted = ApplySort(query, sortBy, sortDirection);
|
|
var items = await sorted
|
|
.Include(b => b.EnteredByUser)
|
|
.Include(b => b.Events)
|
|
.Skip((page - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.Select(b => new WorkQueueItemResponse(
|
|
b.Id,
|
|
b.Status.ToDbString(),
|
|
b.BatchType.ToDbString(),
|
|
b.Track.ToDbString(),
|
|
b.PatientId,
|
|
b.EnteredByUserId,
|
|
b.EnteredByUser != null ? b.EnteredByUser.FullName : null,
|
|
b.RejectionReason,
|
|
b.CreatedAt,
|
|
b.UpdatedAt,
|
|
b.Events.Count
|
|
))
|
|
.ToListAsync();
|
|
|
|
return new WorkQueueResponse(queueName, items, page, pageSize, totalCount, totalPages);
|
|
}
|
|
|
|
public async Task<WorkQueueOverviewResponse> GetOverviewAsync()
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
|
|
var statusGroups = await _db.DigitizationBatches
|
|
.AsNoTracking()
|
|
.GroupBy(b => b.Status)
|
|
.Select(g => new { Status = g.Key, Count = g.Count() })
|
|
.ToListAsync();
|
|
|
|
var statusCounts = new Dictionary<string, int>();
|
|
foreach (var status in Enum.GetValues<BatchStatus>())
|
|
{
|
|
var count = statusGroups.FirstOrDefault(g => g.Status == status)?.Count ?? 0;
|
|
statusCounts[status.ToDbString()] = count;
|
|
}
|
|
|
|
var pendingBatches = await _db.DigitizationBatches
|
|
.AsNoTracking()
|
|
.Where(b => b.Status == BatchStatus.PendingVerification)
|
|
.Select(b => b.UpdatedAt)
|
|
.ToListAsync();
|
|
|
|
double avgTimeInQueueMinutes = 0;
|
|
double oldestPendingMinutes = 0;
|
|
|
|
if (pendingBatches.Count > 0)
|
|
{
|
|
var ages = pendingBatches
|
|
.Select(updatedAt => (now - updatedAt).TotalMinutes)
|
|
.ToList();
|
|
|
|
avgTimeInQueueMinutes = ages.Average();
|
|
oldestPendingMinutes = ages.Max();
|
|
}
|
|
|
|
var cutoff = now.AddHours(-24);
|
|
|
|
var recentEvents = await _db.DigitizationEvents
|
|
.AsNoTracking()
|
|
.Where(e => e.OccurredAt >= cutoff)
|
|
.Where(e => e.EventType == DigitizationEventType.Rejected
|
|
|| e.EventType == DigitizationEventType.VerificationFailed
|
|
|| e.EventType == DigitizationEventType.Verified
|
|
|| e.EventType == DigitizationEventType.VerifiedPendingClinical)
|
|
.GroupBy(e => e.EventType)
|
|
.Select(g => new { EventType = g.Key, Count = g.Count() })
|
|
.ToListAsync();
|
|
|
|
var rejections = recentEvents
|
|
.Where(e => e.EventType == DigitizationEventType.Rejected
|
|
|| e.EventType == DigitizationEventType.VerificationFailed)
|
|
.Sum(e => e.Count);
|
|
|
|
var verifications = recentEvents
|
|
.Where(e => e.EventType == DigitizationEventType.Verified
|
|
|| e.EventType == DigitizationEventType.VerifiedPendingClinical)
|
|
.Sum(e => e.Count);
|
|
|
|
var totalDecisions = rejections + verifications;
|
|
var rejectRate = totalDecisions > 0
|
|
? (double)rejections / totalDecisions
|
|
: 0.0;
|
|
|
|
_logger.LogDebug(
|
|
"Work queue overview: {PendingCount} pending, avg queue {AvgMinutes:F1}m, reject rate {RejectRate:P1}",
|
|
pendingBatches.Count, avgTimeInQueueMinutes, rejectRate);
|
|
|
|
return new WorkQueueOverviewResponse
|
|
{
|
|
StatusCounts = statusCounts,
|
|
AverageTimeInQueueMinutes = Math.Round(avgTimeInQueueMinutes, 1),
|
|
RejectRate = Math.Round(rejectRate, 4),
|
|
OldestPendingVerificationMinutes = Math.Round(oldestPendingMinutes, 1)
|
|
};
|
|
}
|
|
} |