feature: FHIR R4 Inbound Facade

This commit is contained in:
voltsrage
2026-06-21 13:53:38 +08:00
parent 9f96f54007
commit a43db52813
42 changed files with 2873 additions and 3 deletions
@@ -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();