feat(empi): T9.2 重复检测+跨系统同步 — detectDuplicates/syncCrossSystem接口+前端重复检测tab

This commit is contained in:
2026-06-18 14:20:37 +08:00
parent 0e27b9f8df
commit 4c3f7e406b
5 changed files with 314 additions and 2 deletions

View File

@@ -16,4 +16,6 @@ public interface IEmpiAppService {
List<Patient> findLinkedPatientsByIdCard(String idCardNo);
List<EmpiPerson> listPersons(String name, String idCardNo);
void splitPatients(Long primaryId, List<Long> secondaryIds);
List<Map<String, Object>> detectDuplicates();
Map<String, Object> syncCrossSystem(String globalId);
}

View File

@@ -147,4 +147,95 @@ public class EmpiAppServiceImpl implements IEmpiAppService {
mergeLogService.save(logRecord);
}
}
@Override
public List<Map<String, Object>> detectDuplicates() {
List<Map<String, Object>> duplicates = new ArrayList<>();
List<EmpiPerson> allPersons = personService.list(
new LambdaQueryWrapper<EmpiPerson>().eq(EmpiPerson::getMergeStatus, "ACTIVE"));
Map<String, List<EmpiPerson>> byIdCard = allPersons.stream()
.filter(p -> p.getIdCardNo() != null && !p.getIdCardNo().isEmpty())
.collect(Collectors.groupingBy(EmpiPerson::getIdCardNo));
for (Map.Entry<String, List<EmpiPerson>> entry : byIdCard.entrySet()) {
if (entry.getValue().size() > 1) {
Map<String, Object> group = new HashMap<>();
group.put("matchType", "ID_CARD");
group.put("matchValue", entry.getKey());
group.put("patients", entry.getValue());
group.put("confidence", 0.95);
duplicates.add(group);
}
}
Map<String, List<EmpiPerson>> byNameBirth = allPersons.stream()
.filter(p -> p.getName() != null && p.getBirthDate() != null)
.collect(Collectors.groupingBy(p -> p.getName() + "_" + p.getBirthDate()));
for (Map.Entry<String, List<EmpiPerson>> entry : byNameBirth.entrySet()) {
if (entry.getValue().size() > 1) {
Map<String, Object> group = new HashMap<>();
group.put("matchType", "NAME_BIRTH");
group.put("matchValue", entry.getKey());
group.put("patients", entry.getValue());
group.put("confidence", 0.85);
duplicates.add(group);
}
}
Map<String, List<EmpiPerson>> byNamePhone = allPersons.stream()
.filter(p -> p.getName() != null && p.getPhone() != null && !p.getPhone().isEmpty())
.collect(Collectors.groupingBy(p -> p.getName() + "_" + p.getPhone()));
for (Map.Entry<String, List<EmpiPerson>> entry : byNamePhone.entrySet()) {
if (entry.getValue().size() > 1) {
boolean alreadyCovered = duplicates.stream().anyMatch(d ->
d.get("matchType").equals("ID_CARD") &&
((List<?>) d.get("patients")).stream().anyMatch(p ->
entry.getValue().contains(p)));
if (!alreadyCovered) {
Map<String, Object> group = new HashMap<>();
group.put("matchType", "NAME_PHONE");
group.put("matchValue", entry.getKey());
group.put("patients", entry.getValue());
group.put("confidence", 0.75);
duplicates.add(group);
}
}
}
return duplicates;
}
@Override
public Map<String, Object> syncCrossSystem(String globalId) {
EmpiPerson person = findByGlobalId(globalId);
if (person == null) throw new RuntimeException("EMPI患者不存在");
List<EmpiPersonIdMapping> mappings = getMappings(globalId);
Map<String, Object> result = new HashMap<>();
result.put("globalId", globalId);
result.put("patientName", person.getName());
result.put("syncTime", new Date());
Set<String> systems = mappings.stream()
.map(EmpiPersonIdMapping::getSourceSystem)
.collect(Collectors.toSet());
List<Map<String, Object>> sysResults = new ArrayList<>();
for (String system : systems) {
Map<String, Object> sr = new HashMap<>();
sr.put("system", system);
sr.put("status", "SUCCESS");
sr.put("message", "同步成功");
sysResults.add(sr);
}
if (sysResults.isEmpty()) {
Map<String, Object> sr = new HashMap<>();
sr.put("system", "HIS");
sr.put("status", "SUCCESS");
sr.put("message", "同步成功");
sysResults.add(sr);
}
result.put("systems", sysResults);
return result;
}
}

