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
@@ -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;
}
}