288 lines
11 KiB
C#
288 lines
11 KiB
C#
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
public class EncounterService : IEncounterService
|
|
{
|
|
// Explicit transition matrix. Every allowed move is listed here.
|
|
// Any transition not in this dictionary is illegal and throws ConflictException.
|
|
private static readonly Dictionary<EncounterStatus, HashSet<EncounterStatus>> _allowedTransitions = new()
|
|
{
|
|
[EncounterStatus.Scheduled] = new() { EncounterStatus.Active, EncounterStatus.Cancelled },
|
|
[EncounterStatus.Active] = new() { EncounterStatus.Discharged, EncounterStatus.Cancelled },
|
|
[EncounterStatus.Discharged] = new(),
|
|
[EncounterStatus.Cancelled] = new(),
|
|
};
|
|
|
|
private readonly AppDbContext _db;
|
|
private readonly IQsofaService _qsofa;
|
|
private readonly IExternalIdentifierService _identifiers;
|
|
|
|
public EncounterService(
|
|
AppDbContext db,
|
|
IQsofaService qsofa,
|
|
IExternalIdentifierService identifiers)
|
|
{
|
|
_db = db;
|
|
_qsofa = qsofa;
|
|
_identifiers = identifiers;
|
|
}
|
|
|
|
public async Task<Encounter> GetByIdAsync(Guid id)
|
|
{
|
|
var encounter = await _db.Encounters
|
|
.Include(e => e.Patient)
|
|
.Include(e => e.Observations.OrderByDescending(o => o.RecordedAt).Take(10))
|
|
.Include(e => e.Alerts.Where(a => a.Status == AlertStatus.Open))
|
|
.FirstOrDefaultAsync(e => e.Id == id);
|
|
|
|
if (encounter is null)
|
|
throw new NotFoundException("Encounter not found.", "ENCOUNTER_NOT_FOUND");
|
|
|
|
return encounter;
|
|
}
|
|
|
|
public async Task<PagedResult<WardEncounterSummary>> ListAsync(
|
|
EncounterStatus? status, Department? department, int page, int pageSize)
|
|
{
|
|
page = Math.Max(1, page);
|
|
pageSize = Math.Clamp(pageSize, 1, 100);
|
|
|
|
var query = _db.Encounters
|
|
.AsNoTracking()
|
|
.Include(e => e.Patient)
|
|
.AsQueryable();
|
|
|
|
if (status.HasValue)
|
|
query = query.Where(e => e.Status == status.Value);
|
|
|
|
if (department.HasValue)
|
|
query = query.Where(e => e.Department == department.Value);
|
|
|
|
var total = await query.CountAsync();
|
|
|
|
var encounters = await query
|
|
.OrderByDescending(e => e.AdmittedAt)
|
|
.Skip((page - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.ToListAsync();
|
|
|
|
if (encounters.Count == 0)
|
|
return new PagedResult<WardEncounterSummary>([], page, pageSize, total);
|
|
|
|
var encounterIds = encounters.Select(e => e.Id).ToList();
|
|
|
|
var news2ByEncounter = (await _db.News2Scores
|
|
.AsNoTracking()
|
|
.Where(s => encounterIds.Contains(s.EncounterId))
|
|
.OrderByDescending(s => s.CalculatedAt)
|
|
.ToListAsync())
|
|
.GroupBy(s => s.EncounterId)
|
|
.ToDictionary(g => g.Key, g => g.First());
|
|
|
|
var openAlertCounts = await _db.ClinicalAlerts
|
|
.AsNoTracking()
|
|
.Where(a => encounterIds.Contains(a.EncounterId) && a.Status == AlertStatus.Open)
|
|
.GroupBy(a => a.EncounterId)
|
|
.Select(g => new { EncounterId = g.Key, Count = g.Count() })
|
|
.ToDictionaryAsync(x => x.EncounterId, x => x.Count);
|
|
|
|
var bundlesByEncounter = (await _db.SepsisBundles
|
|
.AsNoTracking()
|
|
.Where(b => encounterIds.Contains(b.EncounterId))
|
|
.OrderByDescending(b => b.RecognizedAt)
|
|
.ToListAsync())
|
|
.GroupBy(b => b.EncounterId)
|
|
.ToDictionary(g => g.Key, g => g.First());
|
|
|
|
var summaries = new List<WardEncounterSummary>(encounters.Count);
|
|
foreach (var encounter in encounters)
|
|
{
|
|
news2ByEncounter.TryGetValue(encounter.Id, out var news2);
|
|
bundlesByEncounter.TryGetValue(encounter.Id, out var bundle);
|
|
openAlertCounts.TryGetValue(encounter.Id, out var openCount);
|
|
|
|
var qsofaScore = await _qsofa.GetActiveCriteriaCountAsync(encounter.Id);
|
|
|
|
summaries.Add(new WardEncounterSummary(
|
|
encounter.Id,
|
|
encounter.PatientId,
|
|
encounter.Patient.Mrn,
|
|
encounter.Patient.FirstName,
|
|
encounter.Patient.LastName,
|
|
encounter.RoomBed,
|
|
encounter.Department,
|
|
encounter.Status,
|
|
news2?.TotalScore,
|
|
news2?.RiskLevel,
|
|
qsofaScore,
|
|
bundle is not null && bundle.ComplianceStatus != SepsisBundleComplianceStatus.Compliant,
|
|
bundle?.ComplianceStatus,
|
|
openCount));
|
|
}
|
|
|
|
return new PagedResult<WardEncounterSummary>(summaries, page, pageSize, total);
|
|
}
|
|
|
|
public async Task<EncounterStatusTransitionResult> TransitionStatusAsync(
|
|
Guid encounterId, EncounterStatus targetStatus, string? dischargeDiagnosis = null)
|
|
{
|
|
var encounter = await _db.Encounters
|
|
.Include(e => e.Patient)
|
|
.FirstOrDefaultAsync(e => e.Id == encounterId);
|
|
|
|
if (encounter is null)
|
|
throw new NotFoundException("Encounter not found.", "ENCOUNTER_NOT_FOUND");
|
|
|
|
if (!_allowedTransitions[encounter.Status].Contains(targetStatus))
|
|
throw new ConflictException(
|
|
$"Transition to '{targetStatus}' is not permitted from the current status.",
|
|
"ILLEGAL_STATUS_TRANSITION");
|
|
|
|
var previousStatus = encounter.Status;
|
|
encounter.Status = targetStatus;
|
|
|
|
if (targetStatus == EncounterStatus.Discharged)
|
|
{
|
|
encounter.DischargedAt = DateTimeOffset.UtcNow;
|
|
encounter.DischargeDiagnosis = dischargeDiagnosis;
|
|
}
|
|
|
|
_db.OutboxEvents.Add(new OutboxEvent
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Topic = "encounter.status.changed",
|
|
Payload = JsonSerializer.Serialize(new
|
|
{
|
|
encounterId,
|
|
patientId = encounter.PatientId,
|
|
mrn = encounter.Patient.Mrn,
|
|
patientName = $"{encounter.Patient.FirstName} {encounter.Patient.LastName}",
|
|
previousStatus = previousStatus.ToDbString(),
|
|
newStatus = targetStatus.ToDbString(),
|
|
department = encounter.Department.ToDbString(),
|
|
attendingPhysician = encounter.AttendingPhysician,
|
|
roomBed = encounter.RoomBed,
|
|
admissionReason = encounter.AdmissionReason,
|
|
admittedAt = encounter.AdmittedAt,
|
|
changedAt = DateTimeOffset.UtcNow
|
|
}),
|
|
PartitionKey = encounterId.ToString(),
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
|
|
await _db.SaveChangesAsync();
|
|
return new EncounterStatusTransitionResult(encounterId, targetStatus);
|
|
}
|
|
|
|
public async Task<object> GetTimelineAsync(Guid encounterId)
|
|
{
|
|
var encounter = await _db.Encounters.FindAsync(encounterId);
|
|
if (encounter is null)
|
|
throw new NotFoundException("Encounter not found.", "ENCOUNTER_NOT_FOUND");
|
|
|
|
var observations = await _db.Observations
|
|
.Where(o => o.EncounterId == encounterId)
|
|
.OrderByDescending(o => o.RecordedAt)
|
|
.Select(o => new { type = "observation", timestamp = o.RecordedAt, o.ObservationCode, o.Value, o.Unit })
|
|
.ToListAsync();
|
|
|
|
var alerts = await _db.ClinicalAlerts
|
|
.Where(a => a.EncounterId == encounterId)
|
|
.OrderByDescending(a => a.TriggeredAt)
|
|
.Select(a => new { type = "alert", timestamp = a.TriggeredAt, a.AlertType, a.Severity, a.Status })
|
|
.ToListAsync();
|
|
|
|
var timeline = observations.Cast<object>()
|
|
.Concat(alerts.Cast<object>())
|
|
.OrderByDescending(x => (DateTimeOffset)((dynamic)x).timestamp)
|
|
.ToList();
|
|
|
|
return new { encounterId, events = timeline };
|
|
}
|
|
|
|
public async Task<Encounter> OpenOrUpdateByIdentifierAsync(FhirEncounterUpsertRequest req)
|
|
{
|
|
var existingId = await _identifiers.ResolveInternalIdAsync(
|
|
ExternalResourceType.Encounter, req.IdentifierSystem, req.IdentifierValue);
|
|
|
|
if (existingId.HasValue)
|
|
{
|
|
var existingEncounter = await _db.Encounters
|
|
.Include(e => e.Patient)
|
|
.FirstOrDefaultAsync(e => e.Id == existingId.Value)
|
|
?? throw new NotFoundException("Encounter not found.", "ENCOUNTER_NOT_FOUND");
|
|
|
|
existingEncounter.Department = req.Department;
|
|
existingEncounter.AttendingPhysician = req.AttendingPhysician;
|
|
existingEncounter.RoomBed = req.RoomBed;
|
|
existingEncounter.AdmissionReason = req.AdmissionReason;
|
|
|
|
if (existingEncounter.Status != req.TargetStatus)
|
|
{
|
|
await TransitionStatusAsync(
|
|
existingEncounter.Id, req.TargetStatus, req.DischargeDiagnosis);
|
|
await _db.Entry(existingEncounter).ReloadAsync();
|
|
}
|
|
|
|
return existingEncounter;
|
|
}
|
|
|
|
// Create new encounter — mirrors PatientService.OpenEncounterAsync
|
|
var patient = await _db.Patients.FindAsync(req.PatientId);
|
|
if (patient is null)
|
|
throw new NotFoundException("Patient not found.", "PATIENT_NOT_FOUND");
|
|
|
|
var encounter = new Encounter
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
PatientId = req.PatientId,
|
|
EncounterType = req.EncounterType,
|
|
Status = EncounterStatus.Active,
|
|
Department = req.Department,
|
|
AttendingPhysician = req.AttendingPhysician,
|
|
RoomBed = req.RoomBed,
|
|
AdmissionReason = req.AdmissionReason,
|
|
AdmittedAt = req.AdmittedAt ?? DateTimeOffset.UtcNow,
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
|
|
_db.Encounters.Add(encounter);
|
|
|
|
_db.OutboxEvents.Add(new OutboxEvent
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Topic = "encounter.status.changed",
|
|
Payload = JsonSerializer.Serialize(new
|
|
{
|
|
encounterId = encounter.Id,
|
|
patientId = patient.Id,
|
|
mrn = patient.Mrn,
|
|
patientName = $"{patient.FirstName} {patient.LastName}",
|
|
previousStatus = (string?)null,
|
|
newStatus = encounter.Status.ToDbString(),
|
|
department = encounter.Department.ToDbString(),
|
|
attendingPhysician = encounter.AttendingPhysician,
|
|
roomBed = encounter.RoomBed,
|
|
admissionReason = encounter.AdmissionReason,
|
|
admittedAt = encounter.AdmittedAt,
|
|
changedAt = DateTimeOffset.UtcNow
|
|
}),
|
|
PartitionKey = encounter.Id.ToString(),
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
await _identifiers.LinkAsync(
|
|
ExternalResourceType.Encounter,
|
|
encounter.Id,
|
|
req.IdentifierSystem,
|
|
req.IdentifierValue);
|
|
|
|
if (req.TargetStatus == EncounterStatus.Discharged)
|
|
await TransitionStatusAsync(encounter.Id, EncounterStatus.Discharged, req.DischargeDiagnosis);
|
|
|
|
return encounter;
|
|
}
|
|
}
|