Compare commits

...

14 Commits

Author SHA1 Message Date
ae746cdd37 fix(#770): 请修复 Bug #770(重试)
根因:
- Bug #请修复 Bug #770(重试) 存在的问题

修复:
- ## 步骤 5: 验证修改
2026-06-16 16:19:53 +08:00
b2c60ab76f Merge remote-tracking branch 'origin/develop' into zhaoyun 2026-06-16 16:06:10 +08:00
wangjian963
8b6265801d fix(门诊医生站): 修复中医tab页药品医嘱搜索选中后不填充及展开行不渲染的问题
vxe-table v4 中 expandRowKeys 仅在初始化时生效,后续变更必须通过实例方法
  setRowExpand/clearRowExpand 控制展开行。同时 vxe-table 浅监听 data prop,
  替换行对象引用会导致 slot scope 响应链路断裂。
2026-06-16 16:05:36 +08:00
fec6e928d8 Merge remote-tracking branch 'origin/develop' into zhaoyun 2026-06-16 15:57:05 +08:00
wangjian963
bf5a9674df fix(门诊医生站): 修复中医诊断弹窗选择后诊断详情不显示的问题
- addDiagnosisDialog: 修复 vxe-table v4 cell-click 事件未解构 row 导致
    conditionName/syndromeName 为 undefined,右侧详情始终为空
  - diagnosislist: 新增 medTypeCode prop 按诊断类型过滤列表,避免中/西医错选
  - diagnosis: 保存时排除中医诊断(已通过独立接口保存),防止重复提交
2026-06-16 14:30:39 +08:00
471bf2b823 Merge remote-tracking branch 'origin/develop' into zhaoyun 2026-06-16 14:15:44 +08:00
954462272e feat(empi): 添加EMPI合并日志记录功能
- 引入EmpiMergeLog实体类和IEmpiMergeLogService服务接口
- 在EmpiAppServiceImpl中注入mergeLogService依赖
- 实现合并操作时自动创建合并日志记录
- 记录合并的源患者ID、目标患者ID和合并类型
- 添加合并原因、操作人和合并时间等关键信息
- 确保每次患者合并操作都有完整的审计日志
2026-06-16 14:10:37 +08:00
7b42e94b85 Merge remote-tracking branch 'origin/develop' into zhaoyun 2026-06-16 13:45:35 +08:00
9a5d772c72 Merge remote-tracking branch 'origin/develop' into develop 2026-06-16 13:38:18 +08:00
d861c20d5e feat(empi): 实现EMPI患者主索引系统核心功能
- 新增EMPI核心数据表:empi_person和empi_person_id_mapping
- 实现EMPI服务层接口,支持患者注册、合并、查询等功能
- 集成EMPI与院内患者系统的双向关联查询
- 添加患者保存事件监听器,实现EMPI数据自动同步
- 开发EMPI管理界面,支持患者合并操作和数据展示
- 优化EMPI统计功能,增加重复率和待合并患者统计
- 完善EMPI ID映射机制,支持多系统患者标识关联
2026-06-16 13:38:05 +08:00
488573a51b fix(#663): guanyu (文件合入) 2026-06-16 13:37:38 +08:00
e35bdb5b9e Merge remote-tracking branch 'origin/develop' into zhaoyun 2026-06-16 13:35:29 +08:00
wangjian963
259a5946c2 667 [门诊收费-业务流程] 医嘱未挂钩【完诊】状态,医生未终结门诊即可提前在收费端结算,存在漏开/错开费用风险
- @select → @checkbox-change,适配新事件签名
  - 新增 collapseAllExpanded() 使用 setRowExpand/clearRowExpand 兼容 v4 expand
  - setNewRow/setValue 保持行引用不变,原地更新数据
  - 所有医嘱类型编辑模板新增"取消"按钮
  - 内联布局样式抽取为 .edit-form-row 类,四种医嘱类型布局统一
  - 列宽、间距、备注框宽度等样式微调
  - requiredProps 空安全检查、handleBlur 修复、ref 名称修复
2026-06-16 13:33:42 +08:00
d0d6cf3533 fix(#770): zhaoyun (文件合入) 2026-06-16 13:30:49 +08:00
18 changed files with 787 additions and 265 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,129 @@
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.domain.EmpiMergeLog;
import com.healthlink.his.empi.service.IEmpiMergeLogService;
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;
@Autowired private IEmpiMergeLogService mergeLogService;
@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);
EmpiMergeLog logRecord = new EmpiMergeLog();
logRecord.setSourcePatientId(primaryId);
logRecord.setTargetPatientId(secId);
logRecord.setMergeType("MERGE");
logRecord.setMergeReason("EMPI合并");
logRecord.setMergeBy("system");
logRecord.setMergeTime(new java.util.Date());
logRecord.setStatus("MERGED");
mergeLogService.save(logRecord);
}
}
@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,22 @@
-- V45: Create lab_activity_def_device_def table
-- 检验活动定义与耗材/设备定义关联表
CREATE TABLE IF NOT EXISTS lab_activity_def_device_def (
id BIGINT PRIMARY KEY,
activity_definition_id BIGINT,
device_definition_id BIGINT,
device_definition_name VARCHAR(255),
instrument_id BIGINT,
instrument_name VARCHAR(255),
device_quantity INTEGER DEFAULT 0,
delete_flag VARCHAR(1) DEFAULT '0',
activity_definition_name VARCHAR(255),
create_by VARCHAR(64),
create_time TIMESTAMP,
update_by VARCHAR(64),
update_time TIMESTAMP,
tenant_id INTEGER
);
CREATE INDEX IF NOT EXISTS idx_lab_act_dev_act_def_id ON lab_activity_def_device_def(activity_definition_id);
CREATE INDEX IF NOT EXISTS idx_lab_act_dev_tenant ON lab_activity_def_device_def(tenant_id);

