feature: FHIR R4 Inbound Facade
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
public static class FhirUnitConverter
|
||||
{
|
||||
public static (decimal Value, string Unit) Normalize(
|
||||
string internalCode, decimal value, string? fhirUnit, bool allowFahrenheit)
|
||||
{
|
||||
if (internalCode == "TEMP_C" && allowFahrenheit &&
|
||||
fhirUnit is not null &&
|
||||
(fhirUnit.Equals("[degF]", StringComparison.OrdinalIgnoreCase) ||
|
||||
fhirUnit.Equals("degF", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var celsius = (value - 32m) * 5m / 9m;
|
||||
return (Math.Round(celsius, 2), "Cel");
|
||||
}
|
||||
|
||||
return (value, fhirUnit ?? "1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
public static class LoincCodeMapper
|
||||
{
|
||||
// LOINC → internal observation code. Covers all codes in DataSeeder + PlausibilityValidator.
|
||||
private static readonly Dictionary<string, LoincMapping> _map = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["8867-4"] = new("HEART_RATE", "/min"),
|
||||
["8310-5"] = new("TEMP_C", "Cel", AllowFahrenheit: true),
|
||||
["2823-3"] = new("POTASSIUM_MEQ_L", "mmol/L"),
|
||||
["2708-6"] = new("SPO2", "%"),
|
||||
["9279-1"] = new("RESP_RATE", "/min"),
|
||||
["6690-2"] = new("WBC_K_UL", "10*3/uL"),
|
||||
["8480-6"] = new("SYSTOLIC_BP", "mm[Hg]"),
|
||||
["8462-4"] = new("DIASTOLIC_BP", "mm[Hg]"),
|
||||
["2524-7"] = new("LACTATE_MMOL_L", "mmol/L"),
|
||||
["2339-0"] = new("GLUCOSE_MG_DL", "mg/dL"),
|
||||
["777-3"] = new("PLATELET_K_UL", "10*3/uL"),
|
||||
["1975-2"] = new("BILIRUBIN_MG_DL", "mg/dL"),
|
||||
["2160-0"] = new("CREATININE_MG_DL", "mg/dL"),
|
||||
["2703-7"] = new("PAO2_MMHG", "mm[Hg]"),
|
||||
["3150-0"] = new("FIO2_PCT", "%"),
|
||||
["9187-6"] = new("URINE_OUTPUT_ML_H", "mL/h"),
|
||||
// GCS — often sent as panel with components; also map individual LOINC codes used by some EHRs
|
||||
["80288-7"] = new("GCS_EYE", "{score}"),
|
||||
["80289-5"] = new("GCS_VERBAL", "{score}"),
|
||||
["80290-3"] = new("GCS_MOTOR", "{score}"),
|
||||
};
|
||||
|
||||
// SNOMED CT fallbacks for non-LOINC sites
|
||||
private static readonly Dictionary<string, LoincMapping> _snomedMap = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["364075005"] = new("HEART_RATE", "/min"),
|
||||
["431314004"] = new("SPO2", "%"),
|
||||
["86290005"] = new("RESP_RATE", "/min"),
|
||||
};
|
||||
|
||||
public static bool TryMap(string system, string code, out LoincMapping mapping)
|
||||
{
|
||||
if (system.Contains("loinc", StringComparison.OrdinalIgnoreCase) &&
|
||||
_map.TryGetValue(code, out mapping!))
|
||||
return true;
|
||||
|
||||
if (system.Contains("snomed", StringComparison.OrdinalIgnoreCase) &&
|
||||
_snomedMap.TryGetValue(code, out mapping!))
|
||||
return true;
|
||||
|
||||
mapping = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
public static IReadOnlyCollection<string> SupportedLoincCodes => _map.Keys;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
public record LoincMapping(
|
||||
string InternalCode,
|
||||
string ExpectedUnit,
|
||||
bool AllowFahrenheit = false);
|
||||
@@ -0,0 +1,123 @@
|
||||
using Hl7.Fhir.Model;
|
||||
|
||||
public class FhirBundleProcessor
|
||||
{
|
||||
private readonly IPatientService _patients;
|
||||
private readonly IEncounterService _encounters;
|
||||
private readonly IObservationService _observations;
|
||||
private readonly IMedicationService _medications;
|
||||
private readonly PatientFhirMapper _patientMapper;
|
||||
private readonly EncounterFhirMapper _encounterMapper;
|
||||
private readonly ObservationFhirMapper _observationMapper;
|
||||
private readonly MedicationAdministrationFhirMapper _medMapper;
|
||||
|
||||
public FhirBundleProcessor(
|
||||
IPatientService patients,
|
||||
IEncounterService encounters,
|
||||
IObservationService observations,
|
||||
IMedicationService medications,
|
||||
PatientFhirMapper patientMapper,
|
||||
EncounterFhirMapper encounterMapper,
|
||||
ObservationFhirMapper observationMapper,
|
||||
MedicationAdministrationFhirMapper medMapper)
|
||||
{
|
||||
_patients = patients;
|
||||
_encounters = encounters;
|
||||
_observations = observations;
|
||||
_medications = medications;
|
||||
_patientMapper = patientMapper;
|
||||
_encounterMapper = encounterMapper;
|
||||
_observationMapper = observationMapper;
|
||||
_medMapper = medMapper;
|
||||
}
|
||||
|
||||
public async Task<Bundle> ProcessTransactionAsync(Bundle transaction)
|
||||
{
|
||||
var response = new Bundle { Type = Bundle.BundleType.TransactionResponse };
|
||||
|
||||
// Process in dependency order: Patient → Encounter → Observation/MedAdmin
|
||||
var entries = transaction.Entry
|
||||
.OrderBy(e => Priority(e.Resource))
|
||||
.ToList();
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var resource = entry.Resource;
|
||||
try
|
||||
{
|
||||
var location = resource switch
|
||||
{
|
||||
Hl7.Fhir.Model.Patient p => await ProcessPatientAsync(p),
|
||||
Hl7.Fhir.Model.Encounter e => await ProcessEncounterAsync(e),
|
||||
Hl7.Fhir.Model.Observation o => await ProcessObservationAsync(o),
|
||||
Hl7.Fhir.Model.MedicationAdministration m => await ProcessMedAsync(m),
|
||||
_ => throw new FhirMappingException(
|
||||
$"Unsupported resource type in bundle: {resource.TypeName}", "not-supported")
|
||||
};
|
||||
|
||||
response.Entry.Add(new Bundle.EntryComponent
|
||||
{
|
||||
Response = new Bundle.ResponseComponent
|
||||
{
|
||||
Status = "201 Created",
|
||||
Location = location
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
response.Entry.Add(new Bundle.EntryComponent
|
||||
{
|
||||
Response = new Bundle.ResponseComponent
|
||||
{
|
||||
Status = "422 Unprocessable Entity",
|
||||
Outcome = FhirOperationOutcomeBuilder.FromException(ex)
|
||||
}
|
||||
});
|
||||
break; // transaction semantics — stop on first failure
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private static int Priority(Resource? r) => r switch
|
||||
{
|
||||
Hl7.Fhir.Model.Patient => 0,
|
||||
Hl7.Fhir.Model.Encounter => 1,
|
||||
_ => 2
|
||||
};
|
||||
|
||||
private async Task<string> ProcessPatientAsync(Hl7.Fhir.Model.Patient fhir)
|
||||
{
|
||||
var req = _patientMapper.ToUpsertRequest(fhir);
|
||||
var patient = await _patients.RegisterOrUpdateByIdentifierAsync(req);
|
||||
return $"Patient/{patient.Id}";
|
||||
}
|
||||
|
||||
private async Task<string> ProcessEncounterAsync(Hl7.Fhir.Model.Encounter fhir)
|
||||
{
|
||||
var req = await _encounterMapper.ToUpsertRequestAsync(fhir);
|
||||
var encounter = await _encounters.OpenOrUpdateByIdentifierAsync(req);
|
||||
return $"Encounter/{encounter.Id}";
|
||||
}
|
||||
|
||||
private async Task<string> ProcessObservationAsync(Hl7.Fhir.Model.Observation fhir)
|
||||
{
|
||||
var mapped = await _observationMapper.ToIngestRequestsAsync(fhir);
|
||||
Guid? lastId = null;
|
||||
foreach (var item in mapped)
|
||||
{
|
||||
var result = await _observations.IngestAsync(item.EncounterId, item.Request);
|
||||
lastId = result.Observation.Id;
|
||||
}
|
||||
return $"Observation/{lastId}";
|
||||
}
|
||||
|
||||
private async Task<string> ProcessMedAsync(Hl7.Fhir.Model.MedicationAdministration fhir)
|
||||
{
|
||||
var (req, encounterId) = await _medMapper.ToCreateRequestAsync(fhir);
|
||||
var med = await _medications.CreateAsync(encounterId, req);
|
||||
return $"MedicationAdministration/{med.Id}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Hl7.Fhir.Serialization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
public class FhirExceptionFilter : IExceptionFilter
|
||||
{
|
||||
private static readonly FhirJsonSerializer Serializer = new();
|
||||
|
||||
public void OnException(ExceptionContext context)
|
||||
{
|
||||
if (!context.HttpContext.Request.Path.StartsWithSegments("/fhir"))
|
||||
return;
|
||||
|
||||
var outcome = FhirOperationOutcomeBuilder.FromException(context.Exception);
|
||||
var status = context.Exception switch
|
||||
{
|
||||
FhirMappingException fme => fme.HttpStatus,
|
||||
NotFoundException => 404,
|
||||
ValidationException => 422,
|
||||
ConflictException => 409,
|
||||
_ => 500
|
||||
};
|
||||
|
||||
context.Result = new ContentResult
|
||||
{
|
||||
StatusCode = status,
|
||||
ContentType = "application/fhir+json",
|
||||
Content = Serializer.SerializeToString(outcome)
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
public class FhirMappingException : Exception
|
||||
{
|
||||
public string FhirIssueCode { get; }
|
||||
public int HttpStatus { get; }
|
||||
|
||||
public FhirMappingException(string message, string fhirIssueCode, int httpStatus = 422)
|
||||
: base(message)
|
||||
{
|
||||
FhirIssueCode = fhirIssueCode;
|
||||
HttpStatus = httpStatus;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Hl7.Fhir.Model;
|
||||
|
||||
public static class FhirOperationOutcomeBuilder
|
||||
{
|
||||
public static OperationOutcome FromException(Exception ex) => ex switch
|
||||
{
|
||||
FhirMappingException fme => Create(fme.HttpStatus, fme.FhirIssueCode, fme.Message),
|
||||
NotFoundException nfe => Create(404, "not-found", nfe.Message),
|
||||
ValidationException ve => Create(422, "invalid", ve.Message),
|
||||
ConflictException ce => Create(409, "conflict", ce.Message),
|
||||
_ => Create(500, "exception", "An unexpected error occurred.")
|
||||
};
|
||||
|
||||
public static OperationOutcome Create(int status, string code, string diagnostics) =>
|
||||
new()
|
||||
{
|
||||
Issue = new List<OperationOutcome.IssueComponent>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Severity = status >= 500
|
||||
? OperationOutcome.IssueSeverity.Error
|
||||
: OperationOutcome.IssueSeverity.Warning,
|
||||
Code = Enum.TryParse<OperationOutcome.IssueType>(code, true, out var c)
|
||||
? c
|
||||
: OperationOutcome.IssueType.Processing,
|
||||
Diagnostics = diagnostics
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using Hl7.Fhir.Model;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
|
||||
public class EncounterFhirMapper
|
||||
{
|
||||
private readonly FhirReferenceResolver _refs;
|
||||
private readonly FhirOptions _options;
|
||||
|
||||
public EncounterFhirMapper(FhirReferenceResolver refs, IOptions<FhirOptions> options)
|
||||
{
|
||||
_refs = refs;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task<FhirEncounterUpsertRequest> ToUpsertRequestAsync(Hl7.Fhir.Model.Encounter fhir)
|
||||
{
|
||||
var identifier = _refs.ExtractPrimaryIdentifier(fhir, _options.EncounterIdentifierSystems)
|
||||
?? throw new FhirMappingException("Encounter must include at least one identifier.", "required");
|
||||
|
||||
if (fhir.Subject is null)
|
||||
throw new FhirMappingException("Encounter subject is required.", "required");
|
||||
|
||||
var patientId = await _refs.ResolvePatientReferenceAsync(fhir.Subject);
|
||||
|
||||
var actCode = fhir.Class?.Code ?? "IMP";
|
||||
if (!_options.EncounterClassMap.TryGetValue(actCode, out var encounterTypeStr))
|
||||
encounterTypeStr = _options.DefaultEncounterType;
|
||||
|
||||
var departmentStr = _options.DefaultDepartment;
|
||||
var locationCode = fhir.Location?.FirstOrDefault()?.Location?.Display;
|
||||
if (locationCode is not null)
|
||||
{
|
||||
foreach (var (key, value) in _options.DepartmentCodeMap)
|
||||
{
|
||||
if (locationCode.Contains(key, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
departmentStr = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var attending = fhir.Participant?
|
||||
.FirstOrDefault(p => p.Type?.Any(t =>
|
||||
t.Coding?.Any(c => c.Code == "ATND") == true) == true)
|
||||
?.Individual?.Display ?? "Unknown";
|
||||
|
||||
var targetStatus = fhir.Status switch
|
||||
{
|
||||
Hl7.Fhir.Model.Encounter.EncounterStatus.Finished => EncounterStatus.Discharged,
|
||||
Hl7.Fhir.Model.Encounter.EncounterStatus.Cancelled => EncounterStatus.Cancelled,
|
||||
_ => EncounterStatus.Active
|
||||
};
|
||||
|
||||
return new FhirEncounterUpsertRequest(
|
||||
IdentifierSystem: identifier.System,
|
||||
IdentifierValue: identifier.Value,
|
||||
PatientId: patientId,
|
||||
EncounterType: EncounterTypeExtensions.FromDbString(encounterTypeStr),
|
||||
Department: DepartmentExtensions.FromDbString(departmentStr),
|
||||
AttendingPhysician: attending,
|
||||
TargetStatus: targetStatus,
|
||||
AdmittedAt: fhir.Period?.StartElement?.ToDateTimeOffset(TimeSpan.Zero),
|
||||
RoomBed: fhir.Location?.FirstOrDefault()?.Location?.Display,
|
||||
AdmissionReason: fhir.ReasonCode?.FirstOrDefault()?.Text);
|
||||
}
|
||||
|
||||
public Hl7.Fhir.Model.Encounter ToFhirResponse(Encounter encounter, (string System, string Value)? hospitalId)
|
||||
{
|
||||
var resource = new Hl7.Fhir.Model.Encounter
|
||||
{
|
||||
Id = encounter.Id.ToString(),
|
||||
Status = encounter.Status switch
|
||||
{
|
||||
EncounterStatus.Active => Hl7.Fhir.Model.Encounter.EncounterStatus.InProgress,
|
||||
EncounterStatus.Discharged => Hl7.Fhir.Model.Encounter.EncounterStatus.Finished,
|
||||
EncounterStatus.Cancelled => Hl7.Fhir.Model.Encounter.EncounterStatus.Cancelled,
|
||||
_ => Hl7.Fhir.Model.Encounter.EncounterStatus.Unknown
|
||||
},
|
||||
Class = new Coding("http://terminology.hl7.org/CodeSystem/v3-ActCode", "IMP"),
|
||||
Subject = new ResourceReference($"Patient/{encounter.PatientId}"),
|
||||
Identifier = new List<Identifier>()
|
||||
};
|
||||
|
||||
if (hospitalId is not null)
|
||||
{
|
||||
resource.Identifier.Add(new Identifier
|
||||
{
|
||||
System = hospitalId.Value.System,
|
||||
Value = hospitalId.Value.Value
|
||||
});
|
||||
}
|
||||
|
||||
resource.Identifier.Add(new Identifier
|
||||
{
|
||||
System = _options.InternalEncounterIdSystem,
|
||||
Value = encounter.Id.ToString()
|
||||
});
|
||||
|
||||
return resource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Hl7.Fhir.Model;
|
||||
|
||||
internal static class FhirMappingHelpers
|
||||
{
|
||||
public static DateTimeOffset? ToUtcDateTimeOffset(this DataType? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
FhirDateTime fdt => fdt.ToDateTimeOffset(TimeSpan.Zero),
|
||||
Period p when p.StartElement is not null => p.StartElement.ToDateTimeOffset(TimeSpan.Zero),
|
||||
Instant i => i.Value,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
public static bool TryParseResourceReference(string? reference, out string resourceType, out string id)
|
||||
{
|
||||
resourceType = "";
|
||||
id = "";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(reference))
|
||||
return false;
|
||||
|
||||
var refValue = reference;
|
||||
if (Uri.TryCreate(refValue, UriKind.Absolute, out var uri))
|
||||
refValue = uri.AbsolutePath.TrimStart('/');
|
||||
|
||||
var slash = refValue.IndexOf('/');
|
||||
if (slash <= 0)
|
||||
return false;
|
||||
|
||||
resourceType = refValue[..slash];
|
||||
id = refValue[(slash + 1)..];
|
||||
|
||||
var historyIdx = id.IndexOf('/');
|
||||
if (historyIdx > 0)
|
||||
id = id[..historyIdx];
|
||||
|
||||
return !string.IsNullOrWhiteSpace(resourceType) && !string.IsNullOrWhiteSpace(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using Hl7.Fhir.Model;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Task = System.Threading.Tasks.Task;
|
||||
|
||||
public class FhirReferenceResolver
|
||||
{
|
||||
private readonly IExternalIdentifierService _identifiers;
|
||||
private readonly FhirOptions _options;
|
||||
|
||||
public FhirReferenceResolver(
|
||||
IExternalIdentifierService identifiers,
|
||||
IOptions<FhirOptions> options)
|
||||
{
|
||||
_identifiers = identifiers;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task<Guid> ResolvePatientReferenceAsync(ResourceReference reference)
|
||||
{
|
||||
if (reference.Identifier is not null)
|
||||
return await ResolveByIdentifierAsync(
|
||||
ExternalResourceType.Patient,
|
||||
reference.Identifier.System,
|
||||
reference.Identifier.Value,
|
||||
_options.PatientIdentifierSystems);
|
||||
|
||||
if (FhirMappingHelpers.TryParseResourceReference(reference.Reference, out var type, out var id)
|
||||
&& type == "Patient"
|
||||
&& Guid.TryParse(id, out var guid))
|
||||
{
|
||||
return guid;
|
||||
}
|
||||
|
||||
throw new FhirMappingException(
|
||||
"Patient reference must include a resolvable identifier or UUID.",
|
||||
"required");
|
||||
}
|
||||
|
||||
public async Task<Guid> ResolveEncounterReferenceAsync(ResourceReference reference)
|
||||
{
|
||||
if (reference.Identifier is not null)
|
||||
return await ResolveByIdentifierAsync(
|
||||
ExternalResourceType.Encounter,
|
||||
reference.Identifier.System,
|
||||
reference.Identifier.Value,
|
||||
_options.EncounterIdentifierSystems);
|
||||
|
||||
if (FhirMappingHelpers.TryParseResourceReference(reference.Reference, out var type, out var id)
|
||||
&& type == "Encounter"
|
||||
&& Guid.TryParse(id, out var guid))
|
||||
{
|
||||
return guid;
|
||||
}
|
||||
|
||||
throw new FhirMappingException(
|
||||
"Encounter reference must include a resolvable identifier or UUID.",
|
||||
"required");
|
||||
}
|
||||
|
||||
public (string System, string Value)? ExtractPrimaryIdentifier(
|
||||
IIdentifiable<List<Identifier>> resource, string[] acceptedSystems)
|
||||
{
|
||||
foreach (var system in acceptedSystems)
|
||||
{
|
||||
var match = resource.Identifier?
|
||||
.FirstOrDefault(i => i.System == system && !string.IsNullOrWhiteSpace(i.Value));
|
||||
if (match is not null)
|
||||
return (match.System!, match.Value!);
|
||||
}
|
||||
|
||||
return resource.Identifier?
|
||||
.FirstOrDefault(i => !string.IsNullOrWhiteSpace(i.System) && !string.IsNullOrWhiteSpace(i.Value))
|
||||
is { } fallback
|
||||
? (fallback.System!, fallback.Value!)
|
||||
: null;
|
||||
}
|
||||
|
||||
private async Task<Guid> ResolveByIdentifierAsync(
|
||||
ExternalResourceType type, string? system, string? value, string[] acceptedSystems)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(system) || string.IsNullOrWhiteSpace(value))
|
||||
throw new FhirMappingException("Identifier system and value are required.", "required");
|
||||
|
||||
if (!acceptedSystems.Contains(system))
|
||||
throw new FhirMappingException(
|
||||
$"Identifier system '{system}' is not configured.", "not-supported");
|
||||
|
||||
var internalId = await _identifiers.ResolveInternalIdAsync(type, system, value);
|
||||
if (internalId is null)
|
||||
throw new NotFoundException(
|
||||
$"{type} with identifier {system}|{value} not found.",
|
||||
"FHIR_RESOURCE_NOT_FOUND");
|
||||
|
||||
return internalId.Value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Hl7.Fhir.Model;
|
||||
using Task = System.Threading.Tasks.Task;
|
||||
|
||||
public class MedicationAdministrationFhirMapper
|
||||
{
|
||||
private readonly FhirReferenceResolver _refs;
|
||||
|
||||
public MedicationAdministrationFhirMapper(FhirReferenceResolver refs) => _refs = refs;
|
||||
|
||||
public async Task<(CreateMedicationAdministrationRequest Request, Guid EncounterId)> ToCreateRequestAsync(
|
||||
Hl7.Fhir.Model.MedicationAdministration fhir)
|
||||
{
|
||||
if (fhir.Context is null)
|
||||
throw new FhirMappingException(
|
||||
"MedicationAdministration context is required.", "required");
|
||||
|
||||
var encounterId = await _refs.ResolveEncounterReferenceAsync(fhir.Context);
|
||||
|
||||
var drugName = fhir.Medication is CodeableConcept cc
|
||||
? cc.Text ?? cc.Coding?.FirstOrDefault()?.Display ?? "Unknown"
|
||||
: "Unknown";
|
||||
|
||||
var dose = fhir.Dosage?.Dose as Quantity;
|
||||
var route = fhir.Dosage?.Route?.Text ?? fhir.Dosage?.Route?.Coding?.FirstOrDefault()?.Display ?? "IV";
|
||||
var performer = fhir.Performer?.FirstOrDefault()?.Actor?.Display ?? "Unknown";
|
||||
|
||||
var req = new CreateMedicationAdministrationRequest(
|
||||
DrugName: drugName,
|
||||
Dose: dose?.Value ?? 0m,
|
||||
DoseUnit: dose?.Unit ?? "mg",
|
||||
Route: route,
|
||||
AdministeredAt: fhir.Effective.ToUtcDateTimeOffset(),
|
||||
AdministeredBy: performer);
|
||||
|
||||
return (req, encounterId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using Hl7.Fhir.Model;
|
||||
using Task = System.Threading.Tasks.Task;
|
||||
|
||||
public record MappedObservation(IngestObservationRequest Request, Guid EncounterId);
|
||||
|
||||
public class ObservationFhirMapper
|
||||
{
|
||||
private readonly FhirReferenceResolver _refs;
|
||||
|
||||
public ObservationFhirMapper(FhirReferenceResolver refs) => _refs = refs;
|
||||
|
||||
public async Task<IReadOnlyList<MappedObservation>> ToIngestRequestsAsync(Hl7.Fhir.Model.Observation fhir)
|
||||
{
|
||||
if (fhir.Encounter is null)
|
||||
throw new FhirMappingException("Observation encounter reference is required.", "required");
|
||||
|
||||
var encounterId = await _refs.ResolveEncounterReferenceAsync(fhir.Encounter);
|
||||
var source = MapSource(fhir);
|
||||
var recordedAt = fhir.Effective.ToUtcDateTimeOffset() ?? DateTimeOffset.UtcNow;
|
||||
var idempotencyKey = fhir.Identifier?.FirstOrDefault()?.Value ?? fhir.Id;
|
||||
|
||||
var results = new List<MappedObservation>();
|
||||
|
||||
if (fhir.Component?.Count > 0)
|
||||
{
|
||||
foreach (var component in fhir.Component)
|
||||
results.AddRange(MapSingleCoding(
|
||||
component.Code, component.Value, encounterId, source, recordedAt,
|
||||
idempotencyKey is null ? null : $"{idempotencyKey}:{component.Code?.Coding?.FirstOrDefault()?.Code}"));
|
||||
}
|
||||
else
|
||||
{
|
||||
results.AddRange(MapSingleCoding(
|
||||
fhir.Code, fhir.Value, encounterId, source, recordedAt, idempotencyKey));
|
||||
}
|
||||
|
||||
if (results.Count == 0)
|
||||
throw new FhirMappingException("Observation contains no mappable values.", "invalid");
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static ObservationSource MapSource(Hl7.Fhir.Model.Observation fhir)
|
||||
{
|
||||
var category = fhir.Category?.FirstOrDefault()?.Coding?.FirstOrDefault()?.Code;
|
||||
return category switch
|
||||
{
|
||||
"vital-signs" => ObservationSource.Device,
|
||||
"laboratory" => ObservationSource.Lab,
|
||||
_ => ObservationSource.Manual
|
||||
};
|
||||
}
|
||||
|
||||
private static List<MappedObservation> MapSingleCoding(
|
||||
CodeableConcept? code,
|
||||
DataType? value,
|
||||
Guid encounterId,
|
||||
ObservationSource source,
|
||||
DateTimeOffset recordedAt,
|
||||
string? idempotencyKey)
|
||||
{
|
||||
var coding = code?.Coding?.FirstOrDefault(c =>
|
||||
!string.IsNullOrWhiteSpace(c.System) && !string.IsNullOrWhiteSpace(c.Code));
|
||||
|
||||
if (coding is null)
|
||||
throw new FhirMappingException("Observation code coding is required.", "required");
|
||||
|
||||
if (!LoincCodeMapper.TryMap(coding.System!, coding.Code!, out var mapping))
|
||||
throw new FhirMappingException(
|
||||
$"Unsupported observation code {coding.System}|{coding.Code}.",
|
||||
"not-supported");
|
||||
|
||||
if (value is not Quantity qty)
|
||||
throw new FhirMappingException("Observation valueQuantity is required.", "required");
|
||||
|
||||
var (normalizedValue, _) = FhirUnitConverter.Normalize(
|
||||
mapping.InternalCode, qty.Value ?? 0m, qty.Unit, mapping.AllowFahrenheit);
|
||||
|
||||
var request = new IngestObservationRequest(
|
||||
ObservationCode: mapping.InternalCode,
|
||||
Value: normalizedValue,
|
||||
Unit: mapping.ExpectedUnit,
|
||||
Source: source,
|
||||
RecordedAt: recordedAt,
|
||||
IdempotencyKey: idempotencyKey);
|
||||
|
||||
return [new MappedObservation(request, encounterId)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Hl7.Fhir.Model;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public class PatientFhirMapper
|
||||
{
|
||||
private readonly FhirReferenceResolver _refs;
|
||||
private readonly FhirOptions _options;
|
||||
|
||||
public PatientFhirMapper(FhirReferenceResolver refs, IOptions<FhirOptions> options)
|
||||
{
|
||||
_refs = refs;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public FhirPatientUpsertRequest ToUpsertRequest(Hl7.Fhir.Model.Patient fhir)
|
||||
{
|
||||
var identifier = _refs.ExtractPrimaryIdentifier(fhir, _options.PatientIdentifierSystems)
|
||||
?? throw new FhirMappingException("Patient must include at least one identifier.", "required");
|
||||
|
||||
var name = fhir.Name?.FirstOrDefault()
|
||||
?? throw new FhirMappingException("Patient must include a name.", "required");
|
||||
|
||||
var given = name.Given?.FirstOrDefault() ?? "";
|
||||
var family = name.Family ?? "";
|
||||
|
||||
if (fhir.BirthDate is null)
|
||||
throw new FhirMappingException("Patient birthDate is required.", "required");
|
||||
|
||||
return new FhirPatientUpsertRequest(
|
||||
IdentifierSystem: identifier.System,
|
||||
IdentifierValue: identifier.Value,
|
||||
FirstName: given,
|
||||
LastName: family,
|
||||
DateOfBirth: DateOnly.Parse(fhir.BirthDate),
|
||||
Gender: fhir.Gender?.ToString()?.ToLowerInvariant() ?? "unknown");
|
||||
}
|
||||
|
||||
public Hl7.Fhir.Model.Patient ToFhirResponse(Patient patient, (string System, string Value)? hospitalId)
|
||||
{
|
||||
var resource = new Hl7.Fhir.Model.Patient
|
||||
{
|
||||
Id = patient.Id.ToString(),
|
||||
Identifier = new List<Identifier>()
|
||||
};
|
||||
|
||||
if (hospitalId is not null)
|
||||
{
|
||||
resource.Identifier.Add(new Identifier
|
||||
{
|
||||
System = hospitalId.Value.System,
|
||||
Value = hospitalId.Value.Value,
|
||||
Type = new CodeableConcept("http://terminology.hl7.org/CodeSystem/v2-0203", "MR")
|
||||
});
|
||||
}
|
||||
|
||||
resource.Identifier.Add(new Identifier
|
||||
{
|
||||
System = _options.InternalPatientIdSystem,
|
||||
Value = patient.Id.ToString()
|
||||
});
|
||||
|
||||
resource.Name.Add(new HumanName
|
||||
{
|
||||
Given = new[] { patient.FirstName },
|
||||
Family = patient.LastName
|
||||
});
|
||||
resource.BirthDate = patient.DateOfBirth.ToString("yyyy-MM-dd");
|
||||
resource.Gender = patient.Gender switch
|
||||
{
|
||||
"male" => AdministrativeGender.Male,
|
||||
"female" => AdministrativeGender.Female,
|
||||
_ => AdministrativeGender.Unknown
|
||||
};
|
||||
|
||||
return resource;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user