feature: Approval and Promotion to VigilCareClinical

This commit is contained in:
voltsrage
2026-06-26 14:04:52 +08:00
parent 7121520926
commit 470df683dd
21 changed files with 2638 additions and 4 deletions
@@ -0,0 +1,20 @@
/// <summary>
/// Handles batch verification, rejection, and separation-of-duties enforcement.
/// </summary>
public interface IVerificationService
{
/// <summary>
/// Verifies a batch that is in PendingVerification status.
/// Enforces separation of duties: the verifier cannot be the same user who entered the data.
/// On pass, transitions to Verified or AwaitingClinicalApproval based on site config.
/// On fail (Passed = false), transitions to Rejected with field check notes as the reason.
/// </summary>
Task<DigitizationBatch> VerifyAsync(Guid batchId, VerifyBatchRequest request, Guid verifierUserId);
/// <summary>
/// Rejects a batch that is in PendingVerification or AwaitingClinicalApproval status.
/// Stores the rejection reason on the batch and transitions status to Rejected.
/// The batch returns to the entry work queue for re-entry by a data entry clerk.
/// </summary>
Task<DigitizationBatch> RejectAsync(Guid batchId, RejectBatchRequest request, Guid actorUserId);
}
@@ -0,0 +1,27 @@
/// <summary>
/// Provides work queue views for each workflow stage.
/// Each queue returns batches filtered by status and sorted by submission time ASC.
/// </summary>
public interface IWorkQueueService
{
/// <summary>
/// Returns batches in PendingVerification status, sorted by UpdatedAt ASC (oldest first).
/// This is the verifier's work queue — the next batch to verify is always at the top.
/// </summary>
Task<WorkQueueResponse> GetVerificationQueueAsync(int page, int pageSize);
/// <summary>
/// Returns batches that are awaiting or currently in data entry:
/// - Status = Uploaded (awaiting assignment and entry)
/// - Status = InEntry (currently being entered)
/// - Status = Rejected (returned for re-entry after verification rejection)
/// Sorted by UpdatedAt ASC so rejected batches surface for re-entry.
/// </summary>
Task<WorkQueueResponse> GetEntryQueueAsync(int page, int pageSize);
/// <summary>
/// Returns batches in AwaitingClinicalApproval status, sorted by UpdatedAt ASC.
/// This is the clinical approver's work queue.
/// </summary>
Task<WorkQueueResponse> GetClinicalApprovalQueueAsync(int page, int pageSize);
}
@@ -0,0 +1,215 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
/// <summary>
/// Implements batch verification and rejection with separation-of-duties enforcement.
/// Every status transition writes a DigitizationEvent with the actor, timestamp,
/// and metadata including field-level checks where applicable.
/// </summary>
public class VerificationService : IVerificationService
{
private readonly AppDbContext _db;
private readonly SiteConfigOptions _siteConfig;
private readonly ILogger<VerificationService> _logger;
public VerificationService(
AppDbContext db,
IOptions<SiteConfigOptions> siteConfig,
ILogger<VerificationService> logger)
{
_db = db;
_siteConfig = siteConfig.Value;
_logger = logger;
}
public async Task<DigitizationBatch> VerifyAsync(
Guid batchId, VerifyBatchRequest request, Guid verifierUserId)
{
var batch = await _db.DigitizationBatches
.Include(b => b.Events)
.FirstOrDefaultAsync(b => b.Id == batchId);
if (batch is null)
throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND");
// --- Status guard ---
if (batch.Status != BatchStatus.PendingVerification)
throw new ConflictException(
$"Batch is in '{batch.Status.ToDbString()}' status. " +
"Only batches in 'PENDING_VERIFICATION' can be verified.",
"ILLEGAL_STATUS_TRANSITION");
// --- Separation of duties ---
if (batch.EnteredByUserId == verifierUserId)
throw new ConflictException(
"The user who entered the data cannot verify the same batch. " +
"Assign a different verifier.",
"SEPARATION_OF_DUTIES_VIOLATION");
// --- Validate request ---
if (request.FieldChecks is null || request.FieldChecks.Count == 0)
throw new ValidationException(
"At least one field check is required.",
"FIELD_CHECKS_REQUIRED");
var invalidStatuses = request.FieldChecks
.Where(fc => fc.Status is not ("ok" or "warning" or "error"))
.Select(fc => fc.FieldName)
.ToList();
if (invalidStatuses.Count > 0)
throw new ValidationException(
$"Invalid status for fields: {string.Join(", ", invalidStatuses)}. " +
"Allowed values: ok, warning, error.",
"INVALID_FIELD_CHECK_STATUS");
// --- Build event metadata with field checks ---
var metadata = new
{
fieldChecks = request.FieldChecks.Select(fc => new
{
fieldName = fc.FieldName,
status = fc.Status,
note = fc.Note
}),
passed = request.Passed,
totalChecks = request.FieldChecks.Count,
errorCount = request.FieldChecks.Count(fc => fc.Status == "error"),
warningCount = request.FieldChecks.Count(fc => fc.Status == "warning")
};
if (request.Passed)
{
// --- Determine target status based on site config ---
var requiresClinical = _siteConfig.RequiresClinicalApproval(batch.BatchType);
var targetStatus = requiresClinical
? BatchStatus.AwaitingClinicalApproval
: BatchStatus.Verified;
batch.Status = targetStatus;
batch.VerifiedByUserId = verifierUserId;
batch.RejectionReason = null; // Clear any previous rejection reason
batch.UpdatedAt = DateTimeOffset.UtcNow;
_db.DigitizationEvents.Add(new DigitizationEvent
{
Id = Guid.NewGuid(),
BatchId = batchId,
EventType = requiresClinical ? DigitizationEventType.VerifiedPendingClinical : DigitizationEventType.Verified,
ActorUserId = verifierUserId,
OccurredAt = DateTimeOffset.UtcNow,
MetadataJson = JsonSerializer.Serialize(metadata)
});
await _db.SaveChangesAsync();
_logger.LogInformation(
"Batch {BatchId} verified by {VerifierUserId} -> {TargetStatus}",
batchId, verifierUserId, targetStatus.ToDbString());
}
else
{
// Verification failed — treat as rejection with field-level detail
var fieldErrors = request.FieldChecks
.Where(fc => fc.Status == "error")
.Select(fc => $"{fc.FieldName}: {fc.Note ?? "failed check"}")
.ToList();
var rejectionReason = fieldErrors.Count > 0
? $"Verification failed. Errors: {string.Join("; ", fieldErrors)}"
: "Verification failed. See field checks for details.";
batch.Status = BatchStatus.Rejected;
batch.RejectionReason = rejectionReason;
batch.VerifiedByUserId = null;
batch.UpdatedAt = DateTimeOffset.UtcNow;
_db.DigitizationEvents.Add(new DigitizationEvent
{
Id = Guid.NewGuid(),
BatchId = batchId,
EventType = DigitizationEventType.VerificationFailed,
ActorUserId = verifierUserId,
OccurredAt = DateTimeOffset.UtcNow,
MetadataJson = JsonSerializer.Serialize(metadata)
});
await _db.SaveChangesAsync();
_logger.LogInformation(
"Batch {BatchId} verification failed by {VerifierUserId}, rejected",
batchId, verifierUserId);
}
return batch;
}
public async Task<DigitizationBatch> RejectAsync(
Guid batchId, RejectBatchRequest request, Guid actorUserId)
{
var batch = await _db.DigitizationBatches
.FirstOrDefaultAsync(b => b.Id == batchId);
if (batch is null)
throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND");
// --- Status guard: reject is allowed from PendingVerification or AwaitingClinicalApproval ---
if (batch.Status is not (BatchStatus.PendingVerification or BatchStatus.AwaitingClinicalApproval))
throw new ConflictException(
$"Batch is in '{batch.Status.ToDbString()}' status. " +
"Only batches in 'PENDING_VERIFICATION' or 'AWAITING_CLINICAL_APPROVAL' can be rejected.",
"ILLEGAL_STATUS_TRANSITION");
// --- Separation of duties for rejection from PendingVerification ---
if (batch.Status == BatchStatus.PendingVerification && batch.EnteredByUserId == actorUserId)
throw new ConflictException(
"The user who entered the data cannot reject the same batch. " +
"Assign a different verifier.",
"SEPARATION_OF_DUTIES_VIOLATION");
// --- Validate reason ---
if (string.IsNullOrWhiteSpace(request.Reason))
throw new ValidationException(
"Rejection reason is required.",
"REJECTION_REASON_REQUIRED");
if (request.Reason.Length < 10)
throw new ValidationException(
"Rejection reason must be at least 10 characters.",
"REJECTION_REASON_TOO_SHORT");
// --- Apply rejection ---
var previousStatus = batch.Status;
batch.Status = BatchStatus.Rejected;
batch.RejectionReason = request.Reason;
batch.VerifiedByUserId = null;
batch.UpdatedAt = DateTimeOffset.UtcNow;
var metadata = new
{
reason = request.Reason,
previousStatus = previousStatus.ToDbString(),
rejectedByUserId = actorUserId
};
_db.DigitizationEvents.Add(new DigitizationEvent
{
Id = Guid.NewGuid(),
BatchId = batchId,
EventType = DigitizationEventType.Rejected,
ActorUserId = actorUserId,
OccurredAt = DateTimeOffset.UtcNow,
MetadataJson = JsonSerializer.Serialize(metadata)
});
await _db.SaveChangesAsync();
_logger.LogInformation(
"Batch {BatchId} rejected by {ActorUserId} from {PreviousStatus}: {Reason}",
batchId, actorUserId, previousStatus.ToDbString(), request.Reason);
return batch;
}
}
@@ -0,0 +1,83 @@
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;
public WorkQueueService(AppDbContext db)
{
_db = db;
}
public async Task<WorkQueueResponse> GetVerificationQueueAsync(int page, int pageSize)
{
var query = _db.DigitizationBatches
.Where(b => b.Status == BatchStatus.PendingVerification)
.OrderBy(b => b.UpdatedAt);
return await BuildQueueResponseAsync("verification", query, page, pageSize);
}
public async Task<WorkQueueResponse> GetEntryQueueAsync(int page, int pageSize)
{
var entryStatuses = new[]
{
BatchStatus.Uploaded,
BatchStatus.InEntry,
BatchStatus.Rejected
};
var query = _db.DigitizationBatches
.Where(b => entryStatuses.Contains(b.Status))
.OrderBy(b => b.UpdatedAt);
return await BuildQueueResponseAsync("entry", query, page, pageSize);
}
public async Task<WorkQueueResponse> GetClinicalApprovalQueueAsync(int page, int pageSize)
{
var query = _db.DigitizationBatches
.Where(b => b.Status == BatchStatus.AwaitingClinicalApproval)
.OrderBy(b => b.UpdatedAt);
return await BuildQueueResponseAsync("clinical-approval", query, page, pageSize);
}
private async Task<WorkQueueResponse> BuildQueueResponseAsync(
string queueName,
IOrderedQueryable<DigitizationBatch> query,
int page,
int pageSize)
{
var totalCount = await query.CountAsync();
var totalPages = (int)Math.Ceiling((double)totalCount / pageSize);
var items = await query
.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);
}
}