Clinical Sync Batch Engine: first commit
This commit is contained in:
@@ -187,6 +187,95 @@ public class ObservationService : IObservationService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ApplySyncedObservationAsync(SyncedObservation obs, CancellationToken ct)
|
||||
{
|
||||
var encounter = await _db.Encounters
|
||||
.Include(e => e.Patient)
|
||||
.FirstOrDefaultAsync(e => e.Id == obs.EncounterId, ct)
|
||||
?? throw new ValidationException("Encounter not found.", "ENCOUNTER_NOT_FOUND");
|
||||
|
||||
if (encounter.Status != EncounterStatus.Active)
|
||||
throw new ValidationException("Encounter not active.", "ENCOUNTER_NOT_ACTIVE");
|
||||
|
||||
await using var tx = await _db.Database.BeginTransactionAsync(ct);
|
||||
try
|
||||
{
|
||||
var observation = new Observation
|
||||
{
|
||||
Id = obs.ClientRef,
|
||||
EncounterId = obs.EncounterId,
|
||||
ObservationCode = obs.ObservationCode,
|
||||
Value = obs.Value,
|
||||
Unit = obs.Unit ?? "",
|
||||
Source = ObservationSourceExtensions.FromDbString(obs.Source),
|
||||
IdempotencyKey = obs.IdempotencyKey,
|
||||
RecordedAt = obs.RecordedAt,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
_db.Observations.Add(observation);
|
||||
|
||||
var threshold = await LoadThresholdAsync(obs.ObservationCode);
|
||||
if (threshold is not null && IsCriticalBreach(obs.Value, threshold))
|
||||
{
|
||||
var hasOpenAlert = await _db.ClinicalAlerts.AnyAsync(a =>
|
||||
a.EncounterId == obs.EncounterId
|
||||
&& a.ObservationId == observation.Id, ct);
|
||||
|
||||
if (!hasOpenAlert)
|
||||
{
|
||||
var alert = new ClinicalAlert
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
EncounterId = obs.EncounterId,
|
||||
PatientId = encounter.PatientId,
|
||||
ObservationId = observation.Id,
|
||||
AlertType = AlertTypeExtensions.CriticalFor(obs.ObservationCode),
|
||||
Severity = AlertSeverity.Critical,
|
||||
Details = BuildCriticalDetails(
|
||||
new IngestObservationRequest(obs.ObservationCode, obs.Value, obs.Unit ?? "",
|
||||
observation.Source, obs.RecordedAt, obs.IdempotencyKey),
|
||||
threshold),
|
||||
Status = AlertStatus.Open,
|
||||
TriggeredAt = obs.RecordedAt
|
||||
};
|
||||
_db.ClinicalAlerts.Add(alert);
|
||||
_db.OutboxEvents.Add(BuildOutboxEvent("alert.generated", new
|
||||
{
|
||||
alertId = alert.Id,
|
||||
encounterId = obs.EncounterId,
|
||||
patientId = encounter.PatientId,
|
||||
alertType = alert.AlertType.ToDbString(),
|
||||
severity = alert.Severity.ToDbString(),
|
||||
details = alert.Details,
|
||||
triggeredAt = alert.TriggeredAt,
|
||||
partitionKey = obs.EncounterId.ToString()
|
||||
}, obs.EncounterId.ToString()));
|
||||
}
|
||||
}
|
||||
|
||||
_db.OutboxEvents.Add(BuildOutboxEvent("observation.recorded", new
|
||||
{
|
||||
observationId = observation.Id,
|
||||
encounterId = obs.EncounterId,
|
||||
patientId = encounter.PatientId,
|
||||
observationCode = obs.ObservationCode,
|
||||
value = obs.Value,
|
||||
unit = obs.Unit,
|
||||
source = obs.Source,
|
||||
recordedAt = obs.RecordedAt,
|
||||
partitionKey = obs.EncounterId.ToString()
|
||||
}, obs.EncounterId.ToString()));
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
await tx.CommitAsync(ct);
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
// silent skip — idempotency key already applied
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ThresholdCacheEntry?> LoadThresholdAsync(string observationCode)
|
||||
{
|
||||
var cache = _redis.GetDatabase();
|
||||
|
||||
Reference in New Issue
Block a user