feature: Medication Tracking & Vital Sign Correlation

This commit is contained in:
voltsrage
2026-06-19 10:47:55 +08:00
parent e2d40331b7
commit 5c7e37471b
29 changed files with 2512 additions and 4 deletions
@@ -0,0 +1,10 @@
public interface IMedicationService
{
Task<MedicationAdministration> CreateAsync(
Guid encounterId, CreateMedicationAdministrationRequest req);
Task<PagedResult<MedicationAdministration>> ListByEncounterAsync(
Guid encounterId, DateTimeOffset? since, int page, int pageSize);
Task<MedicationAdministration> GetByIdAsync(Guid id);
Task<IReadOnlyList<MedicationAdministration>> GetRecentForEncounterAsync(
Guid encounterId, string observationCode, int windowMinutes);
}
@@ -0,0 +1,102 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
public class MedicationService : IMedicationService
{
private readonly AppDbContext _db;
private readonly MedicationCorrelationOptions _correlationOptions;
public MedicationService(
AppDbContext db,
IOptions<MedicationCorrelationOptions> correlationOptions)
{
_db = db;
_correlationOptions = correlationOptions.Value;
}
public async Task<MedicationAdministration> CreateAsync(
Guid encounterId, CreateMedicationAdministrationRequest req)
{
var encounter = await _db.Encounters.FindAsync(encounterId);
if (encounter is null)
throw new NotFoundException("Encounter not found.", "ENCOUNTER_NOT_FOUND");
if (encounter.Status != EncounterStatus.Active)
throw new ConflictException(
"Cannot record medications for a non-active encounter.",
"ENCOUNTER_NOT_ACTIVE");
var med = new MedicationAdministration
{
Id = Guid.NewGuid(),
EncounterId = encounterId,
DrugName = req.DrugName.Trim(),
Dose = req.Dose,
DoseUnit = req.DoseUnit.Trim(),
Route = req.Route.Trim(),
AdministeredAt = req.AdministeredAt ?? DateTimeOffset.UtcNow,
AdministeredBy = req.AdministeredBy.Trim()
};
_db.MedicationAdministrations.Add(med);
await _db.SaveChangesAsync();
return med;
}
public async Task<MedicationAdministration> GetByIdAsync(Guid id)
{
var med = await _db.MedicationAdministrations
.AsNoTracking()
.Include(m => m.Encounter)
.FirstOrDefaultAsync(m => m.Id == id);
if (med is null)
throw new NotFoundException("Medication administration not found.", "MEDICATION_NOT_FOUND");
return med;
}
public async Task<IReadOnlyList<MedicationAdministration>> GetRecentForEncounterAsync(
Guid encounterId, string observationCode, int windowMinutes)
{
var cutoff = DateTimeOffset.UtcNow.AddMinutes(-windowMinutes);
var mappings = _correlationOptions.DrugVitalMappings;
// Find drugs that affect this observation code
var relevantDrugs = mappings
.Where(kv => kv.Value.Contains(observationCode, StringComparer.OrdinalIgnoreCase))
.Select(kv => kv.Key)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (relevantDrugs.Count == 0)
return Array.Empty<MedicationAdministration>();
return await _db.MedicationAdministrations
.AsNoTracking()
.Where(m => m.EncounterId == encounterId
&& m.AdministeredAt >= cutoff
&& relevantDrugs.Contains(m.DrugName.ToLower()))
.OrderByDescending(m => m.AdministeredAt)
.ToListAsync();
}
public async Task<PagedResult<MedicationAdministration>> ListByEncounterAsync(
Guid encounterId, DateTimeOffset? since, int page, int pageSize)
{
var query = _db.MedicationAdministrations
.AsNoTracking()
.Where(m => m.EncounterId == encounterId);
if (since.HasValue)
query = query.Where(m => m.AdministeredAt >= since.Value);
var total = await query.CountAsync();
var items = await query
.OrderByDescending(m => m.AdministeredAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return new PagedResult<MedicationAdministration>(items, page, pageSize, total);
}
}
@@ -6,12 +6,12 @@ public static class PlausibilityValidator
private static readonly Dictionary<string, (decimal Min, decimal Max)> _ranges = new()
{
["HEART_RATE"] = (1, 300),
["TEMP_C"] = (20, 50),
["POTASSIUM_MEQ_L"] = (0.1m, 15),
["TEMP_C"] = (15, 50),
["POTASSIUM_MEQ_L"] = (0.1m, 12),
["SPO2"] = (50, 100),
["RESP_RATE"] = (1, 80),
["WBC_K_UL"] = (0.1m, 500),
["GLUCOSE_MG_DL"] = (10, 1500),
["GLUCOSE_MG_DL"] = (10, 1000),
["SYSTOLIC_BP"] = (40, 300),
["DIASTOLIC_BP"] = (20, 200),
["LACTATE_MMOL_L"] = (0.1m, 30),
@@ -90,6 +90,10 @@ public class WarningEvaluator
var triggeredAt = DateTimeOffset.UtcNow;
var details = BuildWarningDetails(observationCode, value, threshold);
var correlation = scope.ServiceProvider.GetRequiredService<MedicationCorrelationHelper>();
details = await correlation.TryAnnotateDetailsAsync(
encounterId, observationCode, details, ct);
var affected = await db.Database.ExecuteSqlInterpolatedAsync($"""
INSERT INTO clinical_alerts
(id, encounter_id, patient_id, observation_id, alert_type, severity, details, status, triggered_at)