Merge remote-tracking branch 'origin/develop' into zhaoyun

This commit is contained in:
2026-06-16 13:45:35 +08:00
11 changed files with 597 additions and 71 deletions

View File

@@ -1,7 +1,10 @@
package com.healthlink.his.web.empi.appservice;
import com.healthlink.his.administration.domain.Patient;
import com.healthlink.his.empi.domain.*;
import java.util.List;
import java.util.Map;
public interface IEmpiAppService {
EmpiPerson registerPerson(EmpiPerson p);
void mergePersons(Long primaryId, List<Long> secondaryIds);
@@ -9,4 +12,7 @@ public interface IEmpiAppService {
EmpiPerson findByIdCard(String idCardNo);
List<EmpiPersonIdMapping> getMappings(String globalId);
Map<String, Object> getStatistics();
}
List<Patient> findLinkedPatients(String globalId);
List<Patient> findLinkedPatientsByIdCard(String idCardNo);
List<EmpiPerson> listPersons(String name, String idCardNo);
}

View File

@@ -1,54 +1,116 @@
package com.healthlink.his.web.empi.appservice.impl;
import com.healthlink.his.empi.domain.*;
import com.healthlink.his.empi.service.*;
import com.healthlink.his.web.empi.appservice.IEmpiAppService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.healthlink.his.administration.domain.Patient;
import com.healthlink.his.administration.service.IPatientService;
import com.healthlink.his.empi.domain.EmpiPerson;
import com.healthlink.his.empi.domain.EmpiPersonIdMapping;
import com.healthlink.his.empi.service.IEmpiPersonIdMappingService;
import com.healthlink.his.empi.service.IEmpiPersonService;
import com.healthlink.his.web.empi.appservice.IEmpiAppService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class EmpiAppServiceImpl implements IEmpiAppService {
@Autowired private IEmpiPersonService personService;
@Autowired private IEmpiPersonIdMappingService mappingService;
@Autowired private IPatientService patientService;
@Override
public EmpiPerson registerPerson(EmpiPerson p) {
p.setGlobalId(UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase());
p.setMergeStatus("ACTIVE"); personService.save(p); return p;
p.setMergeStatus("ACTIVE");
personService.save(p);
return p;
}
@Override
public void mergePersons(Long primaryId, List<Long> secondaryIds) {
EmpiPerson primary = personService.getById(primaryId);
for (Long secId : secondaryIds) {
EmpiPerson sec = personService.getById(secId);
if (sec == null) continue;
List<EmpiPersonIdMapping> mappings = mappingService.list(new LambdaQueryWrapper<EmpiPersonIdMapping>()
.eq(EmpiPersonIdMapping::getGlobalId, personService.getById(secId).getGlobalId()));
.eq(EmpiPersonIdMapping::getGlobalId, sec.getGlobalId()));
for (EmpiPersonIdMapping m : mappings) {
m.setGlobalId(primary.getGlobalId());
mappingService.updateById(m);
}
EmpiPerson sec = personService.getById(secId);
sec.setMergeStatus("MERGED"); personService.updateById(sec);
sec.setMergeStatus("MERGED");
personService.updateById(sec);
}
}
@Override
public EmpiPerson findByGlobalId(String globalId) {
return personService.getOne(new LambdaQueryWrapper<EmpiPerson>().eq(EmpiPerson::getGlobalId, globalId));
}
@Override
public EmpiPerson findByIdCard(String idCardNo) {
return personService.getOne(new LambdaQueryWrapper<EmpiPerson>().eq(EmpiPerson::getIdCardNo, idCardNo));
return personService.getOne(new LambdaQueryWrapper<EmpiPerson>().eq(EmpiPerson::getIdCardNo, idCardNo).last("LIMIT 1"));
}
@Override
public List<EmpiPersonIdMapping> getMappings(String globalId) {
return mappingService.list(new LambdaQueryWrapper<EmpiPersonIdMapping>().eq(EmpiPersonIdMapping::getGlobalId, globalId));
}
@Override
public Map<String, Object> getStatistics() {
Map<String, Object> r = new HashMap<>();
r.put("totalPersons", personService.count());
r.put("activePersons", personService.count(new LambdaQueryWrapper<EmpiPerson>().eq(EmpiPerson::getMergeStatus, "ACTIVE")));
r.put("mergedPersons", personService.count(new LambdaQueryWrapper<EmpiPerson>().eq(EmpiPerson::getMergeStatus, "MERGED")));
long total = personService.count();
long activeCount = personService.count(new LambdaQueryWrapper<EmpiPerson>().eq(EmpiPerson::getMergeStatus, "ACTIVE"));
long mergedCount = personService.count(new LambdaQueryWrapper<EmpiPerson>().eq(EmpiPerson::getMergeStatus, "MERGED"));
r.put("totalPersons", total);
r.put("activePersons", activeCount);
r.put("mergedPersons", mergedCount);
r.put("totalMappings", mappingService.count());
r.put("pendingMerges", total > 0 ? total - activeCount - mergedCount : 0);
r.put("duplicateRate", total > 0 ? Math.round((double) mergedCount / total * 10000) / 100.0 : 0);
return r;
}
}
/**
* 通过全局ID查询关联的院内患者记录
*/
public List<Patient> findLinkedPatients(String globalId) {
List<EmpiPersonIdMapping> mappings = mappingService.list(
new LambdaQueryWrapper<EmpiPersonIdMapping>().eq(EmpiPersonIdMapping::getGlobalId, globalId));
if (mappings.isEmpty()) return Collections.emptyList();
List<Long> patientIds = mappings.stream()
.map(EmpiPersonIdMapping::getLocalPatientId)
.collect(Collectors.toList());
return patientService.listByIds(patientIds);
}
/**
* 通过身份证号查询关联的院内患者记录
*/
public List<Patient> findLinkedPatientsByIdCard(String idCardNo) {
EmpiPerson person = findByIdCard(idCardNo);
if (person == null) return Collections.emptyList();
return findLinkedPatients(person.getGlobalId());
}
/**
* 查询EMPI患者列表
*/
@Override
public List<EmpiPerson> listPersons(String name, String idCardNo) {
LambdaQueryWrapper<EmpiPerson> wrapper = new LambdaQueryWrapper<>();
if (name != null && !name.isEmpty()) {
wrapper.like(EmpiPerson::getName, name);
}
if (idCardNo != null && !idCardNo.isEmpty()) {
wrapper.like(EmpiPerson::getIdCardNo, idCardNo);
}
wrapper.orderByDesc(EmpiPerson::getId);
return personService.list(wrapper);
}
}