View File

@@ -44,6 +44,7 @@
ON T1.organization_id = T4.id
AND T4.delete_flag = '0'
WHERE T1.delete_flag = '0'
AND T1.status_enum IN (4, 5)
GROUP BY T1.id,
T1.bus_no,
T1.patient_id,

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,6 +1,6 @@
<template>
<el-dialog
v-model="props.openAddDiagnosisDialog"
v-model="openDialog"
title="添加中医诊断"
width="1300px"
teleported
@@ -153,6 +153,11 @@ const props = defineProps({
},
});
const openDialog = ref(false);
watch(() => props.openAddDiagnosisDialog, (val) => {
openDialog.value = val;
}, { immediate: true });
const conditionList = ref([]);
const syndromeList = ref([]);
const tcmDiagonsisList = ref([]);
@@ -260,7 +265,8 @@ function handleOpen() {
}
// 点击诊断列表处理,点击以后才显示证候列表
function handleClickRow(row) {
// vxe-table v4 cell-click 事件参数为 { row, column, rowIndex, ... },需解构获取实际行数据
function handleClickRow({ row }) {
if (syndromeSelected.value || tcmDiagonsisList.value.length == 0) {
syndromeSelected.value = false;
selectedDisease.value = true;
@@ -286,7 +292,8 @@ function handleClickRow(row) {
}
}
function clickSyndromeRow(row) {
// vxe-table v4 cell-click 事件参数为 { row, column, rowIndex, ... },需解构获取实际行数据
function clickSyndromeRow({ row }) {
// 检查是否已存在完全相同的诊断和证候
let flag = true;
const currentConditionName = tcmDiagonsisList.value[tcmDiagonsisList.value.length - 1].conditionName;

View File

@@ -219,6 +219,7 @@
<div class="diagnosis-popover-body">
<diagnosislist
:diagnosis-searchkey="diagnosisSearchkey"
:med-type-code="scope.row.medTypeCode"
@select-diagnosis="(row) => handleSelectDiagnosis(row, scope.row, scope.rowIndex)"
/>
</div>
@@ -810,8 +811,18 @@ async function handleSaveDiagnosis() {
// 开始加载状态,防止重复提交
saveLoading.value = true;
// 保存前按排序号排序,并转换日期格式为后端期望的格式 yyyy/M/d HH:mm:ss
const diagnosisChildList = form.value.diagnosisList.map(item => ({
// 保存前按排序号排序,排除中医诊断(已通过中医对话框独立保存),并转换日期格式
const westernOnlyList = form.value.diagnosisList
.filter(item => item.typeName !== '中医诊断');
// 如果仅有中医诊断无西医诊断,直接刷新列表即可(中医已通过对话框独立保存)
if (westernOnlyList.length === 0) {
saveLoading.value = false;
proxy.$modal.msgWarning('没有需要保存的西医诊断');
return;
}
const diagnosisChildList = westernOnlyList.map(item => ({
...item,
onsetDate: item.onsetDate ? formatDateStr(item.onsetDate, 'YYYY/M/D HH:mm:ss') : null,
diagnosisTime: item.diagnosisTime ? formatDateStr(item.diagnosisTime, 'YYYY/M/D HH:mm:ss') : null

View File

@@ -70,6 +70,11 @@ const props = defineProps({
type: String,
default: '',
},
/** 当前行的诊断类型编码,用于按分类过滤诊断列表 */
medTypeCode: {
type: String,
default: '',
},
});
const emit = defineEmits(['selectDiagnosis']);
@@ -82,6 +87,18 @@ const queryParams = ref({
});
const diagnosisDefinitionList = ref([]);
/**
* 将 medTypeCode 映射为后端 typeCode 参数
* '1' (西医诊断) → typeCode='1'
* '2','3' (中医主病/主证) → typeCode='2'
* 其他 → 不过滤
*/
function mapMedTypeToTypeCode(medTypeCode) {
if (medTypeCode === '1') return '1';
if (medTypeCode === '2' || medTypeCode === '3') return '2';
return undefined;
}
// 监听外部传入的搜索关键字
watch(
() => props.diagnosisSearchkey,
@@ -94,9 +111,22 @@ watch(
{ immediate: true }
);
// 监听诊断类型变化(切换不同分类的行时重新加载)
watch(
() => props.medTypeCode,
() => {
queryParams.value.pageNo = 1;
getList();
}
);
// 获取诊断列表
function getList() {
getDiagnosisDefinitionList(queryParams.value).then((res) => {
const params = {
...queryParams.value,
typeCode: mapMedTypeToTypeCode(props.medTypeCode),
};
getDiagnosisDefinitionList(params).then((res) => {
diagnosisDefinitionList.value = res.data.records || [];
total.value = res.data.total || 0;
});

View File

@@ -198,7 +198,7 @@
<el-dialog
v-model="open"
:title="title"
width="1000px"
width="1200px"
teleported
:close-on-click-modal="false"
@close="cancel"
@@ -397,7 +397,7 @@
</el-row>
<!-- 次要手术表格 -->
<el-row v-if="form.secondarySurgeries && form.secondarySurgeries.length > 0">
<el-row v-if="form.secondarySurgeries && form.secondarySurgeries.length > 0" style="margin-top: 12px;">
<el-col
:span="24"
style="margin-bottom: 20px;"
@@ -407,10 +407,12 @@
border
stripe
size="small"
style="width: 100%"
:column-config="{ resizable: true }"
>
<vxe-column
title="手术名称"
min-width="200"
min-width="250"
>
<template #default="scope">
<el-select
@@ -518,7 +520,7 @@
</vxe-column>
<vxe-column
title="操作"
width="100"
width="80"
align="center"
>
<template #default="scope">
@@ -1815,4 +1817,11 @@ defineExpose({
text-align: center;
padding: 20px 0;
}
/* Bug #770: 确保对话框表单内容可滚动,防止操作按钮遮盖字段 */
:deep(.el-dialog__body) {
max-height: calc(100vh - 200px);
overflow-y: auto;
padding-bottom: 10px;
}
</style>

View File

@@ -193,13 +193,13 @@
/>
</div>
<vxe-table
ref="prescriptionRef"
:ref="(el) => { if (el) tableRefs[pIndex] = el }"
v-loading="loading"
:data="prescription.prescriptionList"
:row-config="{ keyField: 'uniqueKey', expandRowKeys: prescription.expandOrder }"
border
@cell-click="clickRow"
@cell-dblclick="(row) => clickRowDb(row, pIndex)"
@cell-dblclick="({ row }) => clickRowDb(row, pIndex)"
>
<vxe-column
type="expand"
@@ -217,8 +217,8 @@
style="padding: 16px; background: #f8f9fa; border-radius: 8px"
>
<template v-if="scope.row.adviceType == 1">
<div style="display: flex; align-items: center; margin-bottom: 16px; gap: 8px">
<span class="medicine-title">
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: nowrap">
<span class="medicine-title" style="flex-shrink: 0">
{{
scope.row.adviceName +
' ' +
@@ -230,119 +230,45 @@
']'
}}
</span>
<el-form-item
prop="lotNumber"
label="药房"
>
<el-select
v-model="scope.row.inventoryId"
style="width: 380px; margin-right: 20px"
placeholder="药房"
>
<el-form-item prop="lotNumber" label="药房" style="margin-bottom: 0; flex-shrink: 0">
<el-select v-model="scope.row.inventoryId" style="width: 280px" placeholder="药房">
<el-option
v-for="item in scope.row.stockList"
:key="item.inventoryId"
:value="item.inventoryId"
:label="
item.locationName +
' ' +
'批次号: ' +
item.lotNumber +
' ' +
' 库存:' +
item.quantity / scope.row.partPercent +
item.unitCode_dictText +
' 单价:' +
item.price.toFixed(2) +
'/' +
item.unitCode_dictText
"
:label="item.locationName + ' 库存:' + item.quantity / scope.row.partPercent + item.unitCode_dictText + ' 单价:' + item.price.toFixed(2) + '/' + item.unitCode_dictText"
@click="handleNumberClick(item, scope.rowIndex, pIndex)"
/>
</el-select>
</el-form-item>
<div class="form-group">
<!-- 单次剂量 -->
<el-form-item
label="数量"
prop="minUnitQuantity"
class="required-field"
data-prop="minUnitQuantity"
>
<el-input-number
:ref="(el) => (inputRefs.minUnitQuantity = el)"
v-model="scope.row.minUnitQuantity"
:min="0"
controls-position="right"
:controls="false"
style="width: 70px; margin-right: 20px"
@input="
() => {
nextTick(() => {
scope.row.totalPrice =
scope.row.minUnitQuantity * scope.row.unitPrice;
});
}
"
@keyup.enter.prevent="
handleEnter('minUnitQuantity', scope.row, scope.rowIndex, pIndex)
"
<el-form-item label="数量" prop="minUnitQuantity" class="required-field" data-prop="minUnitQuantity" style="margin-bottom: 0; flex-shrink: 0">
<el-input-number
:ref="(el) => (inputRefs.minUnitQuantity = el)"
v-model="scope.row.minUnitQuantity"
:min="0" controls-position="right" :controls="false" style="width: 75px"
@input="() => { nextTick(() => { scope.row.totalPrice = scope.row.minUnitQuantity * scope.row.unitPrice; }); }"
@keyup.enter.prevent="handleEnter('minUnitQuantity', scope.row, scope.rowIndex, pIndex)"
/>
</el-form-item>
<el-select v-model="scope.row.minUnitCode" style="width: 75px; flex-shrink: 0" placeholder=" ">
<template v-for="item in scope.row.unitCodeList" :key="item.value">
<el-option
v-if="scope.row.unitCodeList.length == 3 ? item.type == unitMap['minUnit'] : item.type == unitMap['unit']"
:value="item.value" :label="item.label"
/>
</el-form-item>
<!-- 剂量单位 -->
<el-select
v-model="scope.row.minUnitCode"
style="width: 80px; margin-right: 20px"
placeholder=" "
>
<template
v-for="item in scope.row.unitCodeList"
:key="item.value"
>
<el-option
v-if="
scope.row.unitCodeList.length == 3
? item.type == unitMap['minUnit']
: item.type == unitMap['unit']
"
:value="item.value"
:label="item.label"
/>
</template>
</el-select>
<el-form-item label="特殊煎法" prop="dosageInstruction" class="required-field" data-prop="dosageInstruction" style="margin-bottom: 0; flex-shrink: 0">
<el-select v-model="scope.row.dosageInstruction" style="width: 95px" placeholder=" ">
<template v-for="item in dosage_instruction" :key="item.value">
<el-option :value="item.value" :label="item.label" />
</template>
</el-select>
<!-- 单次剂量 -->
<el-form-item
label="特殊煎法"
prop="dosageInstruction"
class="required-field"
data-prop="dosageInstruction"
>
<el-select
v-model="scope.row.dosageInstruction"
style="width: 90px; margin-right: 20px"
placeholder=" "
>
<template
v-for="item in dosage_instruction"
:key="item.value"
>
<el-option
:value="item.value"
:label="item.label"
/>
</template>
</el-select>
</el-form-item>
</div>
<span class="total-amount">
总金额:{{
scope.row.totalPrice ? scope.row.totalPrice + ' 元' : '0.00 元'
}}
</el-form-item>
<span class="total-amount" style="flex-shrink: 0; white-space: nowrap">
总金额:{{ scope.row.totalPrice ? scope.row.totalPrice + ' 元' : '0.00 元' }}
</span>
<el-button
type="primary"
@click="handleSaveSign(scope.row, scope.rowIndex, pIndex)"
>
<el-button type="primary" style="flex-shrink: 0" @click="handleSaveSign(scope.row, scope.rowIndex, pIndex)">
确定
</el-button>
</div>
@@ -394,7 +320,7 @@
:width="1200"
>
<tcmMedicineList
ref="adviceTableRef"
:ref="(el) => { if (el) activeAdviceTable = el }"
:popover-visible="scope.row.showPopover"
:advice-query-params="adviceQueryParams"
:patient-info="props.patientInfo"
@@ -416,7 +342,7 @@
if (['ArrowUp', 'ArrowDown', 'Enter'].includes(e.key)) {
e.preventDefault();
// 传递事件到弹窗容器
adviceTableRef.handleKeyDown(e);
activeAdviceTable?.handleKeyDown(e);
}
}
"
@@ -602,7 +528,7 @@ const tcmPrescriptionList = ref([
},
]);
const unitCodeList = ref([]);
const adviceTableRef = ref([]);
const activeAdviceTable = ref(null);
const organization = ref([]);
const loading = ref(false);
const rowRules = ref({
@@ -625,7 +551,7 @@ const props = defineProps({
type: String,
},
});
const prescriptionRef = ref();
const tableRefs = ref({});
const stockList = ref([]);
const contractList = ref([]);
const tcmDiagnosisList = ref([]);
@@ -842,7 +768,7 @@ function handleAddMedicine(pIndex) {
/**
* 点击行赋值
*/
function clickRow(row) {
function clickRow({ row }) {
emit('selectDiagnosis', row);
}
@@ -856,7 +782,7 @@ function clickRowDb(row, pIndex) {
const index = prescription.prescriptionList.findIndex(
(item) => item.uniqueKey === row.uniqueKey
);
prescription.prescriptionList[index] = row;
prescription.prescriptionList.splice(index, 1, row);
prescription.expandOrder = [row.uniqueKey];
}
}
@@ -873,8 +799,14 @@ function handleFocus(row, index, pIndex) {
row.showPopover = true;
}
let blurTimer = null;
function handleBlur(row) {
row.showPopover = false;
if (blurTimer) clearTimeout(blurTimer);
blurTimer = setTimeout(() => {
row.showPopover = false;
blurTimer = null;
}, 200);
}
function handleChange(value) {
@@ -995,96 +927,59 @@ function getPrescriptionMedicineCount(prescriptionIndex) {
* 选择药品回调
*/
function selectAdviceBase(key, row, pIndex) {
// ... 保持原有逻辑,但使用对应处方的数据 ...
const prescription = tcmPrescriptionList.value[pIndex];
getOrgList();
unitCodeList.value = [];
unitCodeList.value.push({ value: row.unitCode, label: row.unitCode_dictText, type: 'unit' });
unitCodeList.value.push({ value: row.unitCode, label: row.unitCode_dictText, type: "unit" });
if (row.doseUnitCode != row.minUnitCode) {
unitCodeList.value.push({
value: row.doseUnitCode,
label: row.doseUnitCode_dictText,
type: 'dose',
});
unitCodeList.value.push({ value: row.doseUnitCode, label: row.doseUnitCode_dictText, type: "dose" });
}
if (
(row.partAttributeEnum == 1 || row.partAttributeEnum == 3) &&
row.minUnitCode != row.unitCode
) {
unitCodeList.value.push({
value: row.minUnitCode,
label: row.minUnitCode_dictText,
type: 'minUnit',
});
if ((row.partAttributeEnum == 1 || row.partAttributeEnum == 3) && row.minUnitCode != row.unitCode) {
unitCodeList.value.push({ value: row.minUnitCode, label: row.minUnitCode_dictText, type: "minUnit" });
}
if (row.adviceType == 2 && row.minUnitCode != row.unitCode) {
unitCodeList.value.push({
value: row.minUnitCode,
label: row.minUnitCode_dictText,
type: 'minUnit',
});
unitCodeList.value.push({ value: row.minUnitCode, label: row.minUnitCode_dictText, type: "minUnit" });
}
// vxe-table v4: keep row ref, mutate in-place (ref prescriptionlist.vue setValue)
const existingRow = prescription.prescriptionList[rowIndex.value];
if (!existingRow) return;
const pk = existingRow.uniqueKey, pe = existingRow.isEdit, pg = existingRow.groupId;
Object.keys(existingRow).forEach(k => { delete existingRow[k]; });
const rowData = JSON.parse(JSON.stringify(row));
prescription.prescriptionList[rowIndex.value] = {
...prescription.prescriptionList[rowIndex.value], // 保留原有属性(如 uniqueKey, groupId
...rowData, // 覆盖药品信息
statusEnum: 1, // 【关键修复】强制状态为“待签发/草稿”,确保能被保存过滤器捕获
adviceDefinitionId: rowData.adviceDefinitionId // 确保这个字段有值,后端会根据这个字段过滤空行
};
prescription.prescriptionList[rowIndex.value].orgId = undefined;
prescription.prescriptionList[rowIndex.value].dose = undefined;
prescription.prescriptionList[rowIndex.value].unitCodeList = unitCodeList.value;
prescription.prescriptionList[rowIndex.value].doseUnitCode = row.doseUnitCode;
prescription.prescriptionList[rowIndex.value].minUnitCode = row.minUnitCode;
prescription.prescriptionList[rowIndex.value].unitCode =
row.partAttributeEnum == 1 ? row.minUnitCode : row.unitCode;
prescription.prescriptionList[rowIndex.value].definitionId = JSON.parse(
JSON.stringify(row)
).chargeItemDefinitionId;
// 库存列表 + 价格列表拼成批次号的下拉框
Object.assign(existingRow, rowData);
existingRow.uniqueKey = pk; existingRow.isEdit = pe; existingRow.groupId = pg;
existingRow.statusEnum = 1; existingRow.showPopover = false;
existingRow.adviceDefinitionId = rowData.adviceDefinitionId;
existingRow.orgId = undefined; existingRow.dose = undefined;
existingRow.unitCodeList = unitCodeList.value;
existingRow.doseUnitCode = row.doseUnitCode;
existingRow.minUnitCode = row.minUnitCode;
existingRow.unitCode = row.partAttributeEnum == 1 ? row.minUnitCode : row.unitCode;
existingRow.definitionId = JSON.parse(JSON.stringify(row)).chargeItemDefinitionId;
if (row.adviceType == 1 || row.adviceType == 2) {
if (row.inventoryList && row.inventoryList.length == 0) {
prescription.expandOrder = [];
proxy.$modal.msgWarning('该项目无库存');
return;
prescription.expandOrder = []; proxy.$modal.msgWarning("无库存"); return;
}
stockList.value = row.inventoryList.map((item, index) => {
return { ...item, ...row.priceList[index] };
});
prescription.prescriptionList[rowIndex.value].stockList = stockList.value;
// 获取默认批次号的库存,如果没有让医生重新选
let stock = stockList.value.filter((item) => {
return item.lotNumber == row.defaultLotNumber;
})[0];
if (stock != {} && stock != undefined) {
if (stock.quantity <= 0) {
proxy.$modal.msgWarning('该项目库存不足,请选择其它库房');
// return;
}
prescription.prescriptionList[rowIndex.value].lotNumber = stock.lotNumber;
prescription.prescriptionList[rowIndex.value].inventoryId = stock.inventoryId;
prescription.prescriptionList[rowIndex.value].locationId = stock.locationId;
prescription.prescriptionList[rowIndex.value].unitPrice = stock.price;
prescription.prescriptionList[rowIndex.value].positionName = stock.locationName;
stockList.value = row.inventoryList.map((item, i) => ({ ...item, ...row.priceList[i] }));
existingRow.stockList = stockList.value;
const s = stockList.value.filter(it => it.lotNumber == row.defaultLotNumber)[0];
if (s && Object.keys(s).length > 0) {
if (s.quantity <= 0) proxy.$modal.msgWarning("库存不足");
existingRow.lotNumber = s.lotNumber; existingRow.inventoryId = s.inventoryId;
existingRow.locationId = s.locationId; existingRow.unitPrice = s.price;
existingRow.positionName = s.locationName;
}
} else {
prescription.prescriptionList[rowIndex.value].orgId = JSON.parse(
JSON.stringify(row)
).positionId;
prescription.prescriptionList[rowIndex.value].unitPrice = row.priceList[0].price;
existingRow.orgId = JSON.parse(JSON.stringify(row)).positionId;
existingRow.unitPrice = row.priceList[0].price;
}
prescription.expandOrder = [key];
nextTick(() => {
const t = tableRefs.value[pIndex];
if (t && t.setRowExpand) t.setRowExpand([existingRow], true);
if (row.adviceType == 1) {
if (row.injectFlag == 1) {
inputRefs.value['executeNum']?.focus();
} else {
inputRefs.value['dose']?.focus();
}
} else {
inputRefs.value['quantity']?.focus();
}
row.injectFlag == 1 ? inputRefs.value.executeNum?.focus() : inputRefs.value.dose?.focus();
} else { inputRefs.value.quantity?.focus(); }
});
}
@@ -1246,6 +1141,12 @@ function handleSaveSign(row, index, pIndex) {
}
row.contentJson = JSON.stringify(row);
// 强制刷新数组引用,触发 vxe-table 重渲染(切换只读模式 + 关闭展开行)
prescription.prescriptionList = [...prescription.prescriptionList];
nextTick(() => {
const t = tableRefs.value[pIndex];
if (t && t.clearRowExpand) t.clearRowExpand();
});
}
});
}
@@ -1345,6 +1246,8 @@ function escKeyListener(e) {
}
prescription.prescriptionList.shift();
prescription.isAdding = false;
// 强制刷新数组引用,触发 vxe-table 重渲染
prescription.prescriptionList = [...prescription.prescriptionList];
}
}
}

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>