feature: Corrections and Supersession
This commit is contained in:
@@ -67,12 +67,21 @@ public class PromotionService : IPromotionService
|
||||
"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");
|
||||
// --- Validate draft data completeness (corrections reuse the linked live patient) ---
|
||||
if (!batch.SupersedesBatchId.HasValue)
|
||||
{
|
||||
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");
|
||||
if (string.IsNullOrWhiteSpace(batch.DraftPatient.FullName))
|
||||
throw new ValidationException("Draft patient must have a full name.", "INVALID_DRAFT_PATIENT");
|
||||
}
|
||||
else if (!batch.PatientId.HasValue)
|
||||
{
|
||||
throw new ValidationException(
|
||||
"Correction batch has no linked patient.",
|
||||
"MISSING_PATIENT");
|
||||
}
|
||||
|
||||
// --- Begin atomic transaction ---
|
||||
await using var transaction = await _db.Database.BeginTransactionAsync();
|
||||
@@ -82,41 +91,79 @@ public class PromotionService : IPromotionService
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// === Step 1: Create or update Patient ===
|
||||
var patient = await CreateOrUpdatePatientAsync(batch.DraftPatient, now);
|
||||
Patient patient;
|
||||
if (batch.SupersedesBatchId.HasValue)
|
||||
{
|
||||
patient = await _db.Patients.FirstOrDefaultAsync(p => p.Id == batch.PatientId!.Value)
|
||||
?? throw new NotFoundException(
|
||||
$"Patient {batch.PatientId.Value} not found.",
|
||||
"PATIENT_NOT_FOUND");
|
||||
}
|
||||
else
|
||||
{
|
||||
patient = await CreateOrUpdatePatientAsync(batch.DraftPatient!, now);
|
||||
}
|
||||
|
||||
// === Step 2: Create or match Encounter ===
|
||||
var encounter = await CreateOrMatchEncounterAsync(batch, patient.Id, now);
|
||||
// === Step 2: Create or match Encounter (reuse original encounter for corrections) ===
|
||||
var encounter = await ResolveClinicalEncounterAsync(batch, patient.Id, now);
|
||||
await EnsureLiveEncounterAsync(encounter, now);
|
||||
|
||||
// === Step 3: Insert each DraftObservation as live Observation ===
|
||||
var (observationIds, outboxCount) = await PromoteObservationsAsync(
|
||||
batch, patient.Id, encounter.Id, enableRetroactiveAlerts, now);
|
||||
|
||||
// === Step 3b: Mirror to live_observations for supersession / digitization history ===
|
||||
PromoteLiveObservationsAsync(batch, patient.Id, encounter.Id, now);
|
||||
|
||||
SupersessionResult? supersessionResult = null;
|
||||
if (batch.SupersedesBatchId.HasValue)
|
||||
{
|
||||
supersessionResult = await SupersedeOriginalBatchAsync(
|
||||
batch.SupersedesBatchId.Value, batchId, approverUserId, now);
|
||||
}
|
||||
|
||||
// === Step 4: Update batch status to Promoted ===
|
||||
batch.Status = BatchStatus.Promoted;
|
||||
batch.ApprovedByUserId = approverUserId;
|
||||
batch.PatientId = patient.Id;
|
||||
batch.PromotedAt = now;
|
||||
batch.PromotionEncounterId = encounter.Id;
|
||||
batch.EnableRetroactiveAlerts = enableRetroactiveAlerts;
|
||||
batch.UpdatedAt = now;
|
||||
|
||||
// === Step 5: Write DigitizationEvent ===
|
||||
var promotionMetadata = new Dictionary<string, object>
|
||||
{
|
||||
["patientId"] = patient.Id,
|
||||
["mrn"] = patient.Mrn,
|
||||
["encounterId"] = encounter.Id,
|
||||
["observationCount"] = observationIds.Length,
|
||||
["outboxEventsWritten"] = outboxCount,
|
||||
["enableRetroactiveAlerts"] = enableRetroactiveAlerts,
|
||||
["track"] = batch.Track.ToDbString(),
|
||||
["isCorrection"] = batch.SupersedesBatchId.HasValue
|
||||
};
|
||||
|
||||
if (supersessionResult is not null)
|
||||
{
|
||||
promotionMetadata["supersession"] = new
|
||||
{
|
||||
originalBatchId = supersessionResult.OriginalBatchId,
|
||||
observationsSuperseded = supersessionResult.ObservationsSuperseded,
|
||||
observationsReplaced = supersessionResult.ObservationsReplaced
|
||||
};
|
||||
}
|
||||
|
||||
_db.DigitizationEvents.Add(new DigitizationEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BatchId = batchId,
|
||||
EventType = DigitizationEventType.Promoted,
|
||||
EventType = batch.SupersedesBatchId.HasValue
|
||||
? DigitizationEventType.CorrectionPromoted
|
||||
: 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()
|
||||
})
|
||||
MetadataJson = JsonSerializer.Serialize(promotionMetadata)
|
||||
});
|
||||
|
||||
// === Step 6: Store idempotency record (within same transaction) ===
|
||||
@@ -258,6 +305,80 @@ public class PromotionService : IPromotionService
|
||||
return patient;
|
||||
}
|
||||
|
||||
private async Task<Encounter> ResolveClinicalEncounterAsync(
|
||||
DigitizationBatch batch, Guid patientId, DateTimeOffset now)
|
||||
{
|
||||
if (batch.SupersedesBatchId.HasValue)
|
||||
{
|
||||
var originalBatch = await _db.DigitizationBatches
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(b => b.Id == batch.SupersedesBatchId.Value);
|
||||
|
||||
if (originalBatch?.PromotionEncounterId is not Guid encounterId)
|
||||
throw new ValidationException(
|
||||
"Original batch has no promotion encounter for correction reuse.",
|
||||
"MISSING_ORIGINAL_ENCOUNTER");
|
||||
|
||||
var encounter = await _db.Encounters.FirstOrDefaultAsync(e => e.Id == encounterId);
|
||||
if (encounter is null)
|
||||
throw new NotFoundException(
|
||||
"Original promotion encounter not found.",
|
||||
"PROMOTION_ENCOUNTER_NOT_FOUND");
|
||||
|
||||
_logger.LogInformation(
|
||||
"Correction batch {CorrectionBatchId} reusing clinical encounter {EncounterId} " +
|
||||
"from original batch {OriginalBatchId}",
|
||||
batch.Id, encounter.Id, originalBatch.Id);
|
||||
|
||||
return encounter;
|
||||
}
|
||||
|
||||
return await CreateOrMatchEncounterAsync(batch, patientId, now);
|
||||
}
|
||||
|
||||
private async Task EnsureLiveEncounterAsync(Encounter encounter, DateTimeOffset now)
|
||||
{
|
||||
if (await _db.LiveEncounters.AnyAsync(e => e.Id == encounter.Id))
|
||||
return;
|
||||
|
||||
_db.LiveEncounters.Add(new LiveEncounter
|
||||
{
|
||||
Id = encounter.Id,
|
||||
PatientId = encounter.PatientId,
|
||||
AdmissionDate = encounter.AdmissionDate ?? now,
|
||||
Department = encounter.Department,
|
||||
RoomBed = encounter.RoomBed,
|
||||
AdmissionReason = encounter.AdmissionReason,
|
||||
DischargeDiagnosis = encounter.DischargeDiagnosis,
|
||||
Status = encounter.Status,
|
||||
CreatedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
private void PromoteLiveObservationsAsync(
|
||||
DigitizationBatch batch, Guid patientId, Guid encounterId, DateTimeOffset now)
|
||||
{
|
||||
foreach (var draft in batch.DraftObservations)
|
||||
{
|
||||
_db.LiveObservations.Add(new LiveObservation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
EncounterId = encounterId,
|
||||
PatientId = patientId,
|
||||
SourceBatchId = batch.Id,
|
||||
ObservationCode = draft.ObservationCode,
|
||||
Value = draft.Value,
|
||||
Unit = draft.Unit,
|
||||
RecordedAt = draft.RecordedAt,
|
||||
Note = draft.Note,
|
||||
CreatedAt = now,
|
||||
IsSuperseded = false,
|
||||
SupersededByBatchId = null,
|
||||
SupersededAt = null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Encounter> CreateOrMatchEncounterAsync(
|
||||
DigitizationBatch batch, Guid patientId, DateTimeOffset now)
|
||||
{
|
||||
@@ -389,4 +510,227 @@ public class PromotionService : IPromotionService
|
||||
|
||||
return (observationIds.ToArray(), outboxCount);
|
||||
}
|
||||
|
||||
public async Task<PromotionResult> PromoteAsync(Guid batchId, Guid actorUserId)
|
||||
{
|
||||
await using var transaction = await _db.Database.BeginTransactionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Load the 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");
|
||||
|
||||
if (batch.Status != BatchStatus.Approved)
|
||||
throw new ConflictException(
|
||||
$"Only approved batches can be promoted. Batch is in '{batch.Status.ToDbString()}' status.",
|
||||
"ILLEGAL_STATUS_TRANSITION");
|
||||
|
||||
// Resolve or create the live encounter
|
||||
var encounterId = await ResolveEncounterAsync(batch);
|
||||
|
||||
// Promote draft observations to live observations
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var liveObservations = batch.DraftObservations.Select(draft => new LiveObservation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
EncounterId = encounterId,
|
||||
PatientId = batch.PatientId,
|
||||
SourceBatchId = batch.Id,
|
||||
ObservationCode = draft.ObservationCode,
|
||||
Value = draft.Value,
|
||||
Unit = draft.Unit,
|
||||
RecordedAt = draft.RecordedAt,
|
||||
Note = draft.Note,
|
||||
CreatedAt = now,
|
||||
IsSuperseded = false,
|
||||
SupersededByBatchId = null,
|
||||
SupersededAt = null
|
||||
}).ToList();
|
||||
|
||||
_db.LiveObservations.AddRange(liveObservations);
|
||||
|
||||
// Handle supersession if this is a correction batch
|
||||
SupersessionResult? supersessionResult = null;
|
||||
if (batch.SupersedesBatchId.HasValue)
|
||||
{
|
||||
supersessionResult = await SupersedeOriginalBatchAsync(
|
||||
batch.SupersedesBatchId.Value,
|
||||
batch.Id,
|
||||
actorUserId,
|
||||
now);
|
||||
}
|
||||
|
||||
// Update batch status to Promoted
|
||||
batch.Status = BatchStatus.Promoted;
|
||||
batch.PromotedAt = now;
|
||||
batch.PromotionEncounterId = encounterId;
|
||||
batch.UpdatedAt = now;
|
||||
|
||||
// Record promotion event on the correction batch
|
||||
var promotionMetadata = new Dictionary<string, object>
|
||||
{
|
||||
["encounterId"] = encounterId,
|
||||
["observationsPromoted"] = liveObservations.Count,
|
||||
["isCorrection"] = batch.SupersedesBatchId.HasValue
|
||||
};
|
||||
|
||||
if (supersessionResult is not null)
|
||||
{
|
||||
promotionMetadata["supersession"] = new
|
||||
{
|
||||
originalBatchId = supersessionResult.OriginalBatchId,
|
||||
observationsSuperseded = supersessionResult.ObservationsSuperseded,
|
||||
observationsReplaced = supersessionResult.ObservationsReplaced
|
||||
};
|
||||
}
|
||||
|
||||
_db.DigitizationEvents.Add(new DigitizationEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BatchId = batch.Id,
|
||||
EventType = batch.SupersedesBatchId.HasValue
|
||||
? DigitizationEventType.CorrectionPromoted
|
||||
: DigitizationEventType.Promoted,
|
||||
ActorUserId = actorUserId,
|
||||
OccurredAt = now,
|
||||
MetadataJson = JsonSerializer.Serialize(promotionMetadata)
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Batch {BatchId} promoted (correction={IsCorrection}, " +
|
||||
"observations={ObservationCount}, superseded={SupersededCount})",
|
||||
batchId,
|
||||
batch.SupersedesBatchId.HasValue,
|
||||
liveObservations.Count,
|
||||
supersessionResult?.ObservationsSuperseded ?? 0);
|
||||
|
||||
return new PromotionResult(
|
||||
batch.Id,
|
||||
encounterId,
|
||||
liveObservations.Count,
|
||||
batch.SupersedesBatchId.HasValue,
|
||||
supersessionResult);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks all live observations sourced from the original batch as superseded.
|
||||
/// Records an audit event on the original batch documenting the supersession.
|
||||
/// </summary>
|
||||
private async Task<SupersessionResult> SupersedeOriginalBatchAsync(
|
||||
Guid originalBatchId, Guid correctionBatchId, Guid actorUserId, DateTimeOffset now)
|
||||
{
|
||||
// Load all live observations that came from the original erroneous batch
|
||||
var originalObservations = await _db.LiveObservations
|
||||
.Where(o => o.SourceBatchId == originalBatchId && !o.IsSuperseded)
|
||||
.ToListAsync();
|
||||
|
||||
if (originalObservations.Count == 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Supersession: no active live observations found for original batch {OriginalBatchId}",
|
||||
originalBatchId);
|
||||
}
|
||||
|
||||
// Mark each observation as superseded — never delete
|
||||
foreach (var obs in originalObservations)
|
||||
{
|
||||
obs.IsSuperseded = true;
|
||||
obs.SupersededByBatchId = correctionBatchId;
|
||||
obs.SupersededAt = now;
|
||||
}
|
||||
|
||||
// Count the correction batch's draft observations for the replacement count
|
||||
var replacementCount = await _db.DigitizationBatches
|
||||
.Where(b => b.Id == correctionBatchId)
|
||||
.SelectMany(b => b.DraftObservations)
|
||||
.CountAsync();
|
||||
|
||||
// Record supersession event on the original batch's audit trail
|
||||
_db.DigitizationEvents.Add(new DigitizationEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BatchId = originalBatchId,
|
||||
EventType = DigitizationEventType.Superseded,
|
||||
ActorUserId = actorUserId,
|
||||
OccurredAt = now,
|
||||
MetadataJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
supersededByBatchId = correctionBatchId,
|
||||
observationsSuperseded = originalObservations.Count,
|
||||
replacementObservations = replacementCount,
|
||||
reason = "Correction batch promoted — original observations marked superseded"
|
||||
})
|
||||
});
|
||||
|
||||
return new SupersessionResult(
|
||||
originalBatchId,
|
||||
originalObservations.Count,
|
||||
replacementCount,
|
||||
now);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the live encounter for promotion. For correction batches, reuses the
|
||||
/// encounter from the original batch to maintain continuity. For new batches,
|
||||
/// creates or resolves the encounter from draft data.
|
||||
/// </summary>
|
||||
private async Task<Guid> ResolveEncounterAsync(DigitizationBatch batch)
|
||||
{
|
||||
// For correction batches, reuse the encounter from the original promoted batch
|
||||
if (batch.SupersedesBatchId.HasValue)
|
||||
{
|
||||
var originalBatch = await _db.DigitizationBatches
|
||||
.FirstOrDefaultAsync(b => b.Id == batch.SupersedesBatchId.Value);
|
||||
|
||||
if (originalBatch?.PromotionEncounterId.HasValue == true)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Correction batch {CorrectionBatchId} reusing encounter {EncounterId} " +
|
||||
"from original batch {OriginalBatchId}",
|
||||
batch.Id, originalBatch.PromotionEncounterId.Value, originalBatch.Id);
|
||||
|
||||
return originalBatch.PromotionEncounterId.Value;
|
||||
}
|
||||
}
|
||||
|
||||
// For non-correction batches, create or find the encounter from draft data
|
||||
if (batch.DraftEncounter is null)
|
||||
throw new ValidationException(
|
||||
"Batch has no draft encounter data for promotion.",
|
||||
"MISSING_ENCOUNTER_DATA");
|
||||
|
||||
var encounter = new LiveEncounter
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PatientId = batch.PatientId
|
||||
?? throw new ValidationException("Patient ID is required for promotion.", "MISSING_PATIENT_ID"),
|
||||
AdmissionDate = batch.DraftEncounter.AdmissionDate
|
||||
?? throw new ValidationException("Admission date is required.", "MISSING_ADMISSION_DATE"),
|
||||
Department = batch.DraftEncounter.Department,
|
||||
RoomBed = batch.DraftEncounter.RoomBed,
|
||||
AdmissionReason = batch.DraftEncounter.AdmissionReason,
|
||||
DischargeDiagnosis = batch.DraftEncounter.DischargeDiagnosis,
|
||||
Status = batch.DraftEncounter.Status ?? "active",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_db.LiveEncounters.Add(encounter);
|
||||
return encounter.Id;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user