View File

@@ -1,4 +1,5 @@
package com.healthlink.his.web.empi.controller;
import com.core.common.core.domain.AjaxResult;
import com.healthlink.his.empi.domain.*;
import com.healthlink.his.web.empi.appservice.IEmpiAppService;
@@ -7,19 +8,69 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "患者主索引(EMPI)") @RestController @RequestMapping("/api/v1/empi")
@Tag(name = "患者主索引(EMPI)")
@RestController
@RequestMapping("/api/v1/empi")
public class EmpiController {
@Autowired private IEmpiAppService empiAppService;
@Operation(summary = "注册患者") @PostMapping("/person")
public AjaxResult register(@RequestBody EmpiPerson p) { return AjaxResult.success(empiAppService.registerPerson(p)); }
@Operation(summary = "合并患者") @PostMapping("/merge")
public AjaxResult merge(@RequestParam Long primaryId, @RequestParam List<Long> secondaryIds) { empiAppService.mergePersons(primaryId, secondaryIds); return AjaxResult.success(); }
@Operation(summary = "按全局ID查询") @GetMapping("/person/global/{globalId}")
public AjaxResult findByGlobalId(@PathVariable String globalId) { return AjaxResult.success(empiAppService.findByGlobalId(globalId)); }
@Operation(summary = "按身份证查询") @GetMapping("/person/idcard/{idCardNo}")
public AjaxResult findByIdCard(@PathVariable String idCardNo) { return AjaxResult.success(empiAppService.findByIdCard(idCardNo)); }
@Operation(summary = "ID映射") @GetMapping("/mappings/{globalId}")
public AjaxResult mappings(@PathVariable String globalId) { return AjaxResult.success(empiAppService.getMappings(globalId)); }
@Operation(summary = "统计") @GetMapping("/statistics")
public AjaxResult statistics() { return AjaxResult.success(empiAppService.getStatistics()); }
}
@Autowired
private IEmpiAppService empiAppService;
@Operation(summary = "注册患者")
@PostMapping("/person")
public AjaxResult register(@RequestBody EmpiPerson p) {
return AjaxResult.success(empiAppService.registerPerson(p));
}
@Operation(summary = "合并患者")
@PostMapping("/merge")
public AjaxResult merge(@RequestParam Long primaryId, @RequestParam List<Long> secondaryIds) {
empiAppService.mergePersons(primaryId, secondaryIds);
return AjaxResult.success();
}
@Operation(summary = "按全局ID查询EMPI")
@GetMapping("/person/global/{globalId}")
public AjaxResult findByGlobalId(@PathVariable String globalId) {
return AjaxResult.success(empiAppService.findByGlobalId(globalId));
}
@Operation(summary = "按身份证查询EMPI")
@GetMapping("/person/idcard/{idCardNo}")
public AjaxResult findByIdCard(@PathVariable String idCardNo) {
return AjaxResult.success(empiAppService.findByIdCard(idCardNo));
}
@Operation(summary = "ID映射")
@GetMapping("/mappings/{globalId}")
public AjaxResult mappings(@PathVariable String globalId) {
return AjaxResult.success(empiAppService.getMappings(globalId));
}
@Operation(summary = "统计")
@GetMapping("/statistics")
public AjaxResult statistics() {
return AjaxResult.success(empiAppService.getStatistics());
}
@Operation(summary = "通过全局ID查询关联的院内患者记录")
@GetMapping("/linked-patients/global/{globalId}")
public AjaxResult findLinkedPatientsByGlobalId(@PathVariable String globalId) {
return AjaxResult.success(empiAppService.findLinkedPatients(globalId));
}
@Operation(summary = "通过身份证号查询关联的院内患者记录")
@GetMapping("/linked-patients/idcard/{idCardNo}")
public AjaxResult findLinkedPatientsByIdCard(@PathVariable String idCardNo) {
return AjaxResult.success(empiAppService.findLinkedPatientsByIdCard(idCardNo));
}
@Operation(summary = "查询EMPI患者列表")
@GetMapping("/persons")
public AjaxResult listPersons(
@RequestParam(required = false) String name,
@RequestParam(required = false) String idCardNo) {
return AjaxResult.success(empiAppService.listPersons(name, idCardNo));
}
}

