feature: HL7 FHIR R4 Integration

This commit is contained in:
voltsrage
2026-06-27 22:23:45 +08:00
parent 756cff332c
commit 5646dfddb4
27 changed files with 2658 additions and 16 deletions
@@ -0,0 +1,18 @@
using Hl7.Fhir.Model;
public static class FhirErrorHelper
{
public static OperationOutcome NotFound(string resourceType, string id) =>
new()
{
Issue =
{
new OperationOutcome.IssueComponent
{
Severity = OperationOutcome.IssueSeverity.Error,
Code = OperationOutcome.IssueType.NotFound,
Diagnostics = $"{resourceType}/{id} not found",
}
}
};
}
@@ -0,0 +1,31 @@
using Hl7.Fhir.Model;
using Hl7.Fhir.Serialization;
using Microsoft.AspNetCore.Mvc.Formatters;
using System.Text;
using Task = System.Threading.Tasks.Task;
public class FhirJsonOutputFormatter : TextOutputFormatter
{
public FhirJsonOutputFormatter()
{
SupportedMediaTypes.Add("application/fhir+json");
SupportedMediaTypes.Add("application/json");
SupportedEncodings.Add(Encoding.UTF8);
}
protected override bool CanWriteType(Type? type)
{
return type != null && typeof(Resource).IsAssignableFrom(type);
}
public override async Task WriteResponseBodyAsync(
OutputFormatterWriteContext context, Encoding selectedEncoding)
{
var resource = context.Object as Resource;
if (resource is null) return;
var serializer = new FhirJsonSerializer(new SerializerSettings { Pretty = true });
var json = serializer.SerializeToString(resource);
await context.HttpContext.Response.WriteAsync(json, selectedEncoding);
}
}
@@ -0,0 +1,90 @@
using Hl7.Fhir.Model;
using FhirEncounter = Hl7.Fhir.Model.Encounter;
public static class EncounterMapper
{
/// <summary>
/// Maps a VigilCare Encounter entity to a FHIR R4 Encounter resource.
///
/// Mapping decisions:
/// - VigilCare encounter Status ("active", "discharged") maps to FHIR
/// Encounter.Status (in-progress, finished).
/// - Department maps to Encounter.serviceType using a local CodeSystem.
/// Facilities should map to their own department OID or SNOMED CT codes.
/// - SourceBatchId is preserved as an extension for traceability.
/// </summary>
public static FhirEncounter ToFhir(Encounter entity, string baseUrl)
{
var encounter = new FhirEncounter
{
Id = entity.Id.ToString(),
Meta = new Meta
{
VersionId = "1",
LastUpdated = entity.UpdatedAt,
},
Status = entity.Status?.ToLowerInvariant() switch
{
"active" => FhirEncounter.EncounterStatus.InProgress,
"discharged" => FhirEncounter.EncounterStatus.Finished,
"cancelled" => FhirEncounter.EncounterStatus.Cancelled,
_ => FhirEncounter.EncounterStatus.Unknown,
},
Class = new Coding(
"http://terminology.hl7.org/CodeSystem/v3-ActCode",
"IMP",
"inpatient encounter"),
Subject = new ResourceReference($"Patient/{entity.PatientId}"),
};
if (entity.AdmissionDate.HasValue)
{
encounter.Period = new Period
{
StartElement = new FhirDateTime(entity.AdmissionDate.Value),
};
}
if (entity.Department.HasValue)
{
encounter.ServiceType = new CodeableConcept(
"urn:vigilcare:department",
entity.Department.Value.ToString(),
entity.Department.Value.ToString());
}
if (!string.IsNullOrEmpty(entity.RoomBed))
{
encounter.Location.Add(new FhirEncounter.LocationComponent
{
Location = new ResourceReference { Display = entity.RoomBed },
Status = FhirEncounter.EncounterLocationStatus.Active,
});
}
if (!string.IsNullOrEmpty(entity.AdmissionReason))
{
encounter.ReasonCode.Add(new CodeableConcept { Text = entity.AdmissionReason });
}
if (!string.IsNullOrEmpty(entity.DischargeDiagnosis))
{
encounter.Diagnosis.Add(new FhirEncounter.DiagnosisComponent
{
Condition = new ResourceReference { Display = entity.DischargeDiagnosis },
Use = new CodeableConcept(
"http://terminology.hl7.org/CodeSystem/diagnosis-role",
"DD", "Discharge diagnosis"),
});
}
if (entity.SourceBatchId.HasValue)
{
encounter.Extension.Add(new Extension(
"urn:vigilcare:source-batch-id",
new FhirString(entity.SourceBatchId.Value.ToString())));
}
return encounter;
}
}
@@ -0,0 +1,137 @@
using Hl7.Fhir.Model;
using FhirObservation = Hl7.Fhir.Model.Observation;
public static class ObservationMapper
{
private static readonly Dictionary<string, string> LoincToVigilCare = new()
{
["8867-4"] = "HEART_RATE",
["8310-5"] = "TEMP_C",
["8480-6"] = "BP_SYSTOLIC",
["8462-4"] = "BP_DIASTOLIC",
["9279-1"] = "RESP_RATE",
["2708-6"] = "SPO2",
["2345-7"] = "GLUCOSE_MG_DL",
["2823-3"] = "POTASSIUM_MEQ_L",
["2951-2"] = "SODIUM_MEQ_L",
["2524-7"] = "LACTATE_MMOL_L",
["6690-2"] = "WBC_K_UL",
["718-7"] = "HEMOGLOBIN_G_DL",
["2160-0"] = "CREATININE_MG_DL",
};
private static readonly HashSet<string> VitalSignCodes =
[
"HEART_RATE", "TEMP_C", "BP_SYSTOLIC", "BP_DIASTOLIC", "RESP_RATE", "SPO2"
];
/// <summary>
/// Maps a VigilCare Observation entity to a FHIR R4 Observation resource.
///
/// Mapping decisions:
/// - ObservationCode maps to LOINC codes where a standard mapping exists.
/// Unknown codes use a local CodeSystem with the original code as display.
/// - Value + Unit maps to Observation.valueQuantity with UCUM unit codes.
/// - RecordedAt maps to effectiveDateTime (when the observation was clinically
/// relevant), not issued (when the system recorded it).
/// - Source and SourceBatchId are preserved as extensions for provenance.
/// </summary>
public static FhirObservation ToFhir(Observation entity, string baseUrl)
{
var observation = new FhirObservation
{
Id = entity.Id.ToString(),
Meta = new Meta
{
VersionId = "1",
LastUpdated = entity.CreatedAt,
},
Status = ObservationStatus.Final,
Subject = new ResourceReference($"Patient/{entity.PatientId}"),
Encounter = new ResourceReference($"Encounter/{entity.EncounterId}"),
Effective = new FhirDateTime(entity.RecordedAt),
Issued = entity.CreatedAt,
};
observation.Code = MapObservationCode(entity.ObservationCode);
observation.Value = new Quantity
{
Value = entity.Value,
Unit = entity.Unit,
System = "http://unitsofmeasure.org",
Code = MapToUcum(entity.Unit),
};
var category = IsVitalSign(entity.ObservationCode) ? "vital-signs" : "laboratory";
observation.Category.Add(new CodeableConcept(
"http://terminology.hl7.org/CodeSystem/observation-category",
category));
if (!string.IsNullOrEmpty(entity.Note))
{
observation.Note.Add(new Annotation { Text = new Markdown(entity.Note) });
}
observation.Extension.Add(new Extension(
"urn:vigilcare:source",
new FhirString(entity.Source)));
if (entity.SourceBatchId.HasValue)
{
observation.Extension.Add(new Extension(
"urn:vigilcare:source-batch-id",
new FhirString(entity.SourceBatchId.Value.ToString())));
}
return observation;
}
/// <summary>
/// Reverse LOINC → VigilCare code mapping for FHIR search by LOINC code.
/// </summary>
public static IReadOnlyDictionary<string, string> GetReverseLoincMapping() => LoincToVigilCare;
/// <summary>
/// VigilCare observation codes that represent vital signs (used for category search).
/// </summary>
public static IReadOnlyCollection<string> GetVitalSignCodes() => VitalSignCodes;
private static CodeableConcept MapObservationCode(string code) => code switch
{
"HEART_RATE" => Loinc("8867-4", "Heart rate"),
"TEMP_C" => Loinc("8310-5", "Body temperature"),
"BP_SYSTOLIC" => Loinc("8480-6", "Systolic blood pressure"),
"BP_DIASTOLIC" => Loinc("8462-4", "Diastolic blood pressure"),
"RESP_RATE" => Loinc("9279-1", "Respiratory rate"),
"SPO2" => Loinc("2708-6", "Oxygen saturation"),
"GLUCOSE_MG_DL" => Loinc("2345-7", "Glucose [Mass/volume] in Serum or Plasma"),
"POTASSIUM_MEQ_L" => Loinc("2823-3", "Potassium [Moles/volume] in Serum or Plasma"),
"SODIUM_MEQ_L" => Loinc("2951-2", "Sodium [Moles/volume] in Serum or Plasma"),
"LACTATE_MMOL_L" => Loinc("2524-7", "Lactate [Moles/volume] in Serum or Plasma"),
"WBC_K_UL" => Loinc("6690-2", "Leukocytes [#/volume] in Blood"),
"HEMOGLOBIN_G_DL" => Loinc("718-7", "Hemoglobin [Mass/volume] in Blood"),
"CREATININE_MG_DL" => Loinc("2160-0", "Creatinine [Mass/volume] in Serum or Plasma"),
_ => new CodeableConcept("urn:vigilcare:observation-code", code, code),
};
private static CodeableConcept Loinc(string code, string display) =>
new("http://loinc.org", code, display);
private static string MapToUcum(string unit) => unit switch
{
"bpm" => "/min",
"C" => "Cel",
"mmHg" => "mm[Hg]",
"breaths/min" => "/min",
"%" => "%",
"mg/dL" => "mg/dL",
"mEq/L" => "meq/L",
"mmol/L" => "mmol/L",
"K/uL" => "10*3/uL",
"g/dL" => "g/dL",
_ => unit,
};
private static bool IsVitalSign(string code) => VitalSignCodes.Contains(code);
}
@@ -0,0 +1,83 @@
using Hl7.Fhir.Model;
using FhirPatient = Hl7.Fhir.Model.Patient;
public static class PatientMapper
{
/// <summary>
/// Maps a VigilCare Patient entity to a FHIR R4 Patient resource.
///
/// Mapping decisions:
/// - VigilCare stores full name as a single string; FHIR splits into family/given.
/// We use HumanName.Text for the full string and attempt to split on the last
/// space for family/given when possible.
/// - MRN maps to Identifier with system "urn:oid:2.16.840.1.113883.19.5" (example OID).
/// Facilities should configure their own OID.
/// - BloodType maps to an extension (no standard FHIR element for blood type).
/// - AllergiesJson is NOT mapped here — allergies should use AllergyIntolerance
/// resources, which are out of scope for Phase 11 v1.
/// </summary>
public static FhirPatient ToFhir(Patient entity, string baseUrl)
{
var patient = new FhirPatient
{
Id = entity.Id.ToString(),
Meta = new Meta
{
VersionId = "1",
LastUpdated = entity.UpdatedAt,
},
Active = true,
};
patient.Identifier.Add(new Identifier
{
System = "urn:oid:2.16.840.1.113883.19.5",
Value = entity.Mrn,
Use = Identifier.IdentifierUse.Official,
Type = new CodeableConcept("http://terminology.hl7.org/CodeSystem/v2-0203", "MR", "Medical Record Number"),
});
var name = new HumanName { Text = entity.FullName, Use = HumanName.NameUse.Official };
var parts = entity.FullName.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2)
{
name.Family = parts[^1];
name.Given = parts[..^1].ToList();
}
else
{
name.Family = entity.FullName;
}
patient.Name.Add(name);
if (entity.DateOfBirth.HasValue)
{
patient.BirthDate = entity.DateOfBirth.Value.ToString("yyyy-MM-dd");
}
if (!string.IsNullOrEmpty(entity.Sex))
{
patient.Gender = entity.Sex.ToLowerInvariant() switch
{
"male" => AdministrativeGender.Male,
"female" => AdministrativeGender.Female,
"other" => AdministrativeGender.Other,
_ => AdministrativeGender.Unknown,
};
}
if (!string.IsNullOrEmpty(entity.EmergencyContact))
{
patient.Contact.Add(new FhirPatient.ContactComponent
{
Relationship = new List<CodeableConcept>
{
new("http://terminology.hl7.org/CodeSystem/v2-0131", "C", "Emergency Contact")
},
Name = new HumanName { Text = entity.EmergencyContact },
});
}
return patient;
}
}