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> _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; public EncounterService(AppDbContext db, IQsofaService qsofa) { _db = db; _qsofa = qsofa; } public async Task 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> 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([], 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(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(summaries, page, pageSize, total); } public async Task 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 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() .Concat(alerts.Cast()) .OrderByDescending(x => (DateTimeOffset)((dynamic)x).timestamp) .ToList(); return new { encounterId, events = timeline }; } }