feat(esb): T8.1 HL7 FHIR R4消息转换 - AppService/Controller/Frontend

This commit is contained in:
2026-06-18 12:53:46 +08:00
parent f1c583d9b7
commit b6a521db29
5 changed files with 366 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
package com.healthlink.his.web.esbmanage.appservice;
import com.healthlink.his.esb.domain.FhirResource;
import java.util.Map;
public interface IFhirConversionAppService {
FhirResource convertToFhir(Map<String, Object> internalData, String resourceType);
Map<String, Object> convertFromFhir(String resourceJson);
}

View File

@@ -0,0 +1,151 @@
package com.healthlink.his.web.esbmanage.appservice.impl;
import com.healthlink.his.esb.domain.FhirResource;
import com.healthlink.his.esb.service.IFhirResourceService;
import com.healthlink.his.web.esbmanage.appservice.IFhirConversionAppService;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.*;
@Service
@Slf4j
@RequiredArgsConstructor
public class FhirConversionAppServiceImpl implements IFhirConversionAppService {
private final IFhirResourceService fhirResourceService;
private final ObjectMapper objectMapper;
@Override
public FhirResource convertToFhir(Map<String, Object> internalData, String resourceType) {
try {
Map<String, Object> fhirBundle = new LinkedHashMap<>();
fhirBundle.put("resourceType", resourceType);
fhirBundle.put("id", UUID.randomUUID().toString());
fhirBundle.put("meta", Map.of("lastUpdated", new Date().toString()));
Map<String, Object> identifier = new LinkedHashMap<>();
identifier.put("system", "urn:oid:2.16.156.10011");
identifier.put("value", String.valueOf(internalData.getOrDefault("patientId", "")));
fhirBundle.put("identifier", List.of(identifier));
if ("Patient".equals(resourceType)) {
Map<String, Object> name = new LinkedHashMap<>();
name.put("use", "official");
name.put("text", String.valueOf(internalData.getOrDefault("patientName", "")));
fhirBundle.put("name", List.of(name));
fhirBundle.put("gender", internalData.getOrDefault("gender", "unknown"));
fhirBundle.put("birthDate", String.valueOf(internalData.getOrDefault("birthDate", "")));
} else if ("Encounter".equals(resourceType)) {
fhirBundle.put("status", "in-progress");
Map<String, Object> classCode = new LinkedHashMap<>();
classCode.put("system", "http://terminology.hl7.org/CodeSystem/v3-ActCode");
classCode.put("code", "IMP");
fhirBundle.put("class", classCode);
fhirBundle.put("period", Map.of("start", internalData.getOrDefault("admissionDate", "")));
} else if ("Observation".equals(resourceType)) {
fhirBundle.put("status", "final");
Map<String, Object> codeMap = new LinkedHashMap<>();
codeMap.put("coding", List.of(Map.of("system", "http://loinc.org", "code", internalData.getOrDefault("obsCode", ""))));
fhirBundle.put("code", codeMap);
Map<String, Object> valueQuantity = new LinkedHashMap<>();
valueQuantity.put("value", internalData.getOrDefault("obsValue", 0));
valueQuantity.put("unit", internalData.getOrDefault("obsUnit", ""));
fhirBundle.put("valueQuantity", valueQuantity);
} else if ("Condition".equals(resourceType)) {
fhirBundle.put("clinicalStatus", Map.of("coding", List.of(Map.of("code", "active"))));
Map<String, Object> condCode = new LinkedHashMap<>();
condCode.put("coding", List.of(Map.of("system", "http://snomed.info/sct", "code", internalData.getOrDefault("conditionCode", ""))));
fhirBundle.put("code", condCode);
} else if ("MedicationRequest".equals(resourceType)) {
fhirBundle.put("status", "active");
fhirBundle.put("intent", "order");
Map<String, Object> medCode = new LinkedHashMap<>();
medCode.put("coding", List.of(Map.of("system", "http://www.nmpa.gov.cn", "code", internalData.getOrDefault("drugCode", ""))));
fhirBundle.put("medicationCodeableConcept", medCode);
fhirBundle.put("dosageInstruction", List.of(Map.of("text", internalData.getOrDefault("dosage", ""))));
}
String resourceJson = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(fhirBundle);
FhirResource resource = new FhirResource();
resource.setResourceType(resourceType);
resource.setResourceId(fhirBundle.get("id").toString());
resource.setPatientId(internalData.get("patientId") != null ? Long.valueOf(String.valueOf(internalData.get("patientId"))) : null);
resource.setEncounterId(internalData.get("encounterId") != null ? Long.valueOf(String.valueOf(internalData.get("encounterId"))) : null);
resource.setResourceJson(resourceJson);
resource.setStatus("ACTIVE");
resource.setVersionId(1);
resource.setCreateTime(new Date());
fhirResourceService.save(resource);
return resource;
} catch (Exception e) {
log.error("FHIR转换失败: {}", e.getMessage(), e);
throw new RuntimeException("FHIR R4转换失败: " + e.getMessage());
}
}
@Override
public Map<String, Object> convertFromFhir(String resourceJson) {
try {
Map<String, Object> fhirResource = objectMapper.readValue(resourceJson, Map.class);
Map<String, Object> result = new LinkedHashMap<>();
result.put("resourceType", fhirResource.get("resourceType"));
result.put("resourceId", fhirResource.get("id"));
List<Map<String, Object>> identifiers = (List<Map<String, Object>>) fhirResource.get("identifier");
if (identifiers != null && !identifiers.isEmpty()) {
result.put("patientId", identifiers.get(0).get("value"));
}
if ("Patient".equals(fhirResource.get("resourceType"))) {
List<Map<String, Object>> names = (List<Map<String, Object>>) fhirResource.get("name");
if (names != null && !names.isEmpty()) {
result.put("patientName", names.get(0).get("text"));
}
result.put("gender", fhirResource.get("gender"));
result.put("birthDate", fhirResource.get("birthDate"));
} else if ("Encounter".equals(fhirResource.get("resourceType"))) {
result.put("status", fhirResource.get("status"));
Map<String, Object> cls = (Map<String, Object>) fhirResource.get("class");
if (cls != null) result.put("encounterClass", cls.get("code"));
Map<String, Object> period = (Map<String, Object>) fhirResource.get("period");
if (period != null) result.put("admissionDate", period.get("start"));
} else if ("Observation".equals(fhirResource.get("resourceType"))) {
Map<String, Object> vq = (Map<String, Object>) fhirResource.get("valueQuantity");
if (vq != null) {
result.put("obsValue", vq.get("value"));
result.put("obsUnit", vq.get("unit"));
}
} else if ("Condition".equals(fhirResource.get("resourceType"))) {
Map<String, Object> code = (Map<String, Object>) fhirResource.get("code");
if (code != null) {
List<Map<String, Object>> codings = (List<Map<String, Object>>) code.get("coding");
if (codings != null && !codings.isEmpty()) {
result.put("conditionCode", codings.get(0).get("code"));
}
}
} else if ("MedicationRequest".equals(fhirResource.get("resourceType"))) {
Map<String, Object> med = (Map<String, Object>) fhirResource.get("medicationCodeableConcept");
if (med != null) {
List<Map<String, Object>> codings = (List<Map<String, Object>>) med.get("coding");
if (codings != null && !codings.isEmpty()) {
result.put("drugCode", codings.get(0).get("code"));
}
}
List<Map<String, Object>> dosage = (List<Map<String, Object>>) fhirResource.get("dosageInstruction");
if (dosage != null && !dosage.isEmpty()) {
result.put("dosage", dosage.get(0).get("text"));
}
}
return result;
} catch (Exception e) {
log.error("FHIR反向转换失败: {}", e.getMessage(), e);
throw new RuntimeException("FHIR R4反向转换失败: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,47 @@
package com.healthlink.his.web.esbmanage.controller;
import com.core.common.core.domain.R;
import com.healthlink.his.esb.domain.FhirResource;
import com.healthlink.his.web.esbmanage.appservice.IFhirConversionAppService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* FHIR R4消息转换 Controller — 内部数据 ↔ FHIR R4标准格式
*/
@RestController
@RequestMapping("/esb/fhir")
@Slf4j
@RequiredArgsConstructor
public class FhirConversionController {
private final IFhirConversionAppService fhirConversionAppService;
@PostMapping("/convert-to")
@PreAuthorize("hasAuthority('infection:esb:edit')")
public R<?> convertToFhir(@RequestBody Map<String, Object> params) {
String resourceType = (String) params.get("resourceType");
@SuppressWarnings("unchecked")
Map<String, Object> internalData = (Map<String, Object>) params.get("data");
if (resourceType == null || internalData == null) {
return R.fail("resourceType和data不能为空");
}
FhirResource result = fhirConversionAppService.convertToFhir(internalData, resourceType);
return R.ok(result);
}
@PostMapping("/convert-from")
@PreAuthorize("hasAuthority('infection:esb:edit')")
public R<?> convertFromFhir(@RequestBody Map<String, String> params) {
String resourceJson = params.get("resourceJson");
if (resourceJson == null || resourceJson.isBlank()) {
return R.fail("resourceJson不能为空");
}
Map<String, Object> result = fhirConversionAppService.convertFromFhir(resourceJson);
return R.ok(result);
}
}

View File

@@ -0,0 +1,5 @@
import request from '@/utils/request'
export function convertToFhir(data) { return request({ url: '/esb/fhir/convert-to', method: 'post', data }) }
export function convertFromFhir(data) { return request({ url: '/esb/fhir/convert-from', method: 'post', data }) }
export function getFhirPage(params) { return request({ url: '/fhir-cda/fhir/page', method: 'get', params }) }
export function getFhirTypeStats() { return request({ url: '/fhir-cda/fhir/type-stats', method: 'get' }) }

View File

@@ -0,0 +1,153 @@
<template>
<div style="padding:16px">
<div style="margin-bottom:16px;display:flex;justify-content:space-between;align-items:center">
<span style="font-size:18px;font-weight:bold">HL7 FHIR R4 消息转换</span>
<el-button type="primary" @click="loadStats">刷新统计</el-button>
</div>
<el-row :gutter="12" style="margin-bottom:16px">
<el-col :span="4" v-for="(val, key) in typeStats" :key="key">
<el-card shadow="hover">
<div style="text-align:center">
<div style="font-size:20px;font-weight:bold;color:#409eff">{{ val }}</div>
<div style="font-size:12px;color:#999">{{ key }}</div>
</div>
</el-card>
</el-col>
</el-row>
<el-tabs v-model="activeTab" type="border-card">
<el-tab-pane label="内部→FHIR R4" name="toFhir">
<el-form :model="toFhirForm" label-width="120px" style="max-width:700px">
<el-form-item label="资源类型">
<el-select v-model="toFhirForm.resourceType" style="width:100%">
<el-option label="Patient" value="Patient" />
<el-option label="Encounter" value="Encounter" />
<el-option label="Observation" value="Observation" />
<el-option label="Condition" value="Condition" />
<el-option label="MedicationRequest" value="MedicationRequest" />
</el-select>
</el-form-item>
<el-form-item label="患者ID">
<el-input v-model="toFhirForm.patientId" />
</el-form-item>
<el-form-item label="就诊ID">
<el-input v-model="toFhirForm.encounterId" />
</el-form-item>
<el-form-item label="患者姓名">
<el-input v-model="toFhirForm.patientName" />
</el-form-item>
<el-form-item label="性别">
<el-select v-model="toFhirForm.gender" style="width:100%">
<el-option label="男" value="male" />
<el-option label="女" value="female" />
<el-option label="未知" value="unknown" />
</el-select>
</el-form-item>
<el-form-item label="出生日期">
<el-input v-model="toFhirForm.birthDate" placeholder="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="诊断编码">
<el-input v-model="toFhirForm.conditionCode" />
</el-form-item>
<el-form-item label="药物编码">
<el-input v-model="toFhirForm.drugCode" />
</el-form-item>
<el-form-item label="用法用量">
<el-input v-model="toFhirForm.dosage" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="doConvertToFhir">转换为FHIR R4</el-button>
</el-form-item>
</el-form>
<el-divider v-if="toFhirResult" content-position="left">转换结果</el-divider>
<el-input v-if="toFhirResult" v-model="toFhirResult" type="textarea" :rows="12" readonly />
</el-tab-pane>
<el-tab-pane label="FHIR R4→内部" name="fromFhir">
<el-form label-width="120px" style="max-width:700px">
<el-form-item label="FHIR JSON">
<el-input v-model="fromFhirJson" type="textarea" :rows="10" placeholder="粘贴FHIR R4 Resource JSON" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="doConvertFromFhir">转换为内部格式</el-button>
</el-form-item>
</el-form>
<el-divider v-if="fromFhirResult" content-position="left">转换结果</el-divider>
<el-descriptions v-if="fromFhirResult" :column="2" border>
<el-descriptions-item v-for="(val, key) in fromFhirResult" :key="key" :label="key">{{ val }}</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
<el-tab-pane label="已有FHIR资源" name="list">
<el-table :data="fhirList" border stripe>
<el-table-column prop="resourceType" label="资源类型" width="150" />
<el-table-column prop="resourceId" label="资源ID" width="200" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="80">
<template #default="{row}">
<el-tag :type="row.status==='ACTIVE'?'success':'info'" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="versionId" label="版本" width="60" align="center" />
<el-table-column prop="createTime" label="创建时间" width="170" />
</el-table>
<el-pagination
v-model:current-page="listPage.pageNo"
v-model:page-size="listPage.pageSize"
style="margin-top:12px;justify-content:flex-end"
:total="listTotal"
layout="total,prev,pager,next"
@current-change="loadList"
/>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { convertToFhir, convertFromFhir, getFhirPage, getFhirTypeStats } from './api'
const activeTab = ref('toFhir')
const typeStats = ref({})
const toFhirForm = ref({ resourceType: 'Patient', patientId: '', encounterId: '', patientName: '', gender: 'male', birthDate: '', conditionCode: '', drugCode: '', dosage: '' })
const toFhirResult = ref('')
const fromFhirJson = ref('')
const fromFhirResult = ref('')
const fhirList = ref([])
const listTotal = ref(0)
const listPage = ref({ pageNo: 1, pageSize: 20 })
const loadStats = async () => {
const r = await getFhirTypeStats()
typeStats.value = r.data || {}
}
const loadList = async () => {
const r = await getFhirPage(listPage.value)
fhirList.value = r.data?.records || []
listTotal.value = r.data?.total || 0
}
const doConvertToFhir = async () => {
const data = { ...toFhirForm.value }
if (!data.patientId) { ElMessage.warning('患者ID必填'); return }
const r = await convertToFhir({ resourceType: data.resourceType, data })
if (r.code === 200) {
toFhirResult.value = JSON.stringify(JSON.parse(r.data?.resourceJson || '{}'), null, 2)
ElMessage.success('转换成功')
loadList()
} else {
ElMessage.error(r.msg || '转换失败')
}
}
const doConvertFromFhir = async () => {
if (!fromFhirJson.value) { ElMessage.warning('请输入FHIR JSON'); return }
const r = await convertFromFhir({ resourceJson: fromFhirJson.value })
if (r.code === 200) {
fromFhirResult.value = r.data
ElMessage.success('转换成功')
} else {
ElMessage.error(r.msg || '转换失败')
}
}
onMounted(() => { loadStats(); loadList() })
</script>