221 lines
8.1 KiB
C#
221 lines
8.1 KiB
C#
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
public class SepsisBundleService : ISepsisBundleService
|
|
{
|
|
private readonly AppDbContext _db;
|
|
private readonly IOrderService _orderService;
|
|
private readonly ClinicalMetrics _metrics;
|
|
|
|
public SepsisBundleService(AppDbContext db, IOrderService orderService, ClinicalMetrics metrics)
|
|
{
|
|
_db = db;
|
|
_orderService = orderService;
|
|
_metrics = metrics;
|
|
}
|
|
|
|
public async Task<SepsisBundle?> TryCreateBundleAsync(
|
|
Guid encounterId, Guid triggeringAlertId, AlertType alertType, CancellationToken ct = default)
|
|
{
|
|
if (alertType != AlertType.SofaSepsis)
|
|
throw new InvalidOperationException(
|
|
$"Sepsis bundle can only be triggered by SOFA_SEPSIS, not {alertType.ToDbString()}.");
|
|
|
|
var exists = await _db.SepsisBundles
|
|
.AnyAsync(b => b.EncounterId == encounterId
|
|
&& b.ComplianceStatus == SepsisBundleComplianceStatus.InProgress, ct);
|
|
|
|
if (exists)
|
|
return null;
|
|
|
|
await using var tx = await _db.Database.BeginTransactionAsync(ct);
|
|
try
|
|
{
|
|
var recognizedAt = DateTimeOffset.UtcNow;
|
|
var bundle = new SepsisBundle
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
EncounterId = encounterId,
|
|
TriggeringAlertId = triggeringAlertId,
|
|
TriggeringAlertType = alertType.ToDbString(),
|
|
RecognizedAt = recognizedAt,
|
|
DeadlineAt = recognizedAt.AddHours(1),
|
|
ComplianceStatus = SepsisBundleComplianceStatus.InProgress
|
|
};
|
|
|
|
var elementDefs = new (SepsisBundleElementType Type, OrderType OrderType, string Description)[]
|
|
{
|
|
(SepsisBundleElementType.BloodCultures, OrderType.Lab, "SEP-1: Blood cultures"),
|
|
(SepsisBundleElementType.SerumLactate, OrderType.Lab, "SEP-1: Serum lactate"),
|
|
(SepsisBundleElementType.BroadSpectrumAntibiotics, OrderType.Medication, "SEP-1: Broad-spectrum antibiotics"),
|
|
(SepsisBundleElementType.IvFluidResuscitation, OrderType.Procedure, "SEP-1: IV fluid bolus"),
|
|
};
|
|
|
|
foreach (var (elementType, orderType, description) in elementDefs)
|
|
{
|
|
var order = await _orderService.CreateAsync(encounterId, new CreateOrderRequest(
|
|
orderType, description, "sepsis-bundle-engine"));
|
|
|
|
bundle.Elements.Add(new SepsisBundleElement
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ElementType = elementType,
|
|
Status = SepsisBundleElementStatus.Pending,
|
|
OrderId = order.Id
|
|
});
|
|
}
|
|
|
|
_db.SepsisBundles.Add(bundle);
|
|
|
|
_db.OutboxEvents.Add(new OutboxEvent
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Topic = "sepsis.bundle.created",
|
|
Payload = JsonSerializer.Serialize(new
|
|
{
|
|
bundleId = bundle.Id,
|
|
encounterId,
|
|
triggeringAlertId,
|
|
triggeringAlertType = alertType.ToDbString(),
|
|
recognizedAt,
|
|
deadlineAt = bundle.DeadlineAt,
|
|
partitionKey = encounterId.ToString()
|
|
}),
|
|
PartitionKey = encounterId.ToString(),
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
|
|
await _db.SaveChangesAsync(ct);
|
|
await tx.CommitAsync(ct);
|
|
return bundle;
|
|
}
|
|
catch (DbUpdateException ex)
|
|
when (ex.InnerException is Npgsql.PostgresException { SqlState: "23505" } pg
|
|
&& pg.ConstraintName == "ix_sepsis_bundles_encounter_in_progress")
|
|
{
|
|
await tx.RollbackAsync(ct);
|
|
_db.ChangeTracker.Clear();
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public async Task<SepsisBundle?> GetCurrentByEncounterAsync(Guid encounterId)
|
|
{
|
|
return await _db.SepsisBundles
|
|
.AsNoTracking()
|
|
.Include(b => b.Elements)
|
|
.ThenInclude(e => e.Order)
|
|
.Where(b => b.EncounterId == encounterId)
|
|
.OrderByDescending(b => b.RecognizedAt)
|
|
.FirstOrDefaultAsync();
|
|
}
|
|
|
|
public async Task<SepsisBundle> GetByIdAsync(Guid id)
|
|
{
|
|
var bundle = await _db.SepsisBundles
|
|
.AsNoTracking()
|
|
.Include(b => b.Elements)
|
|
.ThenInclude(e => e.Order)
|
|
.FirstOrDefaultAsync(b => b.Id == id);
|
|
|
|
if (bundle is null)
|
|
throw new NotFoundException("Sepsis bundle not found.", "BUNDLE_NOT_FOUND");
|
|
|
|
return bundle;
|
|
}
|
|
|
|
public async Task<PagedResult<SepsisBundleSummary>> ListAsync(
|
|
SepsisBundleComplianceStatus? status, int page, int pageSize)
|
|
{
|
|
pageSize = Math.Clamp(pageSize, 1, 100);
|
|
|
|
var query = _db.SepsisBundles
|
|
.AsNoTracking()
|
|
.Include(b => b.Elements)
|
|
.Include(b => b.Encounter)
|
|
.ThenInclude(e => e.Patient)
|
|
.AsQueryable();
|
|
|
|
if (status.HasValue)
|
|
query = query.Where(b => b.ComplianceStatus == status.Value);
|
|
|
|
var total = await query.CountAsync();
|
|
var bundles = await query
|
|
.OrderByDescending(b => b.RecognizedAt)
|
|
.Skip((page - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.ToListAsync();
|
|
|
|
var items = bundles.Select(b => new SepsisBundleSummary(
|
|
b.Id,
|
|
b.EncounterId,
|
|
b.Encounter.Patient.Mrn,
|
|
b.Encounter.Patient.FirstName,
|
|
b.Encounter.Patient.LastName,
|
|
b.Encounter.RoomBed,
|
|
b.Encounter.Department,
|
|
b.TriggeringAlertType,
|
|
b.RecognizedAt,
|
|
b.DeadlineAt,
|
|
b.ComplianceStatus,
|
|
b.CompletedAt,
|
|
b.Elements
|
|
.Select(e => new SepsisBundleElementSummary(e.Id, e.ElementType, e.Status, e.CompletedAt))
|
|
.ToList()))
|
|
.ToList();
|
|
|
|
return new PagedResult<SepsisBundleSummary>(items, page, pageSize, total);
|
|
}
|
|
|
|
public async Task OnOrderResultedAsync(Guid orderId, CancellationToken ct = default)
|
|
{
|
|
var element = await _db.SepsisBundleElements
|
|
.Include(e => e.Bundle)
|
|
.FirstOrDefaultAsync(e => e.OrderId == orderId, ct);
|
|
|
|
if (element is null || element.Status == SepsisBundleElementStatus.Completed)
|
|
return;
|
|
|
|
element.Status = SepsisBundleElementStatus.Completed;
|
|
element.CompletedAt = DateTimeOffset.UtcNow;
|
|
|
|
var bundle = element.Bundle;
|
|
var allComplete = await _db.SepsisBundleElements
|
|
.Where(e => e.BundleId == bundle.Id && e.Id != element.Id)
|
|
.AllAsync(e => e.Status == SepsisBundleElementStatus.Completed, ct);
|
|
|
|
if (allComplete)
|
|
{
|
|
bundle.CompletedAt = DateTimeOffset.UtcNow;
|
|
bundle.ComplianceStatus = bundle.CompletedAt <= bundle.DeadlineAt
|
|
? SepsisBundleComplianceStatus.Compliant
|
|
: SepsisBundleComplianceStatus.NonCompliant;
|
|
|
|
_metrics.SepsisBundleComplianceTotal
|
|
.WithLabels(bundle.ComplianceStatus.ToDbString()).Inc();
|
|
}
|
|
|
|
_db.OutboxEvents.Add(new OutboxEvent
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Topic = "sepsis.bundle.updated",
|
|
Payload = JsonSerializer.Serialize(new
|
|
{
|
|
bundleId = bundle.Id,
|
|
encounterId = bundle.EncounterId,
|
|
elementId = element.Id,
|
|
elementType = element.ElementType.ToDbString(),
|
|
elementStatus = element.Status.ToDbString(),
|
|
complianceStatus = bundle.ComplianceStatus.ToDbString(),
|
|
completedAt = element.CompletedAt,
|
|
partitionKey = bundle.EncounterId.ToString()
|
|
}),
|
|
PartitionKey = bundle.EncounterId.ToString(),
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
|
|
await _db.SaveChangesAsync(ct);
|
|
}
|
|
|
|
}
|