feature: Approval and Promotion to VigilCareClinical
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class PromotionService : IPromotionService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IIdempotencyService _idempotency;
|
||||
private readonly IMrnGenerator _mrnGenerator;
|
||||
private readonly ILogger<PromotionService> _logger;
|
||||
|
||||
public PromotionService(
|
||||
AppDbContext db,
|
||||
IIdempotencyService idempotency,
|
||||
IMrnGenerator mrnGenerator,
|
||||
ILogger<PromotionService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_idempotency = idempotency;
|
||||
_mrnGenerator = mrnGenerator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<PromotionResultResponse> ApproveAndPromoteAsync(
|
||||
Guid batchId, Guid approverUserId, bool enableRetroactiveAlerts, string? idempotencyKey)
|
||||
{
|
||||
// --- Idempotency check (before transaction) ---
|
||||
if (!string.IsNullOrWhiteSpace(idempotencyKey))
|
||||
{
|
||||
var existing = await _idempotency.GetExistingAsync(idempotencyKey, "batch_promote");
|
||||
if (existing is not null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Idempotent replay for batch {BatchId} with key {Key}",
|
||||
batchId, idempotencyKey);
|
||||
|
||||
return JsonSerializer.Deserialize<PromotionResultResponse>(
|
||||
existing.ResponseBodyJson,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Load batch with all draft data ---
|
||||
var batch = await _db.DigitizationBatches
|
||||
.Include(b => b.DraftPatient)
|
||||
.Include(b => b.DraftEncounter)
|
||||
.Include(b => b.DraftObservations)
|
||||
.FirstOrDefaultAsync(b => b.Id == batchId);
|
||||
|
||||
if (batch is null)
|
||||
throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND");
|
||||
|
||||
// --- Status validation ---
|
||||
if (batch.Status != BatchStatus.Verified && batch.Status != BatchStatus.AwaitingClinicalApproval)
|
||||
throw new ConflictException(
|
||||
$"Batch must be in 'verified' or 'awaiting_clinical_approval' status to approve. Current status: '{batch.Status.ToDbString()}'.",
|
||||
"ILLEGAL_STATUS_TRANSITION");
|
||||
|
||||
// --- Separation of duties: approver cannot be the entry clerk ---
|
||||
if (batch.EnteredByUserId == approverUserId)
|
||||
throw new ConflictException(
|
||||
"Separation of duties: the entry clerk cannot approve their own batch.",
|
||||
"SEPARATION_OF_DUTIES_VIOLATION");
|
||||
|
||||
// --- Also cannot be the verifier ---
|
||||
if (batch.VerifiedByUserId == approverUserId)
|
||||
throw new ConflictException(
|
||||
"Separation of duties: the verifier cannot also approve the same batch.",
|
||||
"SEPARATION_OF_DUTIES_VIOLATION");
|
||||
|
||||
// --- Validate draft data completeness ---
|
||||
if (batch.DraftPatient is null)
|
||||
throw new ValidationException("Batch has no draft patient data.", "MISSING_DRAFT_PATIENT");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(batch.DraftPatient.FullName))
|
||||
throw new ValidationException("Draft patient must have a full name.", "INVALID_DRAFT_PATIENT");
|
||||
|
||||
// --- Begin atomic transaction ---
|
||||
await using var transaction = await _db.Database.BeginTransactionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// === Step 1: Create or update Patient ===
|
||||
var patient = await CreateOrUpdatePatientAsync(batch.DraftPatient, now);
|
||||
|
||||
// === Step 2: Create or match Encounter ===
|
||||
var encounter = await CreateOrMatchEncounterAsync(batch, patient.Id, now);
|
||||
|
||||
// === Step 3: Insert each DraftObservation as live Observation ===
|
||||
var (observationIds, outboxCount) = await PromoteObservationsAsync(
|
||||
batch, patient.Id, encounter.Id, enableRetroactiveAlerts, now);
|
||||
|
||||
// === Step 4: Update batch status to Promoted ===
|
||||
batch.Status = BatchStatus.Promoted;
|
||||
batch.ApprovedByUserId = approverUserId;
|
||||
batch.PromotedAt = now;
|
||||
batch.PromotionEncounterId = encounter.Id;
|
||||
batch.EnableRetroactiveAlerts = enableRetroactiveAlerts;
|
||||
batch.UpdatedAt = now;
|
||||
|
||||
// === Step 5: Write DigitizationEvent ===
|
||||
_db.DigitizationEvents.Add(new DigitizationEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BatchId = batchId,
|
||||
EventType = DigitizationEventType.Promoted,
|
||||
ActorUserId = approverUserId,
|
||||
OccurredAt = now,
|
||||
MetadataJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
patientId = patient.Id,
|
||||
mrn = patient.Mrn,
|
||||
encounterId = encounter.Id,
|
||||
observationCount = observationIds.Length,
|
||||
outboxEventsWritten = outboxCount,
|
||||
enableRetroactiveAlerts,
|
||||
track = batch.Track.ToDbString()
|
||||
})
|
||||
});
|
||||
|
||||
// === Step 6: Store idempotency record (within same transaction) ===
|
||||
var result = new PromotionResultResponse(
|
||||
BatchId: batchId,
|
||||
Status: BatchStatus.Promoted.ToDbString(),
|
||||
PatientId: patient.Id,
|
||||
Mrn: patient.Mrn,
|
||||
EncounterId: encounter.Id,
|
||||
ObservationIds: observationIds,
|
||||
PromotedAt: now,
|
||||
OutboxEventsWritten: outboxCount
|
||||
);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(idempotencyKey))
|
||||
{
|
||||
await _idempotency.SaveAsync(
|
||||
idempotencyKey, "batch_promote", batchId,
|
||||
200, result, TimeSpan.FromHours(24));
|
||||
}
|
||||
|
||||
// === Step 7: Commit ===
|
||||
await _db.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Batch {BatchId} promoted: Patient {PatientId} (MRN {Mrn}), " +
|
||||
"Encounter {EncounterId}, {ObsCount} observations, {OutboxCount} outbox events",
|
||||
batchId, patient.Id, patient.Mrn, encounter.Id,
|
||||
observationIds.Length, outboxCount);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PromotionResultResponse> GetPromotionResultAsync(Guid batchId)
|
||||
{
|
||||
var batch = await _db.DigitizationBatches
|
||||
.FirstOrDefaultAsync(b => b.Id == batchId);
|
||||
|
||||
if (batch is null)
|
||||
throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND");
|
||||
|
||||
if (batch.Status != BatchStatus.Promoted)
|
||||
throw new ConflictException(
|
||||
$"Batch is not promoted. Current status: '{batch.Status.ToDbString()}'.",
|
||||
"BATCH_NOT_PROMOTED");
|
||||
|
||||
// Retrieve the live patient by looking up the encounter
|
||||
var encounter = await _db.Encounters
|
||||
.Include(e => e.Patient)
|
||||
.FirstOrDefaultAsync(e => e.Id == batch.PromotionEncounterId);
|
||||
|
||||
if (encounter is null)
|
||||
throw new NotFoundException(
|
||||
"Promotion encounter not found. Data may be inconsistent.",
|
||||
"PROMOTION_ENCOUNTER_NOT_FOUND");
|
||||
|
||||
var observationIds = await _db.Observations
|
||||
.Where(o => o.SourceBatchId == batchId)
|
||||
.Select(o => o.Id)
|
||||
.ToArrayAsync();
|
||||
|
||||
return new PromotionResultResponse(
|
||||
BatchId: batchId,
|
||||
Status: batch.Status.ToDbString(),
|
||||
PatientId: encounter.PatientId,
|
||||
Mrn: encounter.Patient.Mrn,
|
||||
EncounterId: encounter.Id,
|
||||
ObservationIds: observationIds,
|
||||
PromotedAt: batch.PromotedAt!.Value,
|
||||
OutboxEventsWritten: 0 // Historical count not stored; use event metadata
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
private async Task<Patient> CreateOrUpdatePatientAsync(DraftPatient draft, DateTimeOffset now)
|
||||
{
|
||||
// Attempt to match existing patient by name + DOB (simple dedup)
|
||||
Patient? existing = null;
|
||||
|
||||
if (draft.DateOfBirth.HasValue)
|
||||
{
|
||||
existing = await _db.Patients
|
||||
.FirstOrDefaultAsync(p =>
|
||||
p.FullName == draft.FullName &&
|
||||
p.DateOfBirth == draft.DateOfBirth);
|
||||
}
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
// Update fields that may have new information
|
||||
existing.Sex = draft.Sex ?? existing.Sex;
|
||||
existing.BloodType = draft.BloodType ?? existing.BloodType;
|
||||
existing.EmergencyContact = draft.EmergencyContact ?? existing.EmergencyContact;
|
||||
existing.AllergiesJson = draft.AllergiesJson ?? existing.AllergiesJson;
|
||||
existing.NoKnownAllergies = draft.NoKnownAllergies || existing.NoKnownAllergies;
|
||||
existing.UpdatedAt = now;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Matched existing patient {PatientId} (MRN {Mrn}) by name + DOB",
|
||||
existing.Id, existing.Mrn);
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Create new patient with generated MRN
|
||||
var mrn = await _mrnGenerator.GenerateNextMrnAsync();
|
||||
|
||||
var patient = new Patient
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Mrn = mrn,
|
||||
FullName = draft.FullName!,
|
||||
DateOfBirth = draft.DateOfBirth,
|
||||
Sex = draft.Sex,
|
||||
BloodType = draft.BloodType,
|
||||
EmergencyContact = draft.EmergencyContact,
|
||||
AllergiesJson = draft.AllergiesJson,
|
||||
NoKnownAllergies = draft.NoKnownAllergies,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
_db.Patients.Add(patient);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created new patient {PatientId} with MRN {Mrn}",
|
||||
patient.Id, mrn);
|
||||
|
||||
return patient;
|
||||
}
|
||||
|
||||
private async Task<Encounter> CreateOrMatchEncounterAsync(
|
||||
DigitizationBatch batch, Guid patientId, DateTimeOffset now)
|
||||
{
|
||||
// If the batch has a draft encounter, try to match an active encounter
|
||||
// for the same patient in the same department
|
||||
if (batch.DraftEncounter is not null)
|
||||
{
|
||||
var existingEncounter = await _db.Encounters
|
||||
.FirstOrDefaultAsync(e =>
|
||||
e.PatientId == patientId &&
|
||||
e.Department == batch.DraftEncounter.Department &&
|
||||
e.Status == "active" &&
|
||||
e.SourceBatchId != batch.Id);
|
||||
|
||||
if (existingEncounter is not null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Matched existing active encounter {EncounterId} for patient {PatientId}",
|
||||
existingEncounter.Id, patientId);
|
||||
|
||||
return existingEncounter;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine encounter status from draft
|
||||
var encounterStatus = batch.DraftEncounter?.Status ?? "active";
|
||||
if (batch.DraftEncounter?.DischargeDiagnosis is not null)
|
||||
{
|
||||
encounterStatus = "discharged";
|
||||
}
|
||||
|
||||
var encounter = new Encounter
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PatientId = patientId,
|
||||
AdmissionDate = batch.DraftEncounter?.AdmissionDate,
|
||||
Department = batch.DraftEncounter?.Department,
|
||||
RoomBed = batch.DraftEncounter?.RoomBed,
|
||||
AdmissionReason = batch.DraftEncounter?.AdmissionReason,
|
||||
DischargeDiagnosis = batch.DraftEncounter?.DischargeDiagnosis,
|
||||
Status = encounterStatus,
|
||||
SourceBatchId = batch.Id,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
_db.Encounters.Add(encounter);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created encounter {EncounterId} for patient {PatientId} with status '{Status}'",
|
||||
encounter.Id, patientId, encounterStatus);
|
||||
|
||||
return encounter;
|
||||
}
|
||||
|
||||
private async Task<(Guid[] observationIds, int outboxCount)> PromoteObservationsAsync(
|
||||
DigitizationBatch batch, Guid patientId, Guid encounterId,
|
||||
bool enableRetroactiveAlerts, DateTimeOffset now)
|
||||
{
|
||||
var observationIds = new List<Guid>();
|
||||
var outboxCount = 0;
|
||||
|
||||
// Determine the source label based on batch track
|
||||
var source = batch.Track == BatchTrack.LiveCapture
|
||||
? "live_capture"
|
||||
: "digitization_backfill";
|
||||
|
||||
// Determine whether outbox events should be written for this batch
|
||||
var shouldAlert = batch.Track == BatchTrack.LiveCapture || enableRetroactiveAlerts;
|
||||
|
||||
foreach (var draft in batch.DraftObservations)
|
||||
{
|
||||
var observationId = Guid.NewGuid();
|
||||
|
||||
var observation = new Observation
|
||||
{
|
||||
Id = observationId,
|
||||
EncounterId = encounterId,
|
||||
PatientId = patientId,
|
||||
ObservationCode = draft.ObservationCode,
|
||||
Value = draft.Value,
|
||||
Unit = draft.Unit,
|
||||
RecordedAt = draft.RecordedAt,
|
||||
Note = draft.Note,
|
||||
Source = source,
|
||||
SourceDraftObservationId = draft.Id,
|
||||
SourceBatchId = batch.Id,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
_db.Observations.Add(observation);
|
||||
observationIds.Add(observationId);
|
||||
|
||||
// Write outbox event only if alerting is enabled for this batch
|
||||
if (shouldAlert)
|
||||
{
|
||||
_db.OutboxEvents.Add(new OutboxEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
EventType = "observation.created",
|
||||
AggregateType = "Observation",
|
||||
AggregateId = observationId,
|
||||
PayloadJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
observationId,
|
||||
encounterId,
|
||||
patientId,
|
||||
observationCode = draft.ObservationCode,
|
||||
value = draft.Value,
|
||||
unit = draft.Unit,
|
||||
recordedAt = draft.RecordedAt,
|
||||
source,
|
||||
batchId = batch.Id,
|
||||
track = batch.Track.ToDbString()
|
||||
}),
|
||||
CreatedAt = now,
|
||||
ProcessedAt = null,
|
||||
RetryCount = 0
|
||||
});
|
||||
|
||||
outboxCount++;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Promoted {Count} observations for batch {BatchId}, " +
|
||||
"{OutboxCount} outbox events (shouldAlert={ShouldAlert}, track={Track})",
|
||||
observationIds.Count, batch.Id, outboxCount, shouldAlert, batch.Track.ToDbString());
|
||||
|
||||
return (observationIds.ToArray(), outboxCount);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user