|
|
|
@@ -73,21 +73,8 @@ public class PromotionService : IPromotionService
|
|
|
|
|
"Separation of duties: the verifier cannot also approve the same batch.",
|
|
|
|
|
"SEPARATION_OF_DUTIES_VIOLATION");
|
|
|
|
|
|
|
|
|
|
// --- 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");
|
|
|
|
|
}
|
|
|
|
|
else if (!batch.PatientId.HasValue)
|
|
|
|
|
{
|
|
|
|
|
throw new ValidationException(
|
|
|
|
|
"Correction batch has no linked patient.",
|
|
|
|
|
"MISSING_PATIENT");
|
|
|
|
|
}
|
|
|
|
|
// --- Validate draft data completeness ---
|
|
|
|
|
ValidateDraftDataForPromotion(batch);
|
|
|
|
|
|
|
|
|
|
// --- Begin atomic transaction ---
|
|
|
|
|
await using var transaction = await _db.Database.BeginTransactionAsync();
|
|
|
|
@@ -96,92 +83,21 @@ public class PromotionService : IPromotionService
|
|
|
|
|
{
|
|
|
|
|
var now = DateTimeOffset.UtcNow;
|
|
|
|
|
|
|
|
|
|
// === Step 1: Create or update Patient ===
|
|
|
|
|
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 (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
|
|
|
|
|
};
|
|
|
|
|
var core = await ExecutePromotionCoreAsync(
|
|
|
|
|
batch, approverUserId, enableRetroactiveAlerts, now);
|
|
|
|
|
|
|
|
|
|
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 = batch.SupersedesBatchId.HasValue
|
|
|
|
|
? DigitizationEventType.CorrectionPromoted
|
|
|
|
|
: DigitizationEventType.Promoted,
|
|
|
|
|
ActorUserId = approverUserId,
|
|
|
|
|
OccurredAt = now,
|
|
|
|
|
MetadataJson = JsonSerializer.Serialize(promotionMetadata)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// === Step 6: Store idempotency record (within same transaction) ===
|
|
|
|
|
// === 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,
|
|
|
|
|
PatientId: core.Patient.Id,
|
|
|
|
|
Mrn: core.Patient.Mrn,
|
|
|
|
|
EncounterId: core.Encounter.Id,
|
|
|
|
|
ObservationIds: core.ObservationIds,
|
|
|
|
|
PromotedAt: now,
|
|
|
|
|
OutboxEventsWritten: outboxCount
|
|
|
|
|
OutboxEventsWritten: core.OutboxCount
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(idempotencyKey))
|
|
|
|
@@ -191,7 +107,7 @@ public class PromotionService : IPromotionService
|
|
|
|
|
200, result, TimeSpan.FromHours(24));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// === Step 7: Commit ===
|
|
|
|
|
// === Commit ===
|
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
|
await transaction.CommitAsync();
|
|
|
|
|
|
|
|
|
@@ -201,8 +117,8 @@ public class PromotionService : IPromotionService
|
|
|
|
|
_logger.LogInformation(
|
|
|
|
|
"Batch {BatchId} promoted in {ElapsedMs}ms: Patient {PatientId} (MRN {Mrn}), " +
|
|
|
|
|
"Encounter {EncounterId}, {ObsCount} observations, {OutboxCount} outbox events",
|
|
|
|
|
batchId, stopwatch.ElapsedMilliseconds, patient.Id, patient.Mrn, encounter.Id,
|
|
|
|
|
observationIds.Length, outboxCount);
|
|
|
|
|
batchId, stopwatch.ElapsedMilliseconds, core.Patient.Id, core.Patient.Mrn,
|
|
|
|
|
core.Encounter.Id, core.ObservationIds.Length, core.OutboxCount);
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
@@ -263,22 +179,133 @@ public class PromotionService : IPromotionService
|
|
|
|
|
// Private helpers
|
|
|
|
|
// ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
private record PromotionCoreResult(
|
|
|
|
|
Patient Patient,
|
|
|
|
|
Encounter Encounter,
|
|
|
|
|
Guid[] ObservationIds,
|
|
|
|
|
int OutboxCount,
|
|
|
|
|
SupersessionResult? Supersession);
|
|
|
|
|
|
|
|
|
|
private static void ValidateDraftDataForPromotion(DigitizationBatch batch)
|
|
|
|
|
{
|
|
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
else if (!batch.PatientId.HasValue)
|
|
|
|
|
{
|
|
|
|
|
throw new ValidationException(
|
|
|
|
|
"Correction batch has no linked patient.",
|
|
|
|
|
"MISSING_PATIENT");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<PromotionCoreResult> ExecutePromotionCoreAsync(
|
|
|
|
|
DigitizationBatch batch, Guid actorUserId, bool enableRetroactiveAlerts, DateTimeOffset now)
|
|
|
|
|
{
|
|
|
|
|
// === Step 1: Create or update Patient ===
|
|
|
|
|
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 + LiveEncounter ===
|
|
|
|
|
var encounter = await ResolveClinicalEncounterAsync(batch, patient.Id, now);
|
|
|
|
|
await EnsureLiveEncounterAsync(encounter, now);
|
|
|
|
|
|
|
|
|
|
// === Step 3: Insert clinical Observations + OutboxEvents ===
|
|
|
|
|
var (observationIds, outboxCount) = await PromoteObservationsAsync(
|
|
|
|
|
batch, patient.Id, encounter.Id, enableRetroactiveAlerts, now);
|
|
|
|
|
|
|
|
|
|
// === Step 4: Mirror to LiveObservations ===
|
|
|
|
|
PromoteLiveObservationsAsync(batch, patient.Id, encounter.Id, now);
|
|
|
|
|
|
|
|
|
|
// === Step 5: Handle supersession for correction batches ===
|
|
|
|
|
SupersessionResult? supersessionResult = null;
|
|
|
|
|
if (batch.SupersedesBatchId.HasValue)
|
|
|
|
|
{
|
|
|
|
|
supersessionResult = await SupersedeOriginalBatchAsync(
|
|
|
|
|
batch.SupersedesBatchId.Value, batch.Id, actorUserId, now);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// === Step 6: Update batch status to Promoted ===
|
|
|
|
|
batch.Status = BatchStatus.Promoted;
|
|
|
|
|
batch.PatientId = patient.Id;
|
|
|
|
|
batch.PromotedAt = now;
|
|
|
|
|
batch.PromotionEncounterId = encounter.Id;
|
|
|
|
|
batch.EnableRetroactiveAlerts = enableRetroactiveAlerts;
|
|
|
|
|
batch.UpdatedAt = now;
|
|
|
|
|
|
|
|
|
|
// === Step 7: 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 = batch.Id,
|
|
|
|
|
EventType = batch.SupersedesBatchId.HasValue
|
|
|
|
|
? DigitizationEventType.CorrectionPromoted
|
|
|
|
|
: DigitizationEventType.Promoted,
|
|
|
|
|
ActorUserId = actorUserId,
|
|
|
|
|
OccurredAt = now,
|
|
|
|
|
MetadataJson = JsonSerializer.Serialize(promotionMetadata)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return new PromotionCoreResult(patient, encounter, observationIds, outboxCount, supersessionResult);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string NormalizePatientName(string name) =>
|
|
|
|
|
string.Join(" ", name.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries))
|
|
|
|
|
.ToUpperInvariant();
|
|
|
|
|
|
|
|
|
|
private async Task<Patient> CreateOrUpdatePatientAsync(DraftPatient draft, DateTimeOffset now)
|
|
|
|
|
{
|
|
|
|
|
// Attempt to match existing patient by name + DOB (simple dedup)
|
|
|
|
|
var normalizedName = NormalizePatientName(draft.FullName!);
|
|
|
|
|
|
|
|
|
|
Patient? existing = null;
|
|
|
|
|
|
|
|
|
|
if (draft.DateOfBirth.HasValue)
|
|
|
|
|
{
|
|
|
|
|
existing = await _db.Patients
|
|
|
|
|
.FirstOrDefaultAsync(p =>
|
|
|
|
|
p.FullName == draft.FullName &&
|
|
|
|
|
EF.Functions.ILike(p.FullName, normalizedName) &&
|
|
|
|
|
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;
|
|
|
|
@@ -287,20 +314,43 @@ public class PromotionService : IPromotionService
|
|
|
|
|
existing.UpdatedAt = now;
|
|
|
|
|
|
|
|
|
|
_logger.LogInformation(
|
|
|
|
|
"Matched existing patient {PatientId} (MRN {Mrn}) by name + DOB",
|
|
|
|
|
"Matched existing patient {PatientId} (MRN {Mrn}) by normalized name + DOB",
|
|
|
|
|
existing.Id, existing.Mrn);
|
|
|
|
|
|
|
|
|
|
return existing;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create new patient with generated MRN
|
|
|
|
|
// No exact normalized match — check for fuzzy near-misses on same DOB
|
|
|
|
|
if (draft.DateOfBirth.HasValue)
|
|
|
|
|
{
|
|
|
|
|
var sameDobPatients = await _db.Patients
|
|
|
|
|
.Where(p => p.DateOfBirth == draft.DateOfBirth)
|
|
|
|
|
.Select(p => new { p.Id, p.Mrn, p.FullName })
|
|
|
|
|
.ToListAsync();
|
|
|
|
|
|
|
|
|
|
foreach (var candidate in sameDobPatients)
|
|
|
|
|
{
|
|
|
|
|
var distance = LevenshteinDistance(
|
|
|
|
|
normalizedName, NormalizePatientName(candidate.FullName));
|
|
|
|
|
|
|
|
|
|
if (distance > 0 && distance <= 3)
|
|
|
|
|
{
|
|
|
|
|
_logger.LogWarning(
|
|
|
|
|
"Fuzzy patient match: new name '{NewName}' is {Distance} edits from " +
|
|
|
|
|
"existing patient {PatientId} (MRN {Mrn}, name '{ExistingName}') with same DOB. " +
|
|
|
|
|
"Creating new patient — consider merging if these are the same person",
|
|
|
|
|
normalizedName, distance, candidate.Id, candidate.Mrn, candidate.FullName);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var mrn = await _mrnGenerator.GenerateNextMrnAsync();
|
|
|
|
|
|
|
|
|
|
var patient = new Patient
|
|
|
|
|
{
|
|
|
|
|
Id = Guid.NewGuid(),
|
|
|
|
|
Mrn = mrn,
|
|
|
|
|
FullName = draft.FullName!,
|
|
|
|
|
FullName = normalizedName,
|
|
|
|
|
DateOfBirth = draft.DateOfBirth,
|
|
|
|
|
Sex = draft.Sex,
|
|
|
|
|
BloodType = draft.BloodType,
|
|
|
|
@@ -320,6 +370,33 @@ public class PromotionService : IPromotionService
|
|
|
|
|
return patient;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static int LevenshteinDistance(string a, string b)
|
|
|
|
|
{
|
|
|
|
|
if (a.Length == 0) return b.Length;
|
|
|
|
|
if (b.Length == 0) return a.Length;
|
|
|
|
|
|
|
|
|
|
var prev = new int[b.Length + 1];
|
|
|
|
|
var curr = new int[b.Length + 1];
|
|
|
|
|
|
|
|
|
|
for (var j = 0; j <= b.Length; j++)
|
|
|
|
|
prev[j] = j;
|
|
|
|
|
|
|
|
|
|
for (var i = 1; i <= a.Length; i++)
|
|
|
|
|
{
|
|
|
|
|
curr[0] = i;
|
|
|
|
|
for (var j = 1; j <= b.Length; j++)
|
|
|
|
|
{
|
|
|
|
|
var cost = a[i - 1] == b[j - 1] ? 0 : 1;
|
|
|
|
|
curr[j] = Math.Min(
|
|
|
|
|
Math.Min(curr[j - 1] + 1, prev[j] + 1),
|
|
|
|
|
prev[j - 1] + cost);
|
|
|
|
|
}
|
|
|
|
|
(prev, curr) = (curr, prev);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return prev[b.Length];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<Encounter> ResolveClinicalEncounterAsync(
|
|
|
|
|
DigitizationBatch batch, Guid patientId, DateTimeOffset now)
|
|
|
|
|
{
|
|
|
|
@@ -536,7 +613,6 @@ public class PromotionService : IPromotionService
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
// Load the batch with all draft data
|
|
|
|
|
var batch = await _db.DigitizationBatches
|
|
|
|
|
.Include(b => b.DraftPatient)
|
|
|
|
|
.Include(b => b.DraftEncounter)
|
|
|
|
@@ -551,76 +627,13 @@ public class PromotionService : IPromotionService
|
|
|
|
|
$"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);
|
|
|
|
|
ValidateDraftDataForPromotion(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();
|
|
|
|
|
var enableRetroactiveAlerts = batch.EnableRetroactiveAlerts;
|
|
|
|
|
|
|
|
|
|
_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)
|
|
|
|
|
});
|
|
|
|
|
var core = await ExecutePromotionCoreAsync(
|
|
|
|
|
batch, actorUserId, enableRetroactiveAlerts, now);
|
|
|
|
|
|
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
|
await transaction.CommitAsync();
|
|
|
|
@@ -629,20 +642,21 @@ public class PromotionService : IPromotionService
|
|
|
|
|
DiagnosticsMetrics.PromotionDuration.Observe(stopwatch.Elapsed.TotalSeconds);
|
|
|
|
|
|
|
|
|
|
_logger.LogInformation(
|
|
|
|
|
"Batch {BatchId} promoted in {ElapsedMs}ms (correction={IsCorrection}, " +
|
|
|
|
|
"observations={ObservationCount}, superseded={SupersededCount})",
|
|
|
|
|
batchId,
|
|
|
|
|
stopwatch.ElapsedMilliseconds,
|
|
|
|
|
batch.SupersedesBatchId.HasValue,
|
|
|
|
|
liveObservations.Count,
|
|
|
|
|
supersessionResult?.ObservationsSuperseded ?? 0);
|
|
|
|
|
"Batch {BatchId} promoted via retry in {ElapsedMs}ms: Patient {PatientId} (MRN {Mrn}), " +
|
|
|
|
|
"Encounter {EncounterId}, {ObsCount} observations, {OutboxCount} outbox events",
|
|
|
|
|
batchId, stopwatch.ElapsedMilliseconds, core.Patient.Id, core.Patient.Mrn,
|
|
|
|
|
core.Encounter.Id, core.ObservationIds.Length, core.OutboxCount);
|
|
|
|
|
|
|
|
|
|
return new PromotionResult(
|
|
|
|
|
batch.Id,
|
|
|
|
|
encounterId,
|
|
|
|
|
liveObservations.Count,
|
|
|
|
|
core.Patient.Id,
|
|
|
|
|
core.Patient.Mrn,
|
|
|
|
|
core.Encounter.Id,
|
|
|
|
|
core.ObservationIds,
|
|
|
|
|
core.ObservationIds.Length,
|
|
|
|
|
core.OutboxCount,
|
|
|
|
|
batch.SupersedesBatchId.HasValue,
|
|
|
|
|
supersessionResult);
|
|
|
|
|
core.Supersession);
|
|
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
@@ -714,52 +728,4 @@ public class PromotionService : IPromotionService
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|