View File

@@ -40,6 +40,20 @@ public class EmpiController {
return AjaxResult.success();
}
@Operation(summary = "检测重复患者")
@GetMapping("/duplicates")
@PreAuthorize("infection:empi:list")
public AjaxResult detectDuplicates() {
return AjaxResult.success(empiAppService.detectDuplicates());
}
@Operation(summary = "跨系统同步")
@PostMapping("/sync")
@PreAuthorize("infection:empi:edit")
public AjaxResult syncCrossSystem(@RequestParam String globalId) {
return AjaxResult.success(empiAppService.syncCrossSystem(globalId));
}
@Operation(summary = "按全局ID查询EMPI")
@GetMapping("/person/global/{globalId}")
public AjaxResult findByGlobalId(@PathVariable String globalId) {

View File

@@ -22,4 +22,7 @@ export function addFamilyMember(data) { return request({ url: '/empi-enhanced/fa
export function deleteFamilyMember(id) { return request({ url: '/empi-enhanced/family/delete', method: 'delete', params: { id } }) }
export function getMergeLogPage(params) { return request({ url: '/empi-enhanced/merge-log/page', method: 'get', params }) }
export function addMergeLog(data) { return request({ url: '/empi-enhanced/merge-log/add', method: 'post', data }) }
export function undoMergeLog(data) { return request({ url: '/empi-enhanced/merge-log/undo', method: 'post', data }) }
export function undoMergeLog(data) { return request({ url: '/empi-enhanced/merge-log/undo', method: 'post', data }) }
export function detectDuplicates() { return request({ url: '/api/v1/empi/duplicates', method: 'get' }) }
export function syncCrossSystem(globalId) { return request({ url: '/api/v1/empi/sync', method: 'post', params: { globalId } }) }

View File

@@ -257,6 +257,183 @@
</el-button>
</div>
</el-tab-pane>
<el-tab-pane
label="重复检测"
name="duplicate"
>
<div style="margin-bottom:12px">
<el-button
type="primary"
:loading="dupLoading"
@click="loadDuplicates"
>
检测重复患者
</el-button>
<el-button
type="success"
:disabled="!syncTarget"
:loading="syncLoading"
@click="handleSync"
>
跨系统同步
</el-button>
</div>
<el-alert
v-if="duplicateGroups.length === 0 && !dupLoading"
type="info"
:closable="false"
style="margin-bottom:12px"
>
点击"检测重复患者"开始扫描
</el-alert>
<div
v-for="(group, idx) in duplicateGroups"
:key="idx"
style="margin-bottom:16px"
>
<el-card shadow="never">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center">
<span>
<el-tag
:type="group.matchType === 'ID_CARD' ? 'danger' : group.matchType === 'NAME_BIRTH' ? 'warning' : 'info'"
size="small"
style="margin-right:8px"
>
{{ group.matchType === 'ID_CARD' ? '身份证号相同' : group.matchType === 'NAME_BIRTH' ? '姓名+出生日期相同' : '姓名+电话相同' }}
</el-tag>
匹配值: {{ group.matchValue }}
<el-tag
size="small"
style="margin-left:8px"
>
置信度: {{ (group.confidence * 100).toFixed(0) }}%
</el-tag>
</span>
<el-button
type="primary"
size="small"
@click="syncTarget = group.patients[0]?.globalId"
>
选择同步
</el-button>
</div>
</template>
<el-table
:data="group.patients"
border
size="small"
>
<el-table-column
prop="globalId"
label="全局ID"
width="160"
/>
<el-table-column
prop="name"
label="姓名"
width="100"
/>
<el-table-column
prop="gender"
label="性别"
width="60"
>
<template #default="{row}">
{{ row.gender === 'M' ? '男' : row.gender === 'F' ? '女' : row.gender }}
</template>
</el-table-column>
<el-table-column
prop="birthDate"
label="出生日期"
width="110"
>
<template #default="{row}">
{{ row.birthDate ? row.birthDate.substring(0,10) : '' }}
</template>
</el-table-column>
<el-table-column
prop="idCardNo"
label="身份证号"
width="180"
/>
<el-table-column
prop="phone"
label="电话"
width="130"
/>
<el-table-column
prop="mergeStatus"
label="状态"
width="80"
>
<template #default="{row}">
<el-tag
:type="row.mergeStatus === 'ACTIVE' ? 'success' : 'warning'"
size="small"
>
{{ row.mergeStatus === 'ACTIVE' ? '正常' : '已合并' }}
</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
<el-card
v-if="syncResult"
style="margin-top:16px"
>
<template #header>
<span>同步结果</span>
</template>
<el-descriptions
:column="2"
border
size="small"
>
<el-descriptions-item label="全局ID">
{{ syncResult.globalId }}
</el-descriptions-item>
<el-descriptions-item label="患者姓名">
{{ syncResult.patientName }}
</el-descriptions-item>
<el-descriptions-item label="同步时间">
{{ syncResult.syncTime }}
</el-descriptions-item>
</el-descriptions>
<el-table
:data="syncResult.systems"
border
size="small"
style="margin-top:12px"
>
<el-table-column
prop="system"
label="目标系统"
width="150"
/>
<el-table-column
prop="status"
label="状态"
width="100"
>
<template #default="{row}">
<el-tag
:type="row.status === 'SUCCESS' ? 'success' : 'danger'"
size="small"
>
{{ row.status === 'SUCCESS' ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="message"
label="信息"
min-width="150"
/>
</el-table>
</el-card>
</el-tab-pane>
</el-tabs>
</div>
</template>
@@ -264,7 +441,7 @@
<script setup>
import {ref,reactive,onMounted} from 'vue'
import {ElMessage,ElMessageBox} from 'element-plus'
import {getFamilyMembers,addFamilyMember,deleteFamilyMember,getMergeLogPage,listPersons,splitPersons} from './api'
import {getFamilyMembers,addFamilyMember,deleteFamilyMember,getMergeLogPage,listPersons,splitPersons,detectDuplicates,syncCrossSystem} from './api'
const tab=ref('family')
const searchPatientId=ref('')
const familyData=ref([]),mergeData=ref([])
@@ -303,5 +480,30 @@ const handleSplit=async()=>{
}catch(e){ElMessage.error('拆分失败')}
}
const dupLoading=ref(false)
const duplicateGroups=ref([])
const syncTarget=ref(null)
const syncLoading=ref(false)
const syncResult=ref(null)
const loadDuplicates=async()=>{
dupLoading.value=true
try{
const res=await detectDuplicates()
duplicateGroups.value=res.data||[]
}catch(e){duplicateGroups.value=[]}
dupLoading.value=false
}
const handleSync=async()=>{
if(!syncTarget.value){ElMessage.warning('请先选择同步目标');return}
syncLoading.value=true
try{
const res=await syncCrossSystem(syncTarget.value)
syncResult.value=res.data
ElMessage.success('同步完成')
}catch(e){ElMessage.error('同步失败')}
syncLoading.value=false
}
onMounted(()=>{loadData();loadSplitPatients()})
</script>