Clinical Sync Batch Engine: first commit

This commit is contained in:
voltsrage
2026-06-23 03:02:30 +08:00
parent 90b8baa2a1
commit c9994b1ba2
60 changed files with 3547 additions and 31 deletions
@@ -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();