feature: FHIR R4 Inbound Facade
This commit is contained in:
@@ -15,11 +15,16 @@ public class EncounterService : IEncounterService
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IQsofaService _qsofa;
|
||||
private readonly IExternalIdentifierService _identifiers;
|
||||
|
||||
public EncounterService(AppDbContext db, IQsofaService qsofa)
|
||||
public EncounterService(
|
||||
AppDbContext db,
|
||||
IQsofaService qsofa,
|
||||
IExternalIdentifierService identifiers)
|
||||
{
|
||||
_db = db;
|
||||
_qsofa = qsofa;
|
||||
_identifiers = identifiers;
|
||||
}
|
||||
|
||||
public async Task<Encounter> GetByIdAsync(Guid id)
|
||||
@@ -194,4 +199,89 @@ public class EncounterService : IEncounterService
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class ExternalIdentifierService : IExternalIdentifierService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public ExternalIdentifierService(AppDbContext db) => _db = db;
|
||||
|
||||
public async Task<Guid?> ResolveInternalIdAsync(
|
||||
ExternalResourceType resourceType, string system, string value)
|
||||
{
|
||||
var row = await _db.ExternalResourceIdentifiers
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e =>
|
||||
e.ResourceType == resourceType &&
|
||||
e.System == system &&
|
||||
e.Value == value);
|
||||
|
||||
return row?.InternalId;
|
||||
}
|
||||
|
||||
public async Task LinkAsync(
|
||||
ExternalResourceType resourceType, Guid internalId, string system, string value)
|
||||
{
|
||||
var existing = await _db.ExternalResourceIdentifiers
|
||||
.FirstOrDefaultAsync(e =>
|
||||
e.ResourceType == resourceType &&
|
||||
e.System == system &&
|
||||
e.Value == value);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
if (existing.InternalId != internalId)
|
||||
throw new ConflictException(
|
||||
$"Identifier {system}|{value} is already linked to a different internal resource.",
|
||||
"IDENTIFIER_ALREADY_LINKED");
|
||||
return;
|
||||
}
|
||||
|
||||
_db.ExternalResourceIdentifiers.Add(new ExternalResourceIdentifier
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ResourceType = resourceType,
|
||||
InternalId = internalId,
|
||||
System = system,
|
||||
Value = value,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<(string System, string Value)?> FindPrimaryIdentifierAsync(
|
||||
ExternalResourceType resourceType, Guid internalId, string[] acceptedSystems)
|
||||
{
|
||||
var rows = await _db.ExternalResourceIdentifiers
|
||||
.AsNoTracking()
|
||||
.Where(e => e.ResourceType == resourceType && e.InternalId == internalId)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var system in acceptedSystems)
|
||||
{
|
||||
var match = rows.FirstOrDefault(r => r.System == system);
|
||||
if (match is not null)
|
||||
return (match.System, match.Value);
|
||||
}
|
||||
|
||||
return rows.Count > 0 ? (rows[0].System, rows[0].Value) : null;
|
||||
}
|
||||
}
|
||||
@@ -6,4 +6,5 @@ public interface IEncounterService
|
||||
Task<EncounterStatusTransitionResult> TransitionStatusAsync(
|
||||
Guid encounterId, EncounterStatus targetStatus, string? dischargeDiagnosis = null);
|
||||
Task<object> GetTimelineAsync(Guid encounterId);
|
||||
Task<Encounter> OpenOrUpdateByIdentifierAsync(FhirEncounterUpsertRequest req);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
public interface IExternalIdentifierService
|
||||
{
|
||||
Task<Guid?> ResolveInternalIdAsync(
|
||||
ExternalResourceType resourceType, string system, string value);
|
||||
|
||||
Task LinkAsync(
|
||||
ExternalResourceType resourceType, Guid internalId, string system, string value);
|
||||
|
||||
Task<(string System, string Value)?> FindPrimaryIdentifierAsync(
|
||||
ExternalResourceType resourceType, Guid internalId, string[] acceptedSystems);
|
||||
}
|
||||
@@ -4,4 +4,5 @@ public interface IPatientService
|
||||
Task<PagedResult<Patient>> ListAsync(string? q, int page, int pageSize);
|
||||
Task<Patient> GetByIdAsync(Guid id);
|
||||
Task<Encounter> OpenEncounterAsync(Guid patientId, OpenEncounterRequest req);
|
||||
Task<Patient> RegisterOrUpdateByIdentifierAsync(FhirPatientUpsertRequest req);
|
||||
}
|
||||
@@ -4,8 +4,13 @@ using Microsoft.EntityFrameworkCore;
|
||||
public class PatientService : IPatientService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IExternalIdentifierService _identifiers;
|
||||
|
||||
public PatientService(AppDbContext db) => _db = db;
|
||||
public PatientService(AppDbContext db, IExternalIdentifierService identifiers)
|
||||
{
|
||||
_db = db;
|
||||
_identifiers = identifiers;
|
||||
}
|
||||
|
||||
public async Task<Patient> RegisterAsync(RegisterPatientRequest req)
|
||||
{
|
||||
@@ -122,6 +127,61 @@ public class PatientService : IPatientService
|
||||
return encounter;
|
||||
}
|
||||
|
||||
public async Task<Patient> RegisterOrUpdateByIdentifierAsync(FhirPatientUpsertRequest req)
|
||||
{
|
||||
var existingId = await _identifiers.ResolveInternalIdAsync(
|
||||
ExternalResourceType.Patient, req.IdentifierSystem, req.IdentifierValue);
|
||||
|
||||
if (existingId.HasValue)
|
||||
{
|
||||
var patient = await _db.Patients.FindAsync(existingId.Value)
|
||||
?? throw new NotFoundException("Patient not found.", "PATIENT_NOT_FOUND");
|
||||
|
||||
patient.FirstName = req.FirstName;
|
||||
patient.LastName = req.LastName;
|
||||
patient.DateOfBirth = req.DateOfBirth;
|
||||
patient.Gender = req.Gender;
|
||||
patient.BloodType = req.BloodType;
|
||||
patient.Allergies = req.Allergies;
|
||||
patient.EmergencyContactName = req.EmergencyContactName;
|
||||
patient.EmergencyContactPhone = req.EmergencyContactPhone;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return patient;
|
||||
}
|
||||
|
||||
// Use hospital identifier value as MRN when it fits the column constraint (max 20 chars).
|
||||
var mrn = req.IdentifierValue.Length <= 20
|
||||
? req.IdentifierValue
|
||||
: await GenerateMrnAsync();
|
||||
|
||||
var newPatient = new Patient
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Mrn = mrn,
|
||||
FirstName = req.FirstName,
|
||||
LastName = req.LastName,
|
||||
DateOfBirth = req.DateOfBirth,
|
||||
Gender = req.Gender,
|
||||
BloodType = req.BloodType,
|
||||
Allergies = req.Allergies,
|
||||
EmergencyContactName = req.EmergencyContactName,
|
||||
EmergencyContactPhone = req.EmergencyContactPhone,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_db.Patients.Add(newPatient);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
await _identifiers.LinkAsync(
|
||||
ExternalResourceType.Patient,
|
||||
newPatient.Id,
|
||||
req.IdentifierSystem,
|
||||
req.IdentifierValue);
|
||||
|
||||
return newPatient;
|
||||
}
|
||||
|
||||
private async Task<string> GenerateMrnAsync()
|
||||
{
|
||||
var count = await _db.Patients.CountAsync();
|
||||
|
||||
Reference in New Issue
Block a user