View File

@@ -366,7 +366,15 @@ public class AdviceProcessAppServiceImpl implements IAdviceProcessAppService {
// 根据执行状态过滤医嘱列表
List<InpatientAdviceDto> filteredList = new ArrayList<>();
if (EventStatus.COMPLETED.getValue().equals(exeStatus)) {
if (EventStatus.PREPARATION.getValue().equals(exeStatus)) {
// 待执行 - 过滤出没有已执行记录的医嘱Bug #663修复
filteredList = inpatientAdviceList.stream().filter(
advice -> advice.getExePerformRecordList() == null || advice.getExePerformRecordList().isEmpty())
.collect(Collectors.toList());
// 更新分页数据
inpatientAdvicePage.setRecords(filteredList);
inpatientAdvicePage.setTotal(filteredList.size());
} else if (EventStatus.COMPLETED.getValue().equals(exeStatus)) {
// 已执行 - 过滤出有执行记录的医嘱
filteredList = inpatientAdviceList.stream().filter(
advice -> advice.getExePerformRecordList() != null && !advice.getExePerformRecordList().isEmpty())

View File

@@ -29,7 +29,9 @@ import com.healthlink.his.web.patientmanage.dto.PatientBaseInfoDto;
import com.healthlink.his.web.patientmanage.dto.PatientIdInfoDto;
import com.healthlink.his.web.patientmanage.dto.PatientInfoInitDto;
import com.healthlink.his.web.patientmanage.mapper.PatientManageMapper;
import com.healthlink.his.empi.event.PatientSavedEvent;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import jakarta.servlet.http.HttpServletRequest;
@@ -54,6 +56,9 @@ public class PatientInformationServiceImpl implements IPatientInformationService
@Autowired
PatientManageMapper patientManageMapper;
@Autowired
private ApplicationEventPublisher eventPublisher;
@Autowired
private IPatientService patientService;
@@ -379,9 +384,13 @@ public class PatientInformationServiceImpl implements IPatientInformationService
.set(Patient::getEducationLevel, patient.getEducationLevel())
.set(Patient::getCompanyAddress, patient.getCompanyAddress());
patientService.update(updateWrapper);
// 发布患者保存事件触发EMPI同步
eventPublisher.publishEvent(new PatientSavedEvent(this, patient, false));
} else {
// 新增操作
patientService.save(patient);
// 发布患者保存事件触发EMPI同步
eventPublisher.publishEvent(new PatientSavedEvent(this, patient, true));
}
return patient;

View File

@@ -0,0 +1,53 @@
-- V2026_0616_1: EMPI核心表 — empi_person + empi_person_id_mapping
-- 补充 V20 中遗漏的两张EMPI核心表
-- 1. EMPI主索引表全局患者主记录
CREATE TABLE IF NOT EXISTS empi_person (
id BIGSERIAL PRIMARY KEY,
global_id VARCHAR(32) NOT NULL,
id_card_no VARCHAR(20),
patient_name VARCHAR(50),
gender VARCHAR(10),
birth_date DATE,
phone VARCHAR(20),
address TEXT,
merge_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
source_system VARCHAR(50),
create_by VARCHAR(64),
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
update_by VARCHAR(64),
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
tenant_id INT DEFAULT 0,
delete_flag VARCHAR(1) NOT NULL DEFAULT '0'
);
COMMENT ON TABLE empi_person IS 'EMPI患者主索引';
COMMENT ON COLUMN empi_person.global_id IS '全局唯一患者ID';
COMMENT ON COLUMN empi_person.merge_status IS '合并状态(ACTIVE/MERGED)';
CREATE UNIQUE INDEX IF NOT EXISTS idx_ep_global ON empi_person(global_id);
CREATE INDEX IF NOT EXISTS idx_ep_idcard ON empi_person(id_card_no);
CREATE INDEX IF NOT EXISTS idx_ep_name ON empi_person(patient_name);
-- 2. EMPI ID映射表全局ID与院内系统患者ID的映射关系
CREATE TABLE IF NOT EXISTS empi_person_id_mapping (
id BIGSERIAL PRIMARY KEY,
global_id VARCHAR(32) NOT NULL,
local_patient_id BIGINT NOT NULL,
source_system VARCHAR(50) NOT NULL,
id_type VARCHAR(20) NOT NULL,
id_value VARCHAR(100) NOT NULL,
create_by VARCHAR(64),
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
update_by VARCHAR(64),
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
tenant_id INT DEFAULT 0,
delete_flag VARCHAR(1) NOT NULL DEFAULT '0'
);
COMMENT ON TABLE empi_person_id_mapping IS 'EMPI患者ID映射表';
COMMENT ON COLUMN empi_person_id_mapping.global_id IS 'EMPI全局患者ID';
COMMENT ON COLUMN empi_person_id_mapping.local_patient_id IS '院内患者ID';
COMMENT ON COLUMN empi_person_id_mapping.source_system IS '来源系统编码';
COMMENT ON COLUMN empi_person_id_mapping.id_type IS '标识类型(MRN/INSURANCE/CARD等)';
COMMENT ON COLUMN empi_person_id_mapping.id_value IS '标识值';
CREATE INDEX IF NOT EXISTS idx_epim_global ON empi_person_id_mapping(global_id);
CREATE INDEX IF NOT EXISTS idx_epim_local ON empi_person_id_mapping(local_patient_id);
CREATE INDEX IF NOT EXISTS idx_epim_source ON empi_person_id_mapping(source_system);

View File

@@ -0,0 +1,18 @@
package com.healthlink.his.empi.event;
import com.healthlink.his.administration.domain.Patient;
import org.springframework.context.ApplicationEvent;
public class PatientSavedEvent extends ApplicationEvent {
private final Patient patient;
private final boolean isNew;
public PatientSavedEvent(Object source, Patient patient, boolean isNew) {
super(source);
this.patient = patient;
this.isNew = isNew;
}
public Patient getPatient() { return patient; }
public boolean isNew() { return isNew; }
}

View File

@@ -0,0 +1,109 @@
package com.healthlink.his.empi.listener;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.healthlink.his.administration.domain.Patient;
import com.healthlink.his.empi.domain.EmpiPerson;
import com.healthlink.his.empi.domain.EmpiPersonIdMapping;
import com.healthlink.his.empi.event.PatientSavedEvent;
import com.healthlink.his.empi.service.IEmpiPersonIdMappingService;
import com.healthlink.his.empi.service.IEmpiPersonService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import java.util.UUID;
@Slf4j
@Component
public class EmpiSyncListener {
@Autowired
private IEmpiPersonService empiPersonService;
@Autowired
private IEmpiPersonIdMappingService empiPersonIdMappingService;
@EventListener
public void onPatientSaved(PatientSavedEvent event) {
Patient patient = event.getPatient();
try {
if (event.isNew()) {
syncNewPatient(patient);
} else {
syncUpdatedPatient(patient);
}
} catch (Exception e) {
log.error("EMPI同步失败, patientId={}, error={}", patient.getId(), e.getMessage(), e);
}
}
private void syncNewPatient(Patient patient) {
String idCard = patient.getIdCard();
EmpiPerson existingPerson = null;
if (idCard != null && !idCard.isEmpty()) {
existingPerson = empiPersonService.getOne(
new LambdaQueryWrapper<EmpiPerson>()
.eq(EmpiPerson::getIdCardNo, idCard)
.eq(EmpiPerson::getMergeStatus, "ACTIVE"));
}
if (existingPerson == null) {
EmpiPerson person = new EmpiPerson();
person.setGlobalId(UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase());
person.setIdCardNo(idCard);
person.setName(patient.getName());
person.setGender(patient.getGenderEnum() != null && patient.getGenderEnum() == 1 ? "M" : "F");
person.setBirthDate(patient.getBirthDate());
person.setPhone(patient.getPhone());
person.setAddress(patient.getAddress());
person.setMergeStatus("ACTIVE");
person.setSourceSystem("HIS");
empiPersonService.save(person);
existingPerson = person;
log.info("EMPI主索引创建: globalId={}, patientId={}", existingPerson.getGlobalId(), patient.getId());
}
createMappingIfNeeded(existingPerson.getGlobalId(), patient);
}
private void syncUpdatedPatient(Patient patient) {
EmpiPersonIdMapping mapping = empiPersonIdMappingService.getOne(
new LambdaQueryWrapper<EmpiPersonIdMapping>()
.eq(EmpiPersonIdMapping::getLocalPatientId, patient.getId()));
if (mapping != null) {
EmpiPerson person = empiPersonService.getOne(
new LambdaQueryWrapper<EmpiPerson>()
.eq(EmpiPerson::getGlobalId, mapping.getGlobalId()));
if (person != null) {
person.setName(patient.getName());
person.setGender(patient.getGenderEnum() != null && patient.getGenderEnum() == 1 ? "M" : "F");
person.setBirthDate(patient.getBirthDate());
person.setPhone(patient.getPhone());
person.setAddress(patient.getAddress());
empiPersonService.updateById(person);
}
} else {
syncNewPatient(patient);
}
}
private void createMappingIfNeeded(String globalId, Patient patient) {
EmpiPersonIdMapping existing = empiPersonIdMappingService.getOne(
new LambdaQueryWrapper<EmpiPersonIdMapping>()
.eq(EmpiPersonIdMapping::getGlobalId, globalId)
.eq(EmpiPersonIdMapping::getLocalPatientId, patient.getId()));
if (existing == null) {
EmpiPersonIdMapping mapping = new EmpiPersonIdMapping();
mapping.setGlobalId(globalId);
mapping.setLocalPatientId(patient.getId());
mapping.setSourceSystem("HIS");
mapping.setIdType("MRN");
mapping.setIdValue(patient.getBusNo() != null ? patient.getBusNo() : String.valueOf(patient.getId()));
empiPersonIdMappingService.save(mapping);
log.info("EMPI映射创建: globalId={}, patientId={}", globalId, patient.getId());
}
}
}

View File

@@ -1,12 +1,19 @@
import request from '@/utils/request'
// EMPI基础操作
export function registerPerson(data) { return request({ url: '/api/v1/empi/person', method: 'post', data }) }
export function mergePersons(primaryId, secondaryIds) { return request({ url: '/api/v1/empi/merge', method: 'post', params: { primaryId, secondaryIds: secondaryIds.join(',') } }) }
export function findByGlobalId(globalId) { return request({ url: '/api/v1/empi/person/global/' + globalId, method: 'get' }) }
export function findByIdCard(idCardNo) { return request({ url: '/api/v1/empi/person/idcard/' + idCardNo, method: 'get' }) }
export function getMappings(globalId) { return request({ url: '/api/v1/empi/mappings/' + globalId, method: 'get' }) }
export function getStatistics() { return request({ url: '/api/v1/empi/statistics', method: 'get' }) }
export function listPersons(params) { return request({ url: '/api/v1/empi/persons', method: 'get', params }) }
// 关联院内患者查询
export function findLinkedPatientsByGlobalId(globalId) { return request({ url: '/api/v1/empi/linked-patients/global/' + globalId, method: 'get' }) }
export function findLinkedPatientsByIdCard(idCardNo) { return request({ url: '/api/v1/empi/linked-patients/idcard/' + idCardNo, method: 'get' }) }
// EMPI增强功能
export function getPhotos(patientId) { return request({ url: '/empi-enhanced/photo/list', method: 'get', params: { patientId } }) }
export function addPhoto(data) { return request({ url: '/empi-enhanced/photo/add', method: 'post', data }) }
export function getFamilyMembers(patientId) { return request({ url: '/empi-enhanced/family/list', method: 'get', params: { patientId } }) }
@@ -14,4 +21,4 @@ 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 }) }

