package cn.lihu.jh.module.ecg.service.appointment;
import cn.lihu.jh.framework.common.pojo.PageResult;
import cn.lihu.jh.framework.common.util.date.DateUtils;
import cn.lihu.jh.framework.common.util.object.BeanUtils;
import cn.lihu.jh.module.ecg.controller.admin.appointment.vo.AppointmentConfirmReqVO;
import cn.lihu.jh.module.ecg.controller.admin.appointment.vo.AppointmentPageReqVO;
import cn.lihu.jh.module.ecg.controller.admin.appointment.vo.AppointmentSaveReqVO;
import cn.lihu.jh.module.ecg.controller.admin.queue.vo.QueueSaveReqVO;
import cn.lihu.jh.module.ecg.dal.dataobject.appointment.AppointmentDO;
import cn.lihu.jh.module.ecg.dal.dataobject.patient.PatDetails;
import cn.lihu.jh.module.ecg.dal.dataobject.queue.QueueDO;
import cn.lihu.jh.module.ecg.dal.mysql.appointment.AppointmentMapper;
import cn.lihu.jh.module.ecg.enums.AppointmentTypeEnum;
import cn.lihu.jh.module.ecg.enums.HisCheckCodeEnum;
import cn.lihu.jh.module.ecg.enums.HisCheckNameEnum;
import cn.lihu.jh.module.ecg.enums.HisCheckStatusEnum;
import cn.lihu.jh.module.ecg.feign.RemoteDataService;
import cn.lihu.jh.module.ecg.service.config.EcgConfigService;
import cn.lihu.jh.module.ecg.service.devrent.ApplicationTemplate;
import cn.lihu.jh.module.ecg.service.his.HisEcgFilter;
import cn.lihu.jh.module.ecg.service.queue.QueueService;
import cn.lihu.jh.module.ecg.service.queuesequence.QueueSequenceService;
import cn.lihu.jh.module.ecg.webservice.WebServiceClient;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.ibatis.annotations.Param;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.validation.annotation.Validated;
import javax.annotation.Resource;
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
import static cn.lihu.jh.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.lihu.jh.module.ecg.enums.ErrorCodeConstants.*;
/**
* 预约 Service 实现类
*
* @author 马剑波
*/
@Service
@Validated
@Slf4j
public class AppointmentServiceImpl implements AppointmentService {
/**
* 患者性别「未知」的兜底值。
*
* {@code appointment.pat_gender} 在数据库中为 NOT NULL 且无默认值
* (实测:{@code Field 'pat_gender' doesn't have a default value})。
* 而 {@code S0405} 等报文的性别字段路径与 {@code S0201ECG} 不同、且可能缺失,
* 容错新增时若取不到,按 0(未知)写入,避免插入失败。
*/
private static final Byte UNKNOWN_GENDER = (byte) 0;
@Resource
private EcgConfigService ecgConfigService;
@Resource
private RemoteDataService remoteDataService;
@Resource
private QueueSequenceService queueSequenceService;
@Resource
private QueueService queueService;
@Resource
private AppointmentMapper appointmentMapper;
/**
* 心电业务单判定器。
*
* 用于 {@code S0405}(全院通用状态流)的过滤:非心电单只记日志、不落库,
* 避免向 appointment 表灌入放射/超声等其他科室的数据。
*/
@Resource
private HisEcgFilter hisEcgFilter;
@Autowired
private WebServiceClient webServiceClient;
@Override
public Long createAppointment(AppointmentSaveReqVO createReqVO) {
// 插入
AppointmentDO appointment = BeanUtils.toBean(createReqVO, AppointmentDO.class);
try {
appointmentMapper.insert(appointment);
} catch (DuplicateKeyException e) {
throw exception(APPOINTMENT_EXIST_TODAY);
}
// 返回
return appointment.getId();
}
@Override
public void updateAppointment(AppointmentSaveReqVO updateReqVO) {
// 校验存在
validateAppointmentExists(updateReqVO.getId());
// 更新
AppointmentDO updateObj = BeanUtils.toBean(updateReqVO, AppointmentDO.class);
appointmentMapper.updateById(updateObj);
}
@Override
public void updateAppointmentStatus(AppointmentSaveReqVO updateReqVO) {
// 校验存在
validateAppointmentExists(updateReqVO.getId());
// 获取完整的预约信息
AppointmentDO appointment = appointmentMapper.selectById(updateReqVO.getId());
if (appointment == null) {
throw exception(APPOINTMENT_NOT_EXISTS);
}
// 更新状态
appointment.setStatus(updateReqVO.getStatus());
// 调用HIS系统更新状态
String action = "S0405";
ApplicationTemplate app = new ApplicationTemplate();
String statusName = AppointmentTypeEnum.getByType(appointment.getStatus());
String message = app.getXML(appointment.getApplyNo(), appointment.getStatus(), statusName, null, appointment.getPatDeptCode(), appointment.getPatDeptDesc(), appointment.getPatWardCode(), appointment.getPatWardDesc(), appointment.getPatBedNo(), appointment.getEpisodeId(), String.valueOf(appointment.getBookSrc()), appointment.getPatId(), appointment.getPatName());
try {
String response = webServiceClient.callJHFWTYRK(action, "", message);
if (response.contains("更新成功")) {
// 更新本地数据库
appointmentMapper.updateById(appointment);
} else {
throw exception(APPOINTMENT_UPDATE_FAIL);
}
} catch (Exception e) {
log.error("[updateAppointmentStatus][更新预约状态失败 appointment({})]", appointment, e);
throw exception(APPOINTMENT_UPDATE_FAIL);
}
}
@Override
public void deleteAppointment(Long id) {
// 校验存在
validateAppointmentExists(id);
// 删除
appointmentMapper.deleteById(id);
}
private void validateAppointmentExists(Long id) {
if (appointmentMapper.selectById(id) == null) {
throw exception(APPOINTMENT_NOT_EXISTS);
}
}
@Override
public AppointmentDO getAppointment(Long id) {
return appointmentMapper.selectById(id);
}
@Override
public AppointmentDO getCurAppointmentByPatIdAndCheckType(String patId, Integer checkType) {
AppointmentDO appointmentDO = appointmentMapper.getByPatAndCheckTypeAndBookDate(patId, checkType);
return appointmentDO;
}
/**
* @param patCode 编号 : !!! 不一定就是本系统的 患者编号(patID), 根源在于 检查单扫描出来的编号 不一定是 本系统的 患者编号(patID)
* @return
*/
@Override
public List queryAndCreateAppointmentByPatId(String patCode) {
// 从医院平台查询
List appointmentExtermalList = appointmentMapper.getCurrentCode(patCode);
for (int appointmentIndex = 0; appointmentIndex < appointmentExtermalList.size(); appointmentIndex++) {
AppointmentDO appointmentExtermal = appointmentExtermalList.get(appointmentIndex);
if (null == appointmentExtermal) continue;
// 从DB查询预约,是否已经存在
AppointmentDO appointmentDO = appointmentMapper.getByEpisodeIdAndApplyNo(appointmentExtermal.getEpisodeId(), appointmentExtermal.getApplyNo());
if (null == appointmentDO) {
appointmentMapper.insert(appointmentExtermal);
} else {
appointmentExtermal.setId(appointmentDO.getId()); // 确保 返回值包含 appointment id
// 处理第一次确认时还没有预约时间,再来确认时已有预约时间的情况
if (appointmentDO.getBookDate() != appointmentExtermal.getBookDate()) {
appointmentMapper.updateById(appointmentExtermal);
}
}
}
// 查询[queue表]补充 book_seq_num 到返回值中
if (!appointmentExtermalList.isEmpty()) {
List appointIdlist = appointmentExtermalList.stream().map(appointmentDO -> appointmentDO.getId()).toList();
List simpleQueueDOList = queueService.selectBookSeqNumByAppointIdList(appointIdlist);
if (!simpleQueueDOList.isEmpty()) {
Map mapAppointIdVsBookSeqNo = simpleQueueDOList.stream().collect(Collectors.toMap(QueueDO::getAppointId, QueueDO::getBookSeqNum));
appointmentExtermalList.forEach(appointmentExterma -> appointmentExterma.setBookSeqNum(mapAppointIdVsBookSeqNo.get(appointmentExterma.getId())));
}
}
// 不从DB查询
//List appointmentDOList = appointmentMapper.getCurrentPatId( patCode );
//return 0 == appointmentDOList.size() ? null : appointmentDOList.get(0);
return appointmentExtermalList;
}
@Override
public PageResult getAppointmentPage(AppointmentPageReqVO pageReqVO) {
//1. 先根据patId去查
PageResult result = appointmentMapper.selectPage(pageReqVO);
log.info("--------------PATID result的值为:{}", result);
if (!result.getList().isEmpty()) {
return result;
}
if (StringUtils.isNotEmpty(pageReqVO.getPatId())) {
// 2. 用 applyNo 查询
pageReqVO.setApplyNo(pageReqVO.getPatId());
pageReqVO.setPatId(null);
log.info("------------------ApplyNo pageReqVO的值为:{}", pageReqVO);
result = appointmentMapper.selectPage(pageReqVO);
if (!result.getList().isEmpty()) {
return result;
}
// 3. 用 episodeId 查询
pageReqVO.setEpisodeId(pageReqVO.getApplyNo());
pageReqVO.setPatId(null);
pageReqVO.setApplyNo(null);
log.info("-----------EpisodeId pageReqVO的值为:{}", pageReqVO);
}
return appointmentMapper.selectPage(pageReqVO);
}
@Override
public List getAppointmentExtermal(String patId) {
return appointmentMapper.getCurrentPatId(patId);
}
// @Override
// public List getAppointmentExtermal(String patId) {
// List curAppointDOList = new ArrayList();
//
// RestApiReqBodyVo reqBodyVo = new RestApiReqBodyVo();
// if (18 == patId.length()) {
// reqBodyVo.setSfzh( patId );
// } else {
// reqBodyVo.setMzzyh(patId);
// }
//
// // QueryRisReportList queryEcgRequest
// RestApiResult result = remoteDataService.httpApi("queryEcgRequest", "ECG", "ECG", reqBodyVo);
// if (0 == result.getRow().size()) {
// return curAppointDOList;
// }
//
// for (int appointIndex=0; appointIndex < result.getRow().size(); appointIndex++ ) {
// AppointmentExternal appointmentExternal = result.getRow().get( appointIndex );
//
// AppointmentDO appointmentDO = BeanUtils.toBean(appointmentExternal, AppointmentDO.class);
// appointmentDO.setApplyNo( appointmentExternal.getReqIdeApplyno() );
// appointmentDO.setEpisodeId( appointmentExternal.getEpisodeID() );
// appointmentDO.setPatSrc( getCorrespondingPatientSource(appointmentExternal.getAdmTypeCode()) );
// appointmentDO.setPatId(appointmentExternal.getPatientID()); // 内容为 身份证号 或 门诊住院号
// appointmentDO.setPatName(appointmentExternal.getPatName());
// appointmentDO.setPatGender(Byte.valueOf(appointmentExternal.getPatgender()));
// appointmentDO.setPatBirthday(DateUtils.ofUTC(appointmentExternal.getEncPatBirthDate()).toLocalDate());
// appointmentDO.setPatIdentityId(appointmentExternal.getIdentityID());
// appointmentDO.setPatAddr(appointmentExternal.getAddress());
// appointmentDO.setPatDeptCode(appointmentExternal.getPatLocDeptCode());
// appointmentDO.setPatDeptDesc(appointmentExternal.getPatLocDeptDesc());
// appointmentDO.setPatWardCode(appointmentExternal.getPatLocWardCode());
// appointmentDO.setPatWardDesc(appointmentExternal.getPatLocWardDesc());
// appointmentDO.setPatMobile(appointmentExternal.getPhone());
// appointmentDO.setPatBedNo(appointmentExternal.getEnBedno());
// appointmentDO.setBookCheckType(getCorrespondingCheckType(appointmentExternal.getPlanDefItemList().getPlanDefItem().getPlanDefItemcode()));
// appointmentDO.setBookTime(DateUtils.ofUTC(appointmentExternal.getReqAuthoredOn())); // 开单时间
// appointmentDO.setBookSrc( 0 );
//
// // 光开单的情况,没有预约时间
// if (null != appointmentExternal.getReqExtBooktime() ) {
// appointmentDO.setBookDate(DateUtils.ofUTC(appointmentExternal.getReqExtBooktime()).toLocalDate());
// LocalDateTime bookStartTime = DateUtils.ofUTC(appointmentExternal.getReqExtBooktime());
// LocalDateTime bookEndTime = bookStartTime.plusMinutes( ecgConfigService.getTimeslotLength());
// appointmentDO.setBookTimeslot((bookStartTime.getHour() * 100 + bookStartTime.getMinute()) * 10000 + bookEndTime.getHour() * 100 + bookEndTime.getMinute());
// }
//
// appointmentDO.setPaid( 0 );
//
// curAppointDOList.add( appointmentDO );
// }
//
// return curAppointDOList;
// }
@Override
@Transactional(rollbackFor = Exception.class)
public Integer appoitmentConfirm(AppointmentConfirmReqVO confirmReqVO) {
AppointmentDO appointment = getAppointment(confirmReqVO.getId());
if (null == appointment) throw exception(PATIENT_NOT_EXISTS);
// 手动预约,直接设置预约日期为当天
if (confirmReqVO.getIsVip() == 1) {
appointment.setBookDate(LocalDate.now());
}
if (!DateUtils.isToday(appointment.getBookDate())) throw exception(APPOINTMENT_NOT_TODAY);
// 【需求:排队序号规则】按 check_type.seq_rule 决定取号方式:
// 0=预约号(用预约系统已下发的号;当前 HIS 未下发,会自动回退到签到序号)
// 1=签到序号(当天同检查类型连续递增,每 9 个预留 1 个给加急)
// 2=时段号(按 bookTimeslot 从号池取号 —— 改造前的原有行为,也是未配置时的默认)
boolean urgent = isHisUrgent(appointment);
Integer newSeqNo = queueSequenceService.distributeSeqNoByRule(
confirmReqVO.getBookCheckType(),
confirmReqVO.getBookTimeslot(),
appointment.getBookSeqNum(),
urgent);
log.info("[appoitmentConfirm][分配排队序号] appointmentId={} checkType={} timeslot={} 规则取号={} 加急={}",
appointment.getId(), confirmReqVO.getBookCheckType(), confirmReqVO.getBookTimeslot(),
newSeqNo, urgent);
try {
QueueSaveReqVO queueSaveReqVO = new QueueSaveReqVO();
queueSaveReqVO.setApplyNo(appointment.getApplyNo());
queueSaveReqVO.setEpisodeId(appointment.getEpisodeId());
queueSaveReqVO.setAppointId(appointment.getId());
queueSaveReqVO.setPatId(appointment.getPatId());
queueSaveReqVO.setPatName(appointment.getPatName());
queueSaveReqVO.setPatGender(appointment.getPatGender());
queueSaveReqVO.setBookDate(appointment.getBookDate());
queueSaveReqVO.setBookTimeslot(confirmReqVO.getBookTimeslot()); /*appointment.getBookTimeslot()*/
queueSaveReqVO.setBookCheckType(appointment.getBookCheckType());
queueSaveReqVO.setBookSeqNum(newSeqNo);
queueSaveReqVO.setIsVip(confirmReqVO.getIsVip());
queueSaveReqVO.setPassed((byte) 0);
queueSaveReqVO.setExpired((byte) 0);
// 需求2:ISCB「是否床边」随队列一起落库,供屏幕/页面展示
queueSaveReqVO.setIsBedside(appointment.getIsBedside());
// HIS 加急标记(jjbz=J)→ 排队加急,与人工插队共用 jump_flag。
// queue 表按 "order by jump_flag desc, book_timeslot, create_time" 取号,置 1 即优先。
if (urgent) {
queueSaveReqVO.setJumpFlag((byte) 1);
log.info("[appoitmentConfirm][HIS 加急患者,排队标记为加急] applyNo={} patId={}",
appointment.getApplyNo(), appointment.getPatId());
}
queueSaveReqVO.setPatDetails(getPatDetails(appointment));
queueService.queue(queueSaveReqVO);
} catch (DuplicateKeyException duplicateKeyException) {
throw exception(APPOINTMENT_HAVE_QUEUED);
}
return newSeqNo;
}
/**
* 判断该预约是否为 HIS 标记的加急单。
*
* 依据 {@code appointment.jjbz}(来源 HIS 报文 {@code zdy.jjbz}):
* {@code J} / {@code 1} / {@code Y} 视为加急,其余(如 {@code N})为常规。
*
* 注意:实测 2026-09-17 日志中 60 条 S0201ECG 的 {@code jjbz} **全为 N**,
* 未取得 {@code J} 的真实样例,因此该分支尚未经过真实报文验证。
*
* @param appointment 预约记录,可为 null
* @return true=加急
*/
private boolean isHisUrgent(AppointmentDO appointment) {
if (appointment == null || StringUtils.isEmpty(appointment.getJjbz())) {
return false;
}
String flag = appointment.getJjbz().trim();
return "J".equalsIgnoreCase(flag) || "1".equals(flag) || "Y".equalsIgnoreCase(flag);
}
private PatDetails getPatDetails(AppointmentDO appointment) { PatDetails patDetails = new PatDetails();
patDetails.setAppointmentId(appointment.getId());
patDetails.setId(appointment.getPatId());
patDetails.setName(appointment.getPatName());
patDetails.setMobile(appointment.getPatMobile());
patDetails.setDeptCode(appointment.getPatDeptCode());
patDetails.setDeptDesc(appointment.getPatDeptDesc());
patDetails.setWardCode(appointment.getPatWardCode());
patDetails.setWardDesc(appointment.getPatWardDesc());
patDetails.setBedNo(appointment.getPatBedNo());
patDetails.setSource(appointment.getPatSrc());
return patDetails;
}
@Override
public Long handleAppointmentCreate(Map dataMap) {
if (dataMap == null) {
throw exception(APPOINTMENT_CREATE_FAIL);
}
try {
// 创建AppointmentDO对象
AppointmentDO appointment = new AppointmentDO();
// 从dataMap中提取数据
Map encounter = extractEncounter(dataMap);
if (encounter == null) {
throw exception(APPOINTMENT_CREATE_FAIL);
}
// 设置就诊流水号
Optional.ofNullable(encounter.get("id")).map(id -> (Map) id).map(id -> (List>) id.get("item")).filter(items -> items.size() > 1).map(items -> (Map) items.get(1)).map(item -> (String) item.get("extension")).ifPresent(appointment::setEpisodeId);
Optional.ofNullable(encounter.get("location")).map(location -> (Map) location).map(location -> (Map) location.get("serviceDeliveryLocation")).map(serviceDeliveryLocation -> (Map) serviceDeliveryLocation.get("location")).map(location -> (Map) location.get("id")).map(id -> (Map) id.get("item")).map(item -> (String) item.get("extension")).ifPresent(appointment::setPatBedNo);
Optional.ofNullable(dataMap.get("controlActProcess")).map(controlActProcess -> (Map) controlActProcess).map(controlActProcess -> (Map) controlActProcess.get("subject")).map(subject -> (Map) subject.get("observationRequest")).map(observationRequest -> (Map) observationRequest.get("zdy")).map(zdy -> {
Object ISDN = zdy.get("ISDN");
if (ISDN != null) {
return ISDN.toString();
}
return null;
}).ifPresent(appointment::setTolerance);
// 是否床边(需求2):zdy.ISCB。0=否,1=是。
//
// 与 ISDN 同在 zdy 自定义字段区,且**同属官方文档未定义的 7 个扩展字段**
// (实测真实报文 26 个字段,文档只定义了 19 个)。
// 解析逻辑抽到 findBedsideFlag,便于单元测试。
String bedsideFlag = findBedsideFlag(dataMap);
if (bedsideFlag != null) {
appointment.setIsBedside(bedsideFlag);
}
// 优先级/加急标记(zdy.jjbz:N=常规,J=加急)
Map zdyNode = dig(dataMap, "controlActProcess", "subject",
"observationRequest", "zdy");
if (zdyNode != null) {
Object jjbz = zdyNode.get("jjbz");
if (jjbz != null && StringUtils.isNotEmpty(jjbz.toString().trim())) {
appointment.setJjbz(jjbz.toString().trim());
}
}
// 检查方式编码:methodCode.item[0].code
// 类型安全:methodCode 的 item 可能不存在,可能是单个 Map,也可能是 List
Map c2 = dig(dataMap, "controlActProcess", "subject",
"observationRequest", "component2", "observationRequest");
if (c2 != null) {
String methodCode = digText(c2, "methodCode", "item", "code");
if (StringUtils.isEmpty(methodCode)) {
// 传 List 时 digText 会自动取首元素再下钻,这里再兜一次 code
methodCode = digText(c2, "methodCode", "item", "part", "value");
}
if (StringUtils.isNotEmpty(methodCode)) {
appointment.setMethodCode(methodCode);
}
String targetSite = digText(c2, "targetSiteCode", "item", "code");
if (StringUtils.isNotEmpty(targetSite)) {
appointment.setTargetSiteCode(targetSite);
}
// 医嘱号:component2.observationRequest.id.item[root=2.16.156.10011.1.28].extension
//
// 依据(HIS《交互服务通用接口文档》):
// · 6.1 申请单新增服务(本项目所用 S0201ECG)的示例中该节点注释即为「医嘱ID」:
//
// · 6.4/6.5 检查预约状态服务的示例中同样注释为「医嘱号」:
// ...
// 实测 S0201ECG 220 条、S0202ECG 29 条**均携带该值且无空值**
// (形如 112663505 / 112663700,且同一医嘱在新增与更新报文中取值一致)。
//
// 【为什么不用 digText 直接取 item.extension】
// digText 遇到 List **只取首元素**;而 id.item 在部分服务中是数组
// (如 6.4 同时含 1.24 申请单号与 1.28 医嘱号)。故必须**按 root 匹配**,
// 否则可能把申请单号当成医嘱号。
String doctorAdviceNo = findExtensionByRoot(c2.get("id"), ORDER_NO_ROOT);
if (StringUtils.isNotEmpty(doctorAdviceNo)) {
appointment.setDoctorAdviceNo(doctorAdviceNo);
}
}
// 医生开单时间
Optional.ofNullable(dataMap.get("controlActProcess")).map(controlActProcess -> (Map) controlActProcess).map(controlActProcess -> (Map) controlActProcess.get("subject")).map(subject -> (Map) subject.get("observationRequest")).map(observationRequest -> (Map) observationRequest.get("author")).map(author -> (Map) author.get("time")).map(time -> (String) time.get("value")).map(str -> {
try {
return new SimpleDateFormat("yyyyMMddHHmmss").parse(str);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}).filter(Objects::nonNull).ifPresent(appointment::setDoctorTime);
//设置开单医生姓名
Optional.ofNullable(dataMap.get("controlActProcess")).map(controlActProcess -> (Map) controlActProcess).map(controlActProcess -> (Map) controlActProcess.get("subject")).map(subject -> (Map) subject.get("observationRequest")).map(observationRequest -> (Map) observationRequest.get("author")).map(author -> (Map) author.get("assignedEntity")).map(assignedEntity -> (Map) assignedEntity.get("assignedPerson")).map(assignedPerson -> (Map) assignedPerson.get("name")).map(name -> (Map) name.get("item")).map(item -> (Map) item.get("part")).map(part -> (String) part.get("value")).ifPresent(appointment::setDoctor);
//设置开单医生编码
Optional.ofNullable(dataMap.get("controlActProcess")).map(controlActProcess -> (Map) controlActProcess).map(controlActProcess -> (Map) controlActProcess.get("subject")).map(subject -> (Map) subject.get("observationRequest")).map(observationRequest -> (Map) observationRequest.get("author")).map(author -> (Map) author.get("assignedEntity")).map(assignedEntity -> (Map) assignedEntity.get("id")).map(id -> (Map) id.get("item")).map(item -> (String) item.get("extension")).ifPresent(appointment::setDoctorCode);
// 设置患者来源类型
Optional.ofNullable(encounter.get("code")).map(code -> (Map) code).map(code -> (String) code.get("code")).map(Integer::parseInt).ifPresent(appointment::setPatSrc);
// 设置患者信息
Optional.ofNullable(encounter.get("subject")).map(subject -> (Map) subject).map(subject -> (Map) subject.get("patient")).map(patient -> (Map) patient.get("patientPerson")).ifPresent(patientPerson -> {
// 设置患者ID
Optional.ofNullable(encounter.get("subject")).map(subject -> (Map) subject).map(subject -> (Map) subject.get("patient")).map(patient -> (Map) patient.get("id")).map(id -> (Map) id).map(id -> (List>) id.get("item")).filter(items -> items.size() > 1).map(items -> (Map) items.get(1)).map(item -> (String) item.get("extension")).ifPresent(appointment::setPatId);
// 设置患者门诊号
Optional.ofNullable(encounter.get("subject")).map(subject -> (Map) subject).map(subject -> (Map) subject.get("patient")).map(patient -> (Map) patient.get("id")).map(id -> (Map) id).map(id -> (List>) id.get("item")).filter(items -> items.size() > 1).map(items -> (Map) items.get(2)).map(item -> (String) item.get("extension")).ifPresent(appointment::setOutpatientNo);
// 设置患者住院号
Optional.ofNullable(encounter.get("subject")).map(subject -> (Map) subject).map(subject -> (Map) subject.get("patient")).map(patient -> (Map) patient.get("id")).map(id -> (Map) id).map(id -> (List>) id.get("item")).filter(items -> items.size() > 1).map(items -> (Map) items.get(3)).map(item -> (String) item.get("extension")).ifPresent(appointment::setHospitalNo);
// 设置患者姓名
Optional.ofNullable(patientPerson.get("name")).map(name -> (Map) name).map(name -> (Map) name.get("item")).map(item -> (Map) item.get("part")).map(part -> (String) part.get("value")).ifPresent(appointment::setPatName);
// 设置患者手机和电话
Optional.ofNullable(patientPerson.get("telecom")).map(telecom -> (Map) telecom).map(telecom -> (Map) telecom.get("item")).map(item -> (String) item.get("value")).ifPresent(phone -> {
appointment.setPatMobile(phone);
appointment.setPatPhone(phone);
});
// 设置患者性别
Optional.ofNullable(patientPerson.get("administrativeGenderCode")).map(genderCode -> (Map) genderCode).map(genderCode -> (String) genderCode.get("code")).map(Byte::parseByte).ifPresent(appointment::setPatGender);
// 设置患者生日
Optional.ofNullable(patientPerson.get("birthTime")).map(birthTime -> (Map) birthTime).map(birthTime -> (String) birthTime.get("value")).map(birthdayStr -> LocalDate.parse(birthdayStr, DateTimeFormatter.ofPattern("yyyyMMdd"))).ifPresent(appointment::setPatBirthday);
// 设置患者地址
Optional.ofNullable(patientPerson.get("addr")).map(addr -> (Map) addr).map(addr -> (Map) addr.get("item")).map(item -> (Map) item.get("part")).map(part -> (String) part.get("value")).ifPresent(appointment::setPatAddr);
// 设置患者身份证号
Optional.ofNullable(patientPerson.get("id")).map(id -> (Map) id).map(id -> (List>) id.get("item")).filter(items -> !items.isEmpty()).map(items -> (Map) items.get(0)).map(item -> (String) item.get("extension")).ifPresent(appointment::setPatIdentityId);
//患者医保卡
Optional.ofNullable(patientPerson.get("id")).map(id -> (Map) id).map(id -> (List>) id.get("item")).filter(items -> !items.isEmpty()).map(items -> (Map) items.get(1)).map(item -> (String) item.get("extension")).ifPresent(appointment::setMedicalCard);
});
// 设置支付状态
Optional.ofNullable(dataMap.get("controlActProcess")).map(controlActProcess -> (Map) controlActProcess).map(controlActProcess -> (Map) controlActProcess.get("subject")).map(subject -> (Map) subject.get("observationRequest")).map(observationRequest -> (Map) observationRequest.get("zdy")).map(zdy -> {
Object payStatus = zdy.get("PayStatus");
if (payStatus != null) {
try {
return Integer.parseInt(payStatus.toString());
} catch (NumberFormatException e) {
log.warn("PayStatus转换失败: {}", payStatus);
return null;
}
}
return null;
}).ifPresent(appointment::setPaid);
// 设置申请单号
Optional.ofNullable(dataMap.get("controlActProcess")).map(controlActProcess -> (Map) controlActProcess).map(controlActProcess -> (Map) controlActProcess.get("subject")).map(subject -> (Map) subject.get("observationRequest")).map(observationRequest -> (Map) observationRequest.get("id")).map(id -> (Map) id.get("item")).map(item -> (String) item.get("extension")).ifPresent(appointment::setApplyNo);
//设置检查类型
// String checkType = Optional.ofNullable(dataMap.get("controlActProcess")).map(controlActProcess -> (Map) controlActProcess).map(controlActProcess -> (Map) controlActProcess.get("component2")).map(component2 -> (Map) component2.get("observationRequest")).map(observationRequest -> (Map) observationRequest.get("code")).map(code -> (String) code.get("code")).orElseThrow(() -> exception(APPOINTMENT_CREATE_FAIL));
String checkType = Optional.ofNullable(dataMap.get("controlActProcess")).map(controlActProcess -> (Map) controlActProcess).map(controlActProcess -> (Map) controlActProcess.get("subject")).map(subject -> (Map) subject.get("observationRequest")).map(observationRequest -> (Map) observationRequest.get("component2")).map(component2 -> (Map) component2.get("observationRequest")).map(observationRequest -> (Map) observationRequest.get("code")).map(code -> (String) code.get("code")).orElseThrow(() -> exception(APPOINTMENT_CREATE_FAIL));
String checkName = Optional.ofNullable(dataMap.get("controlActProcess")).map(controlActProcess -> (Map) controlActProcess).map(controlActProcess -> (Map) controlActProcess.get("subject")).map(subject -> (Map) subject.get("observationRequest")).map(observationRequest -> (Map) observationRequest.get("component2")).map(component2 -> (Map) component2.get("observationRequest")).map(observationRequest -> (Map) observationRequest.get("code")).map(code -> (Map) code.get("displayName")).map(displayName -> (String) displayName.get("value")).orElseThrow(() -> exception(APPOINTMENT_CREATE_FAIL));
// 依据检查类型解析本地 book_check_type。
// 修正点(原实现有缺陷):
// 1. 判断写成 `checkType != null || checkType != ""`,恒为 true;
// 2. patSrc 为 Integer,`!= 4` 在超出 Integer 缓存范围时会失效,改用 Objects.equals;
// 3. HIS 代码可能映射不到,直接 Integer.valueOf(null) 会抛
// NumberFormatException(实测日志中有 "Cannot parse null string")。
if (Objects.equals(appointment.getPatSrc(), 4)) {
String normalizedName = StringUtils.isNotEmpty(checkName)
? checkName.replaceAll("\\s+", "") : null;
String mapped = StringUtils.isNotEmpty(normalizedName)
? HisCheckNameEnum.getByName(normalizedName) : null;
Integer value = parseIntegerQuietly(mapped);
if (value != null) {
appointment.setBookCheckType(value);
} else {
log.warn("[handleAppointmentCreate][体检患者检查项目名称无法映射] checkName={}", checkName);
}
} else {
Integer value = resolveBookCheckTypeFromComponent2(dataMap);
if (value != null) {
appointment.setBookCheckType(value);
} else {
log.warn("[handleAppointmentCreate][HIS 检查代码无法映射到本地检查类型] checkType={}", checkType);
}
}
// 设置科室和病区信息
Optional.ofNullable(encounter.get("location")).map(location -> (Map) location).map(location -> (Map) location.get("serviceDeliveryLocation")).map(location -> (Map) location.get("serviceProviderOrganization")).ifPresent(org -> {
// 设置科室代码和名称
Optional.ofNullable(org.get("id")).map(id -> (Map) id).map(id -> (Map) id.get("item")).map(item -> (String) item.get("extension")).ifPresent(appointment::setPatDeptCode);
Optional.ofNullable(org.get("name")).map(name -> (Map) name).map(name -> (Map) name.get("item")).map(item -> (Map) item.get("part")).map(part -> (String) part.get("value")).ifPresent(appointment::setPatDeptDesc);
// 设置病区代码和名称
Optional.ofNullable(org.get("asOrganizationPartOf")).map(wardOrg -> (Map) wardOrg).map(wardOrg -> (Map) wardOrg.get("wholeOrganization")).ifPresent(wardOrg -> {
Optional.ofNullable(wardOrg.get("id")).map(id -> (Map) id).map(id -> (Map) id.get("item")).map(item -> (String) item.get("extension")).ifPresent(appointment::setPatWardCode);
if (appointment.getPatSrc() != null && appointment.getPatSrc() == 4) {
//体检就这一个科室
appointment.setPatDeptDesc("健康管理中心");
} else {
Optional.ofNullable(wardOrg.get("name")).map(name -> (Map) name).map(name -> (Map) name.get("item")).map(item -> (Map) item.get("part")).map(part -> (String) part.get("value")).ifPresent(appointment::setPatWardDesc);
}
});
});
// 将AppointmentDO转换为AppointmentSaveReqVO并保存
AppointmentSaveReqVO saveReqVO = BeanUtils.toBean(appointment, AppointmentSaveReqVO.class);
// 按申请单号检查是否已存在。
// 【本次改动 Q5】原实现命中已存在时直接 return(不更新),
// 与 S0202ECG / S0405 两条更新流的"找不到则兜底新增"方向相反,语义不统一。
// 现改为:命中已存在则以本次报文内容执行更新,避免 HIS 重复推送时旧数据不被修正。
AppointmentDO appointmentDO = appointmentMapper.getByApplyNo(saveReqVO.getApplyNo());
log.info("[handleAppointmentCreate][是否已存在] applyNo={} existingId={}",
saveReqVO.getApplyNo(), appointmentDO == null ? null : appointmentDO.getId());
if (appointmentDO != null) {
// 保留原记录中本次报文未提供的字段,避免被空值覆盖
mergeUnsetFields(appointment, appointmentDO);
saveReqVO = BeanUtils.toBean(appointment, AppointmentSaveReqVO.class);
saveReqVO.setId(appointmentDO.getId());
updateAppointment(saveReqVO);
return appointmentDO.getId();
}
return createAppointment(saveReqVO);
} catch (Exception e) {
log.error("[handleAppointmentCreate][处理预约创建失败 dataMap({})]", dataMap, e);
throw exception(APPOINTMENT_CREATE_FAIL);
}
}
/**
* 更新前合并:本次报文未提供(为 null)的字段,沿用数据库中的原值。
*
* 目的:{@code S0201ECG} 的某些字段在部分场景下会缺失(例如体检患者无预约时间、
* 床头屏消息无科室信息)。若直接以报文内容整体覆盖,会把已有数据清空。
* 这是"部分更新"语义,与更新流的 {@code ifPresent} 逐字段赋值保持一致。
*
* 注意:在 {@link AppointmentDO} 层合并(而非 VO 层),因为
* {@code AppointmentSaveReqVO} 与 DO 的字段类型并不一致
* (VO 的 {@code bookDate/patBirthday} 为 {@code LocalDateTime}、
* {@code registerDate/doctorTime} 为 {@code java.sql.Date};
* DO 对应为 {@code LocalDate} / {@code java.util.Date})。
*
* @param target 本次报文解析出的对象(会被就地补全)
* @param source 数据库中已有的记录
*/
private void mergeUnsetFields(AppointmentDO target, AppointmentDO source) {
if (target.getApplyNo() == null) target.setApplyNo(source.getApplyNo());
if (target.getEpisodeId() == null) target.setEpisodeId(source.getEpisodeId());
if (target.getPatSrc() == null) target.setPatSrc(source.getPatSrc());
if (target.getPatId() == null) target.setPatId(source.getPatId());
if (target.getPatName() == null) target.setPatName(source.getPatName());
if (target.getPatGender() == null) target.setPatGender(source.getPatGender());
if (target.getPatBirthday() == null) target.setPatBirthday(source.getPatBirthday());
if (target.getPatMobile() == null) target.setPatMobile(source.getPatMobile());
if (target.getPatPhone() == null) target.setPatPhone(source.getPatPhone());
if (target.getPatIdentityId() == null) target.setPatIdentityId(source.getPatIdentityId());
if (target.getPatAddr() == null) target.setPatAddr(source.getPatAddr());
if (target.getPatDeptCode() == null) target.setPatDeptCode(source.getPatDeptCode());
if (target.getPatDeptDesc() == null) target.setPatDeptDesc(source.getPatDeptDesc());
if (target.getPatWardCode() == null) target.setPatWardCode(source.getPatWardCode());
if (target.getPatWardDesc() == null) target.setPatWardDesc(source.getPatWardDesc());
if (target.getPatBedNo() == null) target.setPatBedNo(source.getPatBedNo());
if (target.getBookDate() == null) target.setBookDate(source.getBookDate());
if (target.getBookTimeslot() == null) target.setBookTimeslot(source.getBookTimeslot());
if (target.getBookTime() == null) target.setBookTime(source.getBookTime());
if (target.getBookCheckType() == null) target.setBookCheckType(source.getBookCheckType());
if (target.getBookSrc() == null) target.setBookSrc(source.getBookSrc());
if (target.getPaid() == null) target.setPaid(source.getPaid());
if (target.getStatus() == null) target.setStatus(source.getStatus());
if (target.getRegisterDate() == null) target.setRegisterDate(source.getRegisterDate());
if (target.getTolerance() == null) target.setTolerance(source.getTolerance());
if (target.getHospitalNo() == null) target.setHospitalNo(source.getHospitalNo());
if (target.getMedicalCard() == null) target.setMedicalCard(source.getMedicalCard());
if (target.getOutpatientNo() == null) target.setOutpatientNo(source.getOutpatientNo());
if (target.getDoctor() == null) target.setDoctor(source.getDoctor());
if (target.getDoctorCode() == null) target.setDoctorCode(source.getDoctorCode());
if (target.getDoctorTime() == null) target.setDoctorTime(source.getDoctorTime());
// —— HIS 补充字段(随表新增列一起加入,保持"部分更新不清空"语义)——
if (target.getJjbz() == null) target.setJjbz(source.getJjbz());
if (target.getMethodCode() == null) target.setMethodCode(source.getMethodCode());
if (target.getTargetSiteCode() == null) target.setTargetSiteCode(source.getTargetSiteCode());
if (target.getRoomName() == null) target.setRoomName(source.getRoomName());
if (target.getRemark() == null) target.setRemark(source.getRemark());
if (target.getBookSeqNum() == null) target.setBookSeqNum(source.getBookSeqNum());
}
@Override
public void handleAppointmentUpdate(Map dataMap) {
if (dataMap == null) {
throw exception(APPOINTMENT_UPDATE_FAIL);
}
try {
// 从dataMap中提取申请单号
String applyNo = Optional.ofNullable(dataMap.get("controlActProcess")).map(controlActProcess -> (Map) controlActProcess).map(controlActProcess -> (Map) controlActProcess.get("subject")).map(subject -> (Map) subject.get("observationRequest")).map(observationRequest -> (Map) observationRequest.get("id")).map(id -> (Map) id.get("item")).map(item -> (String) item.get("extension")).orElseThrow(() -> exception(APPOINTMENT_CREATE_FAIL));
// 查询现有预约记录
AppointmentDO existingAppointment = appointmentMapper.getByApplyNo(applyNo);
// 如果记录不存在,则调用handleAppointmentCreate进行新增
if (existingAppointment == null) {
handleAppointmentCreate(dataMap);
return;
}
// 从dataMap中提取数据
Map encounter = extractEncounter(dataMap);
if (encounter == null) {
throw exception(APPOINTMENT_CREATE_FAIL);
}
// 更新就诊流水号
Optional.ofNullable(encounter.get("id")).map(id -> (Map) id).map(id -> (List>) id.get("item")).filter(items -> items.size() > 1).map(items -> (Map) items.get(1)).map(item -> (String) item.get("extension")).ifPresent(existingAppointment::setEpisodeId);
// 设置床位号
Optional.ofNullable(encounter.get("location")).map(location -> (Map) location).map(location -> (Map) location.get("serviceDeliveryLocation")).map(serviceDeliveryLocation -> (Map) serviceDeliveryLocation.get("location")).map(location -> (Map) location.get("id")).map(id -> (Map) id.get("item")).map(item -> (String) item.get("extension")).ifPresent(existingAppointment::setPatBedNo);
// 更新患者来源类型
Optional.ofNullable(encounter.get("code")).map(code -> (Map) code).map(code -> (String) code.get("code")).map(Integer::parseInt).ifPresent(existingAppointment::setPatSrc);
// 更新患者信息
Optional.ofNullable(encounter.get("subject")).map(subject -> (Map) subject).map(subject -> (Map) subject.get("patient")).map(patient -> (Map) patient.get("patientPerson")).ifPresent(patientPerson -> {
// 更新患者ID
Optional.ofNullable(encounter.get("subject")).map(subject -> (Map) subject).map(subject -> (Map) subject.get("patient")).map(patient -> (Map) patient.get("id")).map(id -> (Map) id).map(id -> (List>) id.get("item")).filter(items -> items.size() > 1).map(items -> (Map) items.get(1)).map(item -> (String) item.get("extension")).ifPresent(existingAppointment::setPatId);
// 更新患者姓名
Optional.ofNullable(patientPerson.get("name")).map(name -> (Map) name).map(name -> (Map) name.get("item")).map(item -> (Map) item.get("part")).map(part -> (String) part.get("value")).ifPresent(existingAppointment::setPatName);
// 更新患者手机和电话
Optional.ofNullable(patientPerson.get("telecom")).map(telecom -> (Map) telecom).map(telecom -> (Map) telecom.get("item")).map(item -> (String) item.get("value")).ifPresent(phone -> {
existingAppointment.setPatMobile(phone);
existingAppointment.setPatPhone(phone);
});
// 更新患者性别
Optional.ofNullable(patientPerson.get("administrativeGenderCode")).map(genderCode -> (Map) genderCode).map(genderCode -> (String) genderCode.get("code")).map(Byte::parseByte).ifPresent(existingAppointment::setPatGender);
// 更新患者生日
Optional.ofNullable(patientPerson.get("birthTime")).map(birthTime -> (Map) birthTime).map(birthTime -> (String) birthTime.get("value")).map(birthdayStr -> LocalDate.parse(birthdayStr, DateTimeFormatter.ofPattern("yyyyMMdd"))).ifPresent(existingAppointment::setPatBirthday);
// 更新患者地址
Optional.ofNullable(patientPerson.get("addr")).map(addr -> (Map) addr).map(addr -> (Map) addr.get("item")).map(item -> (Map) item.get("part")).map(part -> (String) part.get("value")).ifPresent(existingAppointment::setPatAddr);
// 更新患者身份证号
Optional.ofNullable(patientPerson.get("id")).map(id -> (Map) id).map(id -> (List>) id.get("item")).filter(items -> !items.isEmpty()).map(items -> (Map) items.get(0)).map(item -> (String) item.get("extension")).ifPresent(existingAppointment::setPatIdentityId);
});
// 更新科室和病区信息
Optional.ofNullable(encounter.get("location")).map(location -> (Map) location).map(location -> (Map) location.get("serviceDeliveryLocation")).map(location -> (Map) location.get("serviceProviderOrganization")).ifPresent(org -> {
// 更新科室代码和名称
Optional.ofNullable(org.get("id")).map(id -> (Map) id).map(id -> (Map) id.get("item")).map(item -> (String) item.get("extension")).ifPresent(existingAppointment::setPatDeptCode);
Optional.ofNullable(org.get("name")).map(name -> (Map) name).map(name -> (Map) name.get("item")).map(item -> (Map) item.get("part")).map(part -> (String) part.get("value")).ifPresent(existingAppointment::setPatDeptDesc);
// 更新病区代码和名称
Optional.ofNullable(org.get("asOrganizationPartOf")).map(wardOrg -> (Map) wardOrg).map(wardOrg -> (Map) wardOrg.get("wholeOrganization")).ifPresent(wardOrg -> {
Optional.ofNullable(wardOrg.get("id")).map(id -> (Map) id).map(id -> (Map) id.get("item")).map(item -> (String) item.get("extension")).ifPresent(existingAppointment::setPatWardCode);
Optional.ofNullable(wardOrg.get("name")).map(name -> (Map) name).map(name -> (Map) name.get("item")).map(item -> (Map) item.get("part")).map(part -> (String) part.get("value")).ifPresent(existingAppointment::setPatWardDesc);
});
});
// 更新支付状态
Optional.ofNullable(dataMap.get("controlActProcess")).map(controlActProcess -> (Map) controlActProcess).map(controlActProcess -> (Map) controlActProcess.get("subject")).map(subject -> (Map) subject.get("observationRequest")).map(observationRequest -> (Map) observationRequest.get("zdy")).map(zdy -> {
Object payStatus = zdy.get("PayStatus");
if (payStatus != null) {
try {
return Integer.parseInt(payStatus.toString());
} catch (NumberFormatException e) {
log.warn("PayStatus转换失败: {}", payStatus);
return null;
}
}
return null;
}).ifPresent(existingAppointment::setPaid);
// 设置检查类型
Integer checkTypeValue = resolveBookCheckTypeFromComponent2(dataMap);
if (checkTypeValue != null) {
existingAppointment.setBookCheckType(checkTypeValue);
} else {
log.warn("[handleAppointmentUpdate][未能解析检查类型,保留原值] applyNo={}", applyNo);
}
// 将AppointmentDO转换为AppointmentSaveReqVO并更新
AppointmentSaveReqVO updateReqVO = BeanUtils.toBean(existingAppointment, AppointmentSaveReqVO.class);
updateAppointment(updateReqVO);
} catch (Exception e) {
log.error("[handleAppointmentUpdate][申请单更新失败 dataMap({})]", dataMap, e);
throw exception(APPOINTMENT_UPDATE_FAIL);
}
}
/**
* 从报文 {@code component2.observationRequest.code.code} 解析本地检查类型值。
*
* 修正的缺陷
* 原实现为:
*
* if (checkType != null || checkType != "") // 恒为 true,且逻辑应为 &&
* setBookCheckType(Integer.valueOf(
* HisCheckCodeEnum.getByCode(checkType.substring(1)))); // 无长度校验
*
* 存在三处问题:
*
* - {@code ||} 使判断恒真;
* - {@code substring(1)} 直接截断,报文值长度不足时抛
* {@code StringIndexOutOfBoundsException}(实测日志中有类似异常);
* - {@code HisCheckCodeEnum.getByCode()} 可能返回 null,
* 随后 {@code Integer.valueOf(null)} 抛 {@code NumberFormatException}
* (实测日志中确有 {@code Cannot parse null string})。
*
* 现改为:HIS 代码兼容带/不带 {@code Y} 前缀(对应原 substring(1) 语义),
* 且仅在能映射到本地 {@code check_type} 值时才返回,否则返回 null 由调用方保留原值。
*
* @return 本地检查类型值(如 1000);无法解析时返回 null
*/
private Integer resolveBookCheckTypeFromComponent2(Map dataMap) {
// 类型安全:observationRequest.code 可能是 Map,也可能是空字符串
String hisCode = digText(dataMap, "controlActProcess", "subject", "observationRequest",
"component2", "observationRequest", "code", "code");
if (StringUtils.isEmpty(hisCode)) {
return null;
}
String raw = hisCode.trim().replaceAll("\\s+", "");
// 兼容:HIS 报文值形如 "Y1202065",而枚举 code 为去掉首字符后的 "1202065";
// 同时也容忍报文本身不带前缀
String[] candidates = raw.length() > 1
? new String[]{raw, raw.substring(1)}
: new String[]{raw};
for (String candidate : candidates) {
String mapped = HisCheckCodeEnum.getByCode(candidate);
if (StringUtils.isNotEmpty(mapped)) {
return parseIntegerQuietly(mapped);
}
}
log.warn("[resolveBookCheckTypeFromComponent2][HIS 检查代码无法映射到本地检查类型] hisCode={}", hisCode);
return null;
}
/**
* 申请单状态更新(action: {@code S0405} / {@code S0405HIS} / {@code S040501HIS})。
*
* ⚠️ 该服务是全院通用的
* 接口文档 6.3 定义其 action 为 {@code S0405|S0405HIS|S040501HIS},**不区分科室**。
* 医院会把所有检查科室(CT、MRI、DR、超声、骨密度…)的检查状态一并推送。
* 实测 2026-09-17:本服务收到 1225 次请求,其中仅 3.5% 的申请单号属心电业务,
* 其余为放射/超声等科室的单(如 {@code 2126091700001} 胸部CT平扫 / 急诊CT室)。
*
* 处理策略
*
* - 提取申请单号与检查状态码,并对状态码做**归一化**(去前导零、校验合法性)
* - 查本地预约记录:
*
* - 存在 → 更新状态;状态为 3(检查登记)时同时写入登记时间
* - 不存在 + 是心电单 → 记 WARN 并**插入新记录**(Q4 要求容错新增)
* - 不存在 + 非心电单 → 仅记 DEBUG 日志,**不插入**(避免灌入无关数据)
*
*
*
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void handleAppointmentStateUpdate(Map dataMap) {
if (dataMap == null) {
throw exception(APPOINTMENT_STATUS_UPDATE_FAIL);
}
// 1. 提取申请单号(类型安全导航)
String applyNo = digText(dataMap, "controlActProcess", "subject", "placerGroup",
"component2", "observationRequest", "id", "item", "extension");
if (StringUtils.isEmpty(applyNo)) {
log.warn("[handleAppointmentStateUpdate][报文缺少申请单号,忽略] dataMap={}", dataMap);
throw exception(HIS_APPLY_NO_MISSING);
}
// 2. 提取并归一化检查状态码
// 类型安全:processStep.code 在部分报文中是 Map(code={code=7,...}),
// 在另一些报文中可能是空字符串,直接用链式强转会抛 ClassCastException
String rawStatus = digText(dataMap, "controlActProcess", "subject", "placerGroup",
"component2", "observationRequest", "component1", "processStep", "code", "code");
String status = HisCheckStatusEnum.normalize(rawStatus);
if (status == null) {
// 实测报文中该值存在 "3" / "03" / "7" / "08" / "10" 等不同写法,
// 归一化后仍无法识别说明不是编码表内的状态(如病理专用 21/22/23)
log.warn("[handleAppointmentStateUpdate][检查状态编码非法或不在编码表内,忽略] applyNo={} rawStatus={}",
applyNo, rawStatus);
throw exception(HIS_STATUS_INVALID);
}
// 3. 本地记录匹配
AppointmentDO existingAppointment = appointmentMapper.getByApplyNo(applyNo);
if (existingAppointment != null) {
existingAppointment.setStatus(status);
if (HisCheckStatusEnum.REGISTERED.getCode().toString().equals(status)) {
existingAppointment.setRegisterDate(new Date());
}
AppointmentSaveReqVO updateReqVO = BeanUtils.toBean(existingAppointment, AppointmentSaveReqVO.class);
updateAppointment(updateReqVO);
return;
}
// 4. 记录不存在:先判定是否心电业务单,非心电单只记日志、不插入
//
// S0405 是全院通用流。可用的心电判定依据(按实测报文):
// A. apply_no 后缀(1101/6401)
// B. component2.observationRequest.component1.processStep.code.ZDY
// 中的 jcxmcode / jcxmname(实测 1138/1225 条带该字段,
// 形如 jcxmname=胸部CT平扫,上腹部CT平扫 / roomname=急诊CT室)
// C. 执行科室(需配置白名单)
Map zdyFromS0405 = extractZdyFromStateMessage(dataMap);
String s0405CheckCode = extractZdyValueFromMap(zdyFromS0405, "jcxmcode");
String s0405CheckName = extractZdyValueFromMap(zdyFromS0405, "jcxmname");
String hisDeptCode = extractDeptCodeFromComponent2(dataMap);
if (!hisEcgFilter.isEcgOrder("S0405", applyNo, s0405CheckCode, s0405CheckName, hisDeptCode)) {
// 全院流中的其他科室单据,静默跳过(DEBUG 日志由 filter 输出)
return;
}
// 5. 是心电单但本地无记录 → 容错新增(Q4 要求)
log.warn("[handleAppointmentStateUpdate][心电业务单但本地无记录,执行容错新增] applyNo={} status={} dept={}",
applyNo, status, hisDeptCode);
insertAppointmentFromStateMessage(applyNo, status, dataMap);
}
/**
* 从 S0405 报文中构造并插入一条预约记录。
*
* ⚠️ 已知的数据缺失
* {@code S0405} 报文**不包含检查项目信息**(无 {@code component2.observationRequest.code}),
* 因此无法确定 {@code book_check_type}(检查类型)。实测确认:申请单号后缀
* 与检查类型**无对应关系**(同为 {@code ...1101} 后缀的记录存在 1000/100/1100 等多种类型),
* 故不做猜测,保持为空并输出 WARN,待后续 {@code S0202ECG}(申请单更新)
* 或人工补录修正。
*
* 同理 {@code book_date}(预约日期)也不在报文中,保持为空 —— 这与现有数据一致
* (库中约 48% 记录 {@code book_date} 为空,预约日期由 S050401/S040501HIS 后续补入)。
*/
private void insertAppointmentFromStateMessage(String applyNo, String status, Map dataMap) {
AppointmentDO appointment = new AppointmentDO();
appointment.setApplyNo(applyNo);
appointment.setStatus(status);
if (HisCheckStatusEnum.REGISTERED.getCode().toString().equals(status)) {
appointment.setRegisterDate(new Date());
}
// 患者信息(S0405 的 patient.id.item 为列表:域ID/患者ID/门诊号/住院号/就诊次数/就诊流水号)
// 用类型安全导航,避免某一级不是 Map 时抛 ClassCastException
Map encounter = dig(dataMap, "controlActProcess", "subject",
"placerGroup", "componentOf1", "encounter");
if (encounter != null) {
// 就诊流水号:id.item[1]
List> encIdItems = asList(dig(encounter, "id"), "item");
if (encIdItems.size() > 1 && encIdItems.get(1) instanceof Map) {
appointment.setEpisodeId(str(((Map) encIdItems.get(1)).get("extension")));
}
// 患者信息
Map patient = dig(encounter, "subject", "patient");
if (patient != null) {
List> patIdItems = asList(patient, "id", "item");
// 患者ID item[1],门诊号 item[2],住院号 item[3]
if (patIdItems.size() > 1 && patIdItems.get(1) instanceof Map) {
appointment.setPatId(str(((Map) patIdItems.get(1)).get("extension")));
}
if (patIdItems.size() > 2 && patIdItems.get(2) instanceof Map) {
appointment.setOutpatientNo(str(((Map) patIdItems.get(2)).get("extension")));
}
if (patIdItems.size() > 3 && patIdItems.get(3) instanceof Map) {
appointment.setHospitalNo(str(((Map) patIdItems.get(3)).get("extension")));
}
// 姓名
String patName = digText(patient, "patientPerson", "name", "item", "part", "value");
if (StringUtils.isNotEmpty(patName)) {
appointment.setPatName(patName);
}
// 性别:pat_gender 为 NOT NULL,尽量取;取不到时由下方兜底为 0
String gender = digText(patient, "patientPerson", "administrativeGenderCode", "code");
Byte genderByte = parseByteQuietly(gender);
if (genderByte != null) {
appointment.setPatGender(genderByte);
}
}
}
// 执行科室(写入 pat_dept_code / pat_dept_desc)
Map org = dig(dataMap, "controlActProcess", "subject", "placerGroup",
"component2", "observationRequest", "location", "serviceDeliveryLocation",
"serviceProviderOrganization");
if (org != null) {
String deptCode = digText(org, "id", "item", "extension");
if (StringUtils.isNotEmpty(deptCode)) {
appointment.setPatDeptCode(deptCode);
}
String deptDesc = digText(org, "name", "item", "part", "value");
if (StringUtils.isNotEmpty(deptDesc)) {
appointment.setPatDeptDesc(deptDesc);
}
}
if (StringUtils.isEmpty(appointment.getPatName())) {
log.warn("[insertAppointmentFromStateMessage][容错新增的记录缺少患者姓名] applyNo={}", applyNo);
}
log.warn("[insertAppointmentFromStateMessage][注意:S0405 报文不含检查项目," +
"book_check_type 将为空,需后续 S0202ECG 或人工补录] applyNo={}", applyNo);
// pat_id / pat_name / pat_gender 在数据库中均为 NOT NULL,缺值时提前报错,
// 避免 SQL 异常导致事务回滚(实测报错:Field 'pat_gender' doesn't have a default value)
if (StringUtils.isEmpty(appointment.getPatId())) {
log.error("[insertAppointmentFromStateMessage][报文缺患者编号(pat_id),无法新增] applyNo={}", applyNo);
throw exception(PATIENT_NOT_EXISTS);
}
if (appointment.getPatGender() == null) {
log.warn("[insertAppointmentFromStateMessage][报文未提供患者性别,按未知(0)写入] applyNo={}", applyNo);
appointment.setPatGender(UNKNOWN_GENDER);
}
AppointmentSaveReqVO saveReqVO = BeanUtils.toBean(appointment, AppointmentSaveReqVO.class);
createAppointment(saveReqVO);
}
/**
* 取 S0405 报文中的 {@code ZDY} 节点。
*
* 实测路径:{@code controlActProcess.subject.placerGroup.component2.observationRequest
* .component1.processStep.code.ZDY},内容形如:
*
* ZDY={roomcode=111, roomname=急诊CT室, jcxmcode=1080,3016,3014,
* jcxmname=胸部CT平扫,上腹部CT平扫,下腹部CT平扫,
* pdh=000336, yysd=00:00-01:00, isjj=1}
*
* 注意:{@code observationRequest.code} 在报文中**可能是空字符串而非 Map**,
* 故必须用 {@link #dig(Object, String...)} 做类型安全导航。
*/
private Map extractZdyFromStateMessage(Map dataMap) {
return dig(dataMap, "controlActProcess", "subject", "placerGroup", "component2",
"observationRequest", "component1", "processStep", "code", "ZDY");
}
/** 从 Map 中取字符串字段(可为空) */
private String extractZdyValueFromMap(Map map, String key) {
if (map == null) {
return null;
}
Object v = map.get(key);
return v == null ? null : v.toString();
}
/** 安全转字符串:null 返回 null,其余 toString() */
private static String str(Object o) {
return o == null ? null : o.toString();
}
/** {@code appointment.remark} 列宽上限(见 08_fix_remark_length.sql,当前 varchar(2000)) */
private static final int REMARK_MAX_LEN = 2000;
/** {@code appointment.room_name} 列宽上限 */
private static final int ROOM_NAME_MAX_LEN = 200;
/**
* 按列宽截断文本,防止 {@code Data too long for column} 导致**整个事务回滚**。
*
* 血泪教训:2026-09-28 生产环境该错误出现 52 次。心电图运动试验的
* 注意事项长达 474 字符,而 {@code remark} 列原为 {@code varchar(255)},
* 溢出后抛 {@code MysqlDataTruncation},使整条 UPDATE 回滚 ——
* 连带同一事务中的 {@code book_time}、{@code room_name} 一起丢失,
* 表现为"预约时间段没存进数据库"。
*
* @param value 原始文本
* @param maxLen 目标列宽
* @return 截断后的文本(null 原样返回)
*/
private static String truncate(String value, int maxLen) {
if (value == null) {
return null;
}
if (value.length() <= maxLen) {
return value;
}
log.warn("[truncate][文本超长已截断] 原长度={} 截断至={} 内容前 60 字={}",
value.length(), maxLen,
value.substring(0, Math.min(60, value.length())));
return value.substring(0, maxLen);
}
/**
* 层级导航后取「列表」节点:{@code dig(root, path...)} 得到的值若是 List 则返回,
* 否则返回空列表(**不抛异常**)。
*/
@SuppressWarnings("unchecked")
private static List> asList(Map root, String... path) {
Object cur = root;
for (String key : path) {
if (!(cur instanceof Map)) {
return Collections.emptyList();
}
cur = ((Map) cur).get(key);
}
return cur instanceof List ? (List>) cur : Collections.emptyList();
}
/**
* 类型安全的层级导航:逐级取 Map 子节点。
*
* 为什么需要它:HIS 报文经 Jackson 反序列化后,同一字段的类型**并不稳定**。
* 例如 {@code observationRequest.code} 在部分报文中是 {@code Map}({@code code={code=7,...}}),
* 在另一些报文中是**空字符串**({@code code=})。若用
* {@code .map(m -> (Map) m.get("code"))} 这种链式强制转换,
* 遇到字符串会抛 {@code ClassCastException},**导致整条消息处理中断**。
*
* 实测生产日志 2026-09-28 中该类异常出现 **287 次**,正是由此写法引起
* ({@code AppointmentServiceImpl.lambda$extractCheckCodeFromComponent2})。
*
* @param root 起始节点
* @param path 依次要取的键
* @return 末端节点;任一环节不是 Map 或缺失时返回 null(**不抛异常**)
*/
@SuppressWarnings("unchecked")
private static Map dig(Map root, String... path) {
Object cur = root;
for (String key : path) {
if (!(cur instanceof Map)) {
return null;
}
cur = ((Map) cur).get(key);
}
return cur instanceof Map ? (Map) cur : null;
}
/** HIS 报文中「医嘱号/医嘱ID」的 id root(见《交互服务通用接口文档》6.1 / 6.4 / 6.5) */
private static final String ORDER_NO_ROOT = "2.16.156.10011.1.28";
/**
* 在 HL7 的 {@code id} 节点中,按 {@code root} 匹配并取出 {@code extension}。
*
* 兼容三种真实形态(均已在报文/文档中出现):
*
* id={item={root=..., extension=...}} // 单个 Map
* id={item=[{root=..., extension=...}, {...}]} // List(多标识,须按 root 区分)
* {root=..., extension=...} // 直接就是标识本身
*
*
* 为什么不复用 {@link #digText}:{@code digText} 遇到 List 只取首元素,
* 而 {@code id.item} 为数组时可能首元素是「申请单号(root 1.24)」而非「医嘱号(root 1.28)」,
* 会取错值。故这里显式遍历并按 root 匹配。
*
* @param idNode {@code id} 节点(Map 或 List,可为 null)
* @param root 目标 root 值
* @return 匹配项的 extension;未匹配/为空时返回 null
*/
private static String findExtensionByRoot(Object idNode, String root) {
if (idNode == null) {
return null;
}
Object item = (idNode instanceof Map) ? ((Map) idNode).get("item") : idNode;
if (item == null) {
return null;
}
List