View File

@@ -1,29 +1,105 @@
<template>
<div class="app-container">
<el-card>
<template #header><span>患者合并管理</span></template>
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center">
<span>患者合并管理</span>
<el-button type="primary" :disabled="!primaryPatient" @click="handleMerge">
合并选中患者 ({{ selectedRows.length }})
</el-button>
</div>
</template>
<el-form :inline="true" class="mb8">
<el-form-item label="主患者ID"><el-input v-model="primaryId" placeholder="主患者ID" /></el-form-item>
<el-form-item label="待合并ID"><el-input v-model="secondaryIds" placeholder="逗号分隔多个ID" /></el-form-item>
<el-form-item label="姓名">
<el-input v-model="searchName" placeholder="患者姓名" clearable @keyup.enter="loadPatients" />
</el-form-item>
<el-form-item label="身份证号">
<el-input v-model="searchIdCard" placeholder="身份证号" clearable @keyup.enter="loadPatients" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleMerge">合并</el-button>
<el-button type="primary" @click="loadPatients">查询</el-button>
<el-button @click="searchName=''; searchIdCard=''; loadPatients()">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="mt8">
<template #header><span>合并日志</span></template>
<el-table v-loading="loading" :data="mergeLogs">
<el-table-column label="主患者" prop="primaryPatientName" width="120" />
<el-table-column label="被合并患者" prop="secondaryPatientName" width="120" />
<el-table-column label="合并原因" prop="mergeReason" width="200" show-overflow-tooltip />
<el-table-column label="操作人" prop="operatorName" width="100" />
<el-table-column label="合并时间" prop="mergeTime" width="170" />
<el-table-column label="状态" prop="status" width="90">
<template #default="s"><el-tag :type="s.row.status==='ACTIVE'?'success':'info'">{{ s.row.status === 'ACTIVE' ? '有效' : '已撤销' }}</el-tag></template>
<el-alert type="info" :closable="false" style="margin-bottom:12px">
先点击"设为主患者"选一个保留的再勾选要合并的其他患者最后点右上角合并按钮
</el-alert>
<el-table v-loading="loading" :data="patientList" border stripe @selection-change="handleSelectionChange" row-key="id">
<el-table-column type="selection" width="50" :selectable="canSelect" />
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="globalId" label="全局ID" width="160" />
<el-table-column prop="name" label="姓名" width="100">
<template #default="{row}">
<span :style="primaryPatient &amp;&amp; primaryPatient.id === row.id ? 'color:#409eff;font-weight:bold' : ''">
{{ row.name }}
</span>
</template>
</el-table-column>
<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' : row.mergeStatus === 'MERGED' ? 'info' : 'warning'" size="small">
{{ row.mergeStatus === 'ACTIVE' ? '正常' : row.mergeStatus === 'MERGED' ? '已合并' : '待处理' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="s">
<el-button link type="warning" v-if="s.row.status==='ACTIVE'" @click="handleUndo(s.row)">撤销</el-button>
<template #default="{row}">
<el-button link type="primary" size="small"
:type="primaryPatient &amp;&amp; primaryPatient.id === row.id ? 'success' : ''"
@click="setPrimary(row)">
{{ primaryPatient &amp;&amp; primaryPatient.id === row.id ? '已选为主' : '设为主患者' }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="confirmVisible" title="确认合并" width="500px">
<el-descriptions title="主患者(保留)" :column="2" border size="small">
<el-descriptions-item label="姓名">{{ primaryPatient?.name }}</el-descriptions-item>
<el-descriptions-item label="全局ID">{{ primaryPatient?.globalId }}</el-descriptions-item>
<el-descriptions-item label="身份证">{{ primaryPatient?.idCardNo }}</el-descriptions-item>
<el-descriptions-item label="电话">{{ primaryPatient?.phone }}</el-descriptions-item>
</el-descriptions>
<el-divider />
<p style="font-weight:bold;margin-bottom:8px">将被合并的患者{{ selectedRows.length }}</p>
<el-table :data="selectedRows" border size="small" max-height="200">
<el-table-column prop="name" label="姓名" width="80" />
<el-table-column prop="globalId" label="全局ID" width="160" />
<el-table-column prop="idCardNo" label="身份证号" width="180" />
</el-table>
<template #footer>
<el-button @click="confirmVisible = false">取消</el-button>
<el-button type="danger" @click="doMerge">确认合并</el-button>
</template>
</el-dialog>
<el-card style="margin-top:16px">
<template #header><span>合并日志</span></template>
<el-table v-loading="logLoading" :data="mergeLogs" border stripe>
<el-table-column label="主患者" prop="sourcePatientId" width="120">
<template #default="{row}">{{ getPatientName(row.sourcePatientId) }}</template>
</el-table-column>
<el-table-column label="被合并" prop="targetPatientId" width="120">
<template #default="{row}">{{ getPatientName(row.targetPatientId) }}</template>
</el-table-column>
<el-table-column label="类型" prop="mergeType" width="80" />
<el-table-column label="原因" prop="mergeReason" min-width="150" show-overflow-tooltip />
<el-table-column label="操作人" prop="mergeBy" width="90" />
<el-table-column label="时间" prop="mergeTime" width="170" />
<el-table-column label="状态" prop="status" width="80">
<template #default="{row}">
<el-tag :type="row.status==='MERGED'?'success':'info'" size="small">
{{ row.status === 'MERGED' ? '已合并' : '已撤回' }}
</el-tag>
</template>
</el-table-column>
</el-table>
@@ -33,22 +109,73 @@
<script setup>
import { ref, onMounted } from 'vue'
import { mergePersons, getMergeLogPage, undoMergeLog } from '../api'
import { mergePersons, getMergeLogPage, listPersons } from '../api'
import { ElMessage, ElMessageBox } from 'element-plus'
const loading = ref(false); const mergeLogs = ref([])
const primaryId = ref(''); const secondaryIds = ref('')
const loading = ref(false)
const logLoading = ref(false)
const patientList = ref([])
const mergeLogs = ref([])
const searchName = ref('')
const searchIdCard = ref('')
const primaryPatient = ref(null)
const selectedRows = ref([])
const confirmVisible = ref(false)
const tableRef = ref(null)
const loadLogs = async () => { loading.value = true; const res = await getMergeLogPage({ pageNo: 1, pageSize: 20 }); mergeLogs.value = res.data?.records || []; loading.value = false }
const handleMerge = async () => {
if (!primaryId.value || !secondaryIds.value) { ElMessage.warning('请填写ID'); return }
await ElMessageBox.confirm('确认合并?', '提示', { type: 'warning' })
await mergePersons(primaryId.value, secondaryIds.value.split(',').map(Number))
ElMessage.success('合并成功'); primaryId.value = ''; secondaryIds.value = ''; loadLogs()
const loadPatients = async () => {
loading.value = true
try {
const res = await listPersons({ name: searchName.value, idCardNo: searchIdCard.value })
patientList.value = res.data || []
} catch (e) {
patientList.value = []
}
loading.value = false
}
const handleUndo = async (row) => {
await ElMessageBox.confirm('确认撤销合并?', '提示', { type: 'warning' })
await undoMergeLog({ id: row.id, operatorName: '管理员' }); ElMessage.success('已撤销'); loadLogs()
const loadLogs = async () => {
logLoading.value = true
const res = await getMergeLogPage({ pageNo: 1, pageSize: 20 })
mergeLogs.value = res.data?.records || []
logLoading.value = false
}
onMounted(() => loadLogs())
</script>
const canSelect = (row) => row.mergeStatus !== 'MERGED'
const handleSelectionChange = (selection) => {
selectedRows.value = selection.filter(r => r.id !== primaryPatient.value?.id)
}
const setPrimary = (row) => {
primaryPatient.value = row
ElMessage.info('主患者已设为: ' + row.name)
}
const handleMerge = () => {
if (!primaryPatient.value) { ElMessage.warning('请先选择主患者'); return }
if (selectedRows.value.length === 0) { ElMessage.warning('请勾选要合并的患者'); return }
confirmVisible.value = true
}
const doMerge = async () => {
try {
await mergePersons(primaryPatient.value.id, selectedRows.value.map(r => r.id))
ElMessage.success('合并成功')
confirmVisible.value = false
primaryPatient.value = null
selectedRows.value = []
loadPatients()
loadLogs()
} catch (e) {
ElMessage.error('合并失败')
}
}
const getPatientName = (id) => {
const p = patientList.value.find(x => x.id === id)
return p ? p.name : id
}
onMounted(() => { loadPatients(); loadLogs() })
</script>

View File

@@ -1,8 +1,8 @@
<template>
<div class="app-container">
<el-row :gutter="20" class="mb8">
<el-col :span="6"><el-card shadow="hover"><el-statistic title="总患者数" :value="stats.totalPatients || 0" /></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><el-statistic title="已合并" :value="stats.mergedPatients || 0" /></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><el-statistic title="总患者数" :value="stats.totalPersons || 0" /></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><el-statistic title="已合并" :value="stats.mergedPersons || 0" /></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><el-statistic title="待合并" :value="stats.pendingMerges || 0" /></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><el-statistic title="重复率" :value="stats.duplicateRate || 0" suffix="%" /></el-card></el-col>
</el-row>
@@ -18,25 +18,66 @@
<el-form-item label="身份证号"><el-input v-model="searchForm.idCardNo" placeholder="身份证号" clearable /></el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="searchForm={};patientData=null">重置</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<el-descriptions v-if="patientData" :column="2" border>
<el-descriptions-item label="全局ID">{{ patientData.globalId }}</el-descriptions-item>
<el-descriptions-item label="姓名">{{ patientData.patientName }}</el-descriptions-item>
<el-descriptions-item label="姓名">{{ patientData.name }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ patientData.gender === 'M' ? '男' : '女' }}</el-descriptions-item>
<el-descriptions-item label="出生日期">{{ patientData.birthDate }}</el-descriptions-item>
<el-descriptions-item label="身份证号">{{ patientData.idCardNo }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ patientData.phone }}</el-descriptions-item>
<el-descriptions-item label="地址" :span="2">{{ patientData.address }}</el-descriptions-item>
<el-descriptions-item label="来源系统">{{ patientData.sourceSystem }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="patientData.mergeStatus === 'ACTIVE' ? 'success' : 'warning'" size="small">
{{ patientData.mergeStatus === 'ACTIVE' ? '正常' : '已合并' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<el-empty v-else description="请输入查询条件" />
</el-card>
<!-- 关联院内患者记录 -->
<el-card v-if="linkedPatients.length > 0" style="margin-top:16px">
<template #header>
<span>关联院内患者记录 ({{ linkedPatients.length }})</span>
</template>
<el-table :data="linkedPatients" border stripe>
<el-table-column prop="id" label="院内ID" width="100" />
<el-table-column prop="busNo" label="病历号" width="140" />
<el-table-column prop="name" label="姓名" width="100" />
<el-table-column prop="genderEnum" label="性别" width="60">
<template #default="{row}">{{ row.genderEnum === 1 ? '男' : '女' }}</template>
</el-table-column>
<el-table-column prop="birthDate" label="出生日期" width="120">
<template #default="{row}">{{ row.birthDate ? row.birthDate.substring(0,10) : '' }}</template>
</el-table-column>
<el-table-column prop="phone" label="电话" width="130" />
<el-table-column prop="idCard" label="身份证号" width="180" />
<el-table-column prop="address" label="地址" min-width="150" show-overflow-tooltip />
</el-table>
</el-card>
<!-- ID映射列表 -->
<el-card v-if="mappings.length > 0" style="margin-top:16px">
<template #header>
<span>ID映射关系</span>
</template>
<el-table :data="mappings" border stripe>
<el-table-column prop="globalId" label="全局ID" width="180" />
<el-table-column prop="localPatientId" label="院内患者ID" width="120" />
<el-table-column prop="sourceSystem" label="来源系统" width="100" />
<el-table-column prop="idType" label="标识类型" width="100" />
<el-table-column prop="idValue" label="标识值" min-width="150" />
</el-table>
</el-card>
<el-dialog title="注册患者" v-model="dialogVisible" width="600px">
<el-form :model="formData" label-width="100px">
<el-row :gutter="20">
<el-col :span="12"><el-form-item label="姓名"><el-input v-model="formData.patientName" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="姓名"><el-input v-model="formData.name" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="性别">
<el-select v-model="formData.gender"><el-option v-for="d in sys_user_sex" :key="d.value" :label="d.label" :value="d.value" /></el-select>
</el-form-item></el-col>
@@ -57,23 +98,58 @@
<script setup>
import { useDict } from '@/utils/dict'
import { ref, reactive, onMounted } from 'vue'
import { registerPerson, findByGlobalId, findByIdCard, getStatistics } from '../api'
import {
registerPerson, findByGlobalId, findByIdCard, getStatistics,
findLinkedPatientsByGlobalId, findLinkedPatientsByIdCard, getMappings
} from '../api'
import { ElMessage } from 'element-plus'
const { sys_user_sex } = useDict('sys_user_sex')
const stats = ref({})
const searchForm = reactive({ globalId: '', idCardNo: '' })
const patientData = ref(null)
const linkedPatients = ref([])
const mappings = ref([])
const dialogVisible = ref(false)
const formData = ref({})
const loadStats = async () => { const res = await getStatistics(); stats.value = res.data || {} }
const handleSearch = async () => {
if (searchForm.globalId) { const res = await findByGlobalId(searchForm.globalId); patientData.value = res.data }
else if (searchForm.idCardNo) { const res = await findByIdCard(searchForm.idCardNo); patientData.value = res.data }
else { ElMessage.warning('请输入查询条件') }
linkedPatients.value = []
mappings.value = []
if (searchForm.globalId) {
const res = await findByGlobalId(searchForm.globalId)
patientData.value = res.data
if (res.data) {
const lp = await findLinkedPatientsByGlobalId(searchForm.globalId)
linkedPatients.value = lp.data || []
const mp = await getMappings(searchForm.globalId)
mappings.value = mp.data || []
}
} else if (searchForm.idCardNo) {
const res = await findByIdCard(searchForm.idCardNo)
patientData.value = res.data
if (res.data) {
const lp = await findLinkedPatientsByIdCard(searchForm.idCardNo)
linkedPatients.value = lp.data || []
const mp = await getMappings(res.data.globalId)
mappings.value = mp.data || []
}
} else {
ElMessage.warning('请输入查询条件')
}
}
const handleReset = () => {
searchForm.globalId = ''
searchForm.idCardNo = ''
patientData.value = null
linkedPatients.value = []
mappings.value = []
}
const handleRegister = () => { formData.value = {}; dialogVisible.value = true }
const submitForm = async () => { await registerPerson(formData.value); ElMessage.success('注册成功'); dialogVisible.value = false; loadStats() }
onMounted(() => loadStats())
</script>
</script>