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))));  // 无长度校验
     * 
* 存在三处问题: *
    *
  1. {@code ||} 使判断恒真;
  2. *
  3. {@code substring(1)} 直接截断,报文值长度不足时抛 * {@code StringIndexOutOfBoundsException}(实测日志中有类似异常);
  4. *
  5. {@code HisCheckCodeEnum.getByCode()} 可能返回 null, * 随后 {@code Integer.valueOf(null)} 抛 {@code NumberFormatException} * (实测日志中确有 {@code Cannot parse null string})。
  6. *
* 现改为: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室)。 * *

处理策略

*
    *
  1. 提取申请单号与检查状态码,并对状态码做**归一化**(去前导零、校验合法性)
  2. *
  3. 查本地预约记录: *
      *
    • 存在 → 更新状态;状态为 3(检查登记)时同时写入登记时间
    • *
    • 不存在 + 是心电单 → 记 WARN 并**插入新记录**(Q4 要求容错新增)
    • *
    • 不存在 + 非心电单 → 仅记 DEBUG 日志,**不插入**(避免灌入无关数据)
    • *
    *
  4. *
*/ @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 candidates = new ArrayList<>(); if (item instanceof List) { candidates.addAll((List) item); } else { candidates.add(item); } for (Object cand : candidates) { if (!(cand instanceof Map)) { continue; } Map m = (Map) cand; Object r = m.get("root"); if (r != null && root.equals(r.toString().trim())) { Object ext = m.get("extension"); if (ext != null && StringUtils.isNotEmpty(ext.toString().trim())) { return ext.toString().trim(); } } } return null; } /** * 从 HIS 报文中取 {@code zdy} 自定义字段区的「是否床边」标记(需求2)。 * *

字段来源

* {@code controlActProcess.subject.observationRequest.zdy.ISCB}, * 取值 {@code 0=否 1=是}。 * *

该字段**在 HIS 官方《交互服务通用接口文档》中未定义** —— 文档里 25 个 * {@code zdy} 块共 19 个字段,实测真实报文却有 26 个,{@code ISCB} 属其中 * 7 个未公开扩展字段之一({@code ISCB/ISDN/BLLSH/SWCJBS/KDYSSJH/KLX/VTEGWBZ})。 * 实测样本中 {@code ISCB} 恒为 0。 * *

与「床边检查类型」的区别

* {@code check_type.value=1000} 的名称是"床边心电图(常规+频谱)",那是**一个检查项目**; * 本标记表达的是**同一项目、患者需到床边做**,两者语义不同,不可互相替代。 * *

抽成静态方法是为了可单元测试(见 {@code HisParseSafetyTest})。 * * @param dataMap HIS 报文根节点 * @return {@code "0"}/{@code "1"};字段缺失或为空时返回 null */ static String findBedsideFlag(Map dataMap) { if (dataMap == null) { return null; } Object cap = dataMap.get("controlActProcess"); if (!(cap instanceof Map)) { return null; } Object subject = ((Map) cap).get("subject"); if (!(subject instanceof Map)) { return null; } Object obs = ((Map) subject).get("observationRequest"); if (!(obs instanceof Map)) { return null; } Object zdy = ((Map) obs).get("zdy"); if (!(zdy instanceof Map)) { return null; } Object iscb = ((Map) zdy).get("ISCB"); if (iscb == null) { return null; } String v = iscb.toString().trim(); return v.isEmpty() ? null : v; } /** * 取末端节点的标量值(用于 {@code code} 可能是 Map 也可能是字符串的场景)。 * * @return 标量文本;末端为 Map 时取其中的 {@code value}/{@code code};取不到返回 null */ private static String digText(Map root, String... path) { Object cur = root; for (int i = 0; i < path.length; i++) { if (cur instanceof Map) { cur = ((Map) cur).get(path[i]); } else if (cur instanceof List) { // 列表形态(如 item=[{...}])取第一个元素继续 List list = (List) cur; if (list.isEmpty() || !(list.get(0) instanceof Map)) { return null; } cur = ((Map) list.get(0)).get(path[i]); } else { return null; } } if (cur == null) { return null; } if (cur instanceof Map) { Map m = (Map) cur; Object v = m.get("value"); if (v == null) { v = m.get("code"); } return v == null ? null : v.toString(); } if (cur instanceof String) { String s = (String) cur; return s.trim().isEmpty() ? null : s; } return cur.toString(); } /** 从报文的 {@code component2.observationRequest.code.code} 取检查项目代码(可为空) */ private String extractCheckCodeFromComponent2(Map dataMap) { // 类型安全:code 可能是 Map,也可能是空字符串 return digText(dataMap, "controlActProcess", "subject", "placerGroup", "component2", "observationRequest", "code", "code"); } /** 从报文的 {@code component2.observationRequest.location...} 取执行科室编码(可为空) */ private String extractDeptCodeFromComponent2(Map dataMap) { return digText(dataMap, "controlActProcess", "subject", "placerGroup", "component2", "observationRequest", "location", "serviceDeliveryLocation", "serviceProviderOrganization", "id", "item", "extension"); } /** * 从 dataMap 中提取 encounter 数据。 * *

报文路径:{@code controlActProcess.subject.observationRequest.componentOf1.encounter}。 * *

注意:{@code componentOf1} 位于 {@code observationRequest} 之下, * 与 {@code id}、{@code code}、{@code component2} 同级;**不是** {@code subject} 的直接子节点。 * 构造测试报文或解析日志时容易搞错层级,此处逐级判空以便定位。 */ @SuppressWarnings("unchecked") private Map extractEncounter(Map dataMap) { Map cap = (Map) dataMap.get("controlActProcess"); if (cap == null) { return null; } Map subject = (Map) cap.get("subject"); if (subject == null) { return null; } Map observationRequest = (Map) subject.get("observationRequest"); if (observationRequest == null) { return null; } Object componentOf1 = observationRequest.get("componentOf1"); if (componentOf1 == null) { return null; } return (Map) ((Map) componentOf1).get("encounter"); } /** * 检查预约状态新增/更新 * (action: {@code S050401}/{@code S050402} 新增、{@code S050501}/{@code S050502} 更新)。 * *

本次改动

*
    *
  1. 删除死代码:原实现在记录不存在时调用 {@code handleAppointmentCreate(dataMap)}, * 但本服务的报文结构是 {@code subject.actAppointment},而 create 期望 * {@code subject.observationRequest} —— 两者完全不兼容,{@code extractEncounter} * 必然返回 null 并抛异常;原紧随其后的 {@code log.warn("未找到预约记录")} * 因此**永远不会执行**。现改为明确记日志 + 容错新增。
  2. *
  3. 状态归一化:{@code ZDY.statusCode} 是**预约状态**(如 1=已预约), * 与 {@code S0405} 的**检查状态编码**语义不同,但同写入 {@code status} 列。 * 这里统一走 {@link HisCheckStatusEnum#normalize(String)} 校验。
  4. *
  5. 容错新增(Q4 要求):记录不存在时记 WARN 并依据本报文插入新记录。
  6. *
  7. 启用未使用字段(Q7 要求):{@code ZDY.remark}(注意事项)、 * {@code ZDY.area}(位置)、{@code ZDY.roomcode}/{@code roomname}(房间)、 * {@code patient.id.item[4]}(预约排序号)。
  8. *
  9. 空值防御:原实现直接 {@code LocalDateTime.parse(bookTimeStr)} * 与 {@code Integer.parseInt(bookSrc)},报文缺字段时会抛异常导致整条消息失败。
  10. *
*/ @Override @Transactional(rollbackFor = Exception.class) public void handleCheckAppointmentUpdate(Map dataMap) { Map controlActProcess = (Map) dataMap.get("controlActProcess"); if (controlActProcess == null) { throw exception(HIS_MESSAGE_PARSE_FAIL); } Map subject = (Map) controlActProcess.get("subject"); Map actAppointment = subject == null ? null : (Map) subject.get("actAppointment"); if (actAppointment == null) { throw exception(HIS_MESSAGE_PARSE_FAIL); } // 1. 获取申请编号。 // 注意:接口文档定义预约号在 actAppointment.id.extension(如 3360270), // 而实际报文同时提供了 referencedOrder[].actOrder.id.extension(如 2126091700001, // 与 S0201ECG 创建的申请单号一致)。本地 appointment.apply_no 存的是后者, // 因此**优先取 referencedOrder**,取不到再回退到 actAppointment.id。 String applyNo = extractApplyNoFromCheckAppointment(actAppointment); if (StringUtils.isEmpty(applyNo)) { log.warn("[handleCheckAppointmentUpdate][未找到申请编号,忽略] dataMap={}", dataMap); throw exception(HIS_APPLY_NO_MISSING); } // 1.1 【必做过滤】S050401/S050501 同样是**全院通用**服务,会推送放射/超声等 // 非心电科室的预约单(实测 2026-09-17:S050401 109/256、S050501 4/19 为非心电, // 检查项目如"右肘正侧位/2号机房(DR)"、"彩超右下肢动脉/5号诊室")。 // 此前该处理器**没有任何过滤**,导致这些单被当作心电申请单采集入库。 // 非心电单直接跳过(只记日志、不落库、不返回错误,避免 HIS 反复重推)。 Map zdyNode = (Map) actAppointment.get("ZDY"); String checkDeptCode = extractDeptCodeFromCheckAppointment(actAppointment); if (!hisEcgFilter.isEcgOrderWithZdy("S050401/S050501", applyNo, checkDeptCode, zdyNode)) { return; } // 2. 解析预约时间(可能为空,原实现会直接抛异常) Map zdy = zdyNode; Map effectiveTime = (Map) actAppointment.get("effectiveTime"); Map anyTime = effectiveTime == null ? null : (Map) effectiveTime.get("any"); String bookTimeStr = anyTime == null ? null : (String) anyTime.get("value"); LocalDateTime bookTime = parseDateTime(bookTimeStr); LocalDate bookDate = bookTime == null ? null : bookTime.toLocalDate(); // 3. 时间段(ZDY.time,形如 "2026-09-17 08:30-09:00"),解析失败不影响其他字段 Integer bookTimeslot = parseBookTimeslot(zdy == null ? null : (String) zdy.get("time")); // 4. 就诊类别 → bookSrc(文档:actAppointment.code.code,住院/门诊/急诊代码) Map code = (Map) actAppointment.get("code"); String bookSrc = code == null ? null : (String) code.get("code"); Integer bookSrcValue = parseIntegerQuietly(bookSrc); // 5. 预约状态归一化(ZDY.statusCode) String rawStatus = zdy == null || zdy.get("statusCode") == null ? null : zdy.get("statusCode").toString(); String status = HisCheckStatusEnum.normalize(rawStatus); if (status == null && rawStatus != null) { log.warn("[handleCheckAppointmentUpdate][预约状态编码不在编码表内,将不更新状态] applyNo={} rawStatus={}", applyNo, rawStatus); } // 6. 匹配本地记录 AppointmentDO appointment = appointmentMapper.getByApplyNo(applyNo); if (appointment == null) { log.warn("[handleCheckAppointmentUpdate][未找到预约记录,执行容错新增] applyNo={} status={}", applyNo, status); insertAppointmentFromCheckAppointment(applyNo, actAppointment, bookDate, bookTime, bookTimeslot, bookSrcValue, status); return; } // 7. 更新已有记录 if (bookDate != null) { appointment.setBookDate(bookDate); } if (bookTime != null) { appointment.setBookTime(bookTime); } if (bookTimeslot != null) { appointment.setBookTimeslot(bookTimeslot); } if (bookSrcValue != null) { appointment.setBookSrc(bookSrcValue); } if (status != null) { appointment.setStatus(status); } // —— HIS 补充字段(随表新增列一起启用)—— // 注意:remark 可能长达数百字符(如实测心电图运动试验注意事项 474 字), // 必须按列宽截断,否则 Data too long 会导致整个事务回滚(见 truncate 方法说明) if (zdy != null) { Object remark = zdy.get("remark"); if (remark != null && StringUtils.isNotEmpty(remark.toString())) { appointment.setRemark(truncate(remark.toString(), REMARK_MAX_LEN)); } Object roomName = zdy.get("roomname"); if (roomName != null && StringUtils.isNotEmpty(roomName.toString())) { appointment.setRoomName(truncate(roomName.toString(), ROOM_NAME_MAX_LEN)); } } // 预约排序号(patient.id.item[4]) Integer seqNum = extractBookSeqNum(actAppointment); if (seqNum != null) { appointment.setBookSeqNum(seqNum); } appointmentMapper.updateById(appointment); } /** * 取检查预约报文中的执行/申请科室编码。 *

* 报文路径:{@code actAppointment.subject.patient.providerOrganization.id.item.extension} * (实测样例 {@code ZY012})。仅用于心电判定的"依据 C"(需配置白名单才生效)。 */ private String extractDeptCodeFromCheckAppointment(Map actAppointment) { return digText(actAppointment, "subject", "patient", "providerOrganization", "id", "item", "extension"); } /** * 预约排序号在报文中的 root 标识。 *

* 接口文档 6.4 的示例里把「预约排序号」注在 {@code item[4]} 位置, * 但**实际报文的 root 与位置不一致**:真实报文(2026-09-17 日志)为 *

     *   item[0] root=2.16.156.10011.2.5.1.5    域ID
     *   item[1] root=2.16.156.10011.2.5.1.4    患者ID
     *   item[2] root=2.16.156.10011.1.10       门(急)诊号
     *   item[3] root=2.16.156.10011.1.12       住院号
     *   item[4] root=2.16.156.10011.2.5.1.8    就诊次数   ← 不是排序号
     *   item[5] root=2.16.156.10011.2.5.1.9    就诊流水号
     *   item[6] root=2.16.156.10011.2.5.1.11   预约排序号 ← 实际在此
     * 
* 因此**按 root 匹配**而非按位置取值,避免位置漂移导致取错字段。 */ private static final String ROOT_BOOK_SEQ_NUM = "2.16.156.10011.2.5.1.11"; /** * 取「预约排序号」。 *

* 报文路径:{@code actAppointment.subject.patient.id.item[]} 中 * {@code root=2.16.156.10011.2.5.1.11} 的那一项的 {@code extension}。 * * @return 排序号;取不到时返回 null */ @SuppressWarnings("unchecked") private Integer extractBookSeqNum(Map actAppointment) { List items = asList(actAppointment, "subject", "patient", "id", "item"); for (Object it : items) { if (!(it instanceof Map)) { continue; } Map item = (Map) it; Object root = item.get("root"); if (root != null && ROOT_BOOK_SEQ_NUM.equals(root.toString().trim())) { Object ext = item.get("extension"); return ext == null ? null : parseIntegerQuietly(ext.toString()); } } return null; } /** * 取检查预约报文的申请单号。 *

* 优先 {@code referencedOrder[0].actOrder.id.extension}(与 S0201ECG 创建的申请单号一致, * 实测 {@code 2126091700001});取不到时回退到 {@code actAppointment.id.extension} * (接口文档定义的预约号,实测 {@code 3360270})。 */ @SuppressWarnings("unchecked") private String extractApplyNoFromCheckAppointment(Map actAppointment) { Object referencedOrders = actAppointment.get("referencedOrder"); if (referencedOrders instanceof List) { List list = (List) referencedOrders; if (!list.isEmpty() && list.get(0) instanceof Map) { Map first = (Map) list.get(0); Map actOrder = (Map) first.get("actOrder"); if (actOrder != null) { Map id = (Map) actOrder.get("id"); if (id != null && id.get("extension") != null) { return id.get("extension").toString(); } } } } Map id = (Map) actAppointment.get("id"); if (id != null && id.get("extension") != null) { return id.get("extension").toString(); } return null; } /** 依据检查预约报文插入新记录(Q4 容错新增) */ @SuppressWarnings("unchecked") private void insertAppointmentFromCheckAppointment(String applyNo, Map actAppointment, LocalDate bookDate, LocalDateTime bookTime, Integer bookTimeslot, Integer bookSrcValue, String status) { AppointmentDO appointment = new AppointmentDO(); appointment.setApplyNo(applyNo); appointment.setBookDate(bookDate); appointment.setBookTime(bookTime); appointment.setBookTimeslot(bookTimeslot); appointment.setBookSrc(bookSrcValue); appointment.setStatus(status); Map zdy = (Map) actAppointment.get("ZDY"); // ZDY 中的 remark(注意事项)与 roomname(房间名称)已随表新增列落库; // area(位置)与 roomcode(房间编码)目前仍无对应列,暂不保存。 if (zdy != null) { Object remark = zdy.get("remark"); if (remark != null && StringUtils.isNotEmpty(remark.toString())) { appointment.setRemark(truncate(remark.toString(), REMARK_MAX_LEN)); } Object roomName = zdy.get("roomname"); if (roomName != null && StringUtils.isNotEmpty(roomName.toString())) { appointment.setRoomName(truncate(roomName.toString(), ROOM_NAME_MAX_LEN)); } } // 预约排序号(patient.id.item[4]) Integer bookSeqNum = extractBookSeqNum(actAppointment); if (bookSeqNum != null) { appointment.setBookSeqNum(bookSeqNum); } // 患者信息 Map subjectNode = (Map) actAppointment.get("subject"); if (subjectNode != null) { Map patient = (Map) subjectNode.get("patient"); if (patient != null) { List patIdItems = Optional.ofNullable(patient.get("id")) .map(id -> (Map) id).map(id -> id.get("item")) .filter(it -> it instanceof List).map(it -> (List) it) .orElse(Collections.emptyList()); if (patIdItems.size() > 1) { appointment.setPatId((String) ((Map) patIdItems.get(1)).get("extension")); } if (patIdItems.size() > 2) { appointment.setOutpatientNo((String) ((Map) patIdItems.get(2)).get("extension")); } if (patIdItems.size() > 3) { appointment.setHospitalNo((String) ((Map) patIdItems.get(3)).get("extension")); } // 预约排序号已在上方通过 extractBookSeqNum 统一赋值,此处不再重复处理 // 患者姓名 Optional.ofNullable(patient.get("patientPerson")) .map(p -> (Map) p) .map(p -> (Map) p.get("name")) .map(n -> (Map) n.get("item")) .map(i -> (Map) i.get("part")) .map(part -> (String) part.get("value")) .ifPresent(appointment::setPatName); // 病人科室(providerOrganization)—— Q7 启用 Optional.ofNullable(patient.get("providerOrganization")) .map(org -> (Map) org) .ifPresent(org -> { Optional.ofNullable(org.get("id")) .map(id -> (Map) id) .map(id -> (Map) id.get("item")) .map(item -> (Map) item) .map(item -> (String) item.get("extension")) .ifPresent(appointment::setPatDeptCode); Optional.ofNullable(org.get("name")) .map(n -> (Map) n) .map(n -> (Map) n.get("item")) .map(i -> (Map) i.get("part")) .map(part -> (String) part.get("value")) .ifPresent(appointment::setPatDeptDesc); }); } } if (StringUtils.isEmpty(appointment.getPatName())) { log.warn("[insertAppointmentFromCheckAppointment][容错新增的记录缺少患者姓名] applyNo={}", applyNo); } // appointment.pat_id / pat_name 在数据库中为 NOT NULL, // 缺值时若直接 insert 会抛 SQL 异常并导致整个事务回滚。 // 这里提前给出明确错误,便于 HIS 侧定位报文问题。 if (StringUtils.isEmpty(appointment.getPatId())) { log.error("[insertAppointmentFromCheckAppointment][报文缺患者编号(pat_id),无法新增] applyNo={}", applyNo); throw exception(PATIENT_NOT_EXISTS); } if (appointment.getPatGender() == null) { log.warn("[insertAppointmentFromCheckAppointment][报文未提供患者性别,按未知(0)写入] applyNo={}", applyNo); appointment.setPatGender(UNKNOWN_GENDER); } AppointmentSaveReqVO saveReqVO = BeanUtils.toBean(appointment, AppointmentSaveReqVO.class); createAppointment(saveReqVO); } /** 解析 {@code yyyyMMddHHmmss} 时间,失败或为空返回 null(不抛异常) */ private LocalDateTime parseDateTime(String value) { if (StringUtils.isEmpty(value)) { return null; } try { return LocalDateTime.parse(value.trim(), DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); } catch (Exception e) { log.warn("[parseDateTime][时间格式非法,忽略] value={}", value); return null; } } /** 解析 ZDY.time(形如 "2026-09-17 08:30-09:00")为 hhmmhhmm 整数,失败返回 null */ private Integer parseBookTimeslot(String timeRange) { if (StringUtils.isEmpty(timeRange)) { return null; } try { String[] parts = timeRange.trim().split("\\s+"); if (parts.length < 2) { return null; } String[] times = parts[1].split("-"); if (times.length < 2) { return null; } String startTime = times[0].trim().replace(":", ""); String endTime = times[1].trim().replace(":", ""); return Integer.parseInt(startTime) * 10000 + Integer.parseInt(endTime); } catch (Exception e) { log.warn("[parseBookTimeslot][时间段解析失败,忽略] timeRange={}", timeRange); return null; } } /** 宽松整数解析:非数字或空返回 null(避免 Integer.parseInt(null) 抛异常) */ private Integer parseIntegerQuietly(String value) { if (StringUtils.isEmpty(value) || !value.trim().matches("-?\\d+")) { return null; } try { return Integer.parseInt(value.trim()); } catch (NumberFormatException e) { return null; } } /** 宽松 Byte 解析:非数字或空返回 null(用于患者性别等 tinyint 字段) */ private Byte parseByteQuietly(String value) { if (StringUtils.isEmpty(value) || !value.trim().matches("-?\\d+")) { return null; } try { return Byte.parseByte(value.trim()); } catch (NumberFormatException e) { return null; } } @Override public void handleAppointmentStatusUpdate(Map dataMap) { if (dataMap == null) { throw exception(APPOINTMENT_STATUS_REGISTER_UPDATE_FAIL); } //获取applyNo Map controlActProcess = (Map) dataMap.get("controlActProcess"); Map subject = (Map) controlActProcess.get("subject"); Map placerGroup = (Map) subject.get("placerGroup"); Map component2 = (Map) placerGroup.get("component2"); Map observationRequest = (Map) component2.get("observationRequest"); Map id = (Map) observationRequest.get("id"); Map item = (Map) id.get("item"); String applyNo = (String) item.get("extension"); //获取status Map component1 = (Map) observationRequest.get("component1"); Map processStep = (Map) component1.get("processStep"); Map codeMap = (Map) processStep.get("code"); String status = String.valueOf(codeMap.get("code")); Date date = null; if (status.equals("3")) { //记录登记时间 date = new Date(); } // 【诊断日志】登记时间写入前后带类型信息,用于快速定位「00:00:00 截断」类问题。 // // 背景:2026-09-27 起 appointment.register_date 出现大量 yyyy-MM-dd 00:00:00, // 经实验确认成因是「写入值是 DATE 语义的值」而非驱动问题: // · java.util.Date + DATETIME 列 → 保留时分秒 // · java.sql.Date / DATE 列 → 必然变成 00:00:00 // 本日志打印 date 的实际类名与值,若日志里出现 java.sql.Date 或非 3 状态却被写, // 即可直接定位写入方。 if (date != null) { log.info("[handleAppointmentStatusUpdate][登记时间写入] applyNo={} status={} " + "日期对象类型={} 值={}(时间部分应为当前时刻,若为 00:00:00 说明被截断)", applyNo, status, date.getClass().getName(), new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(date)); } //状态更新 Integer i = appointmentMapper.updateStatusByApplyNo(applyNo, status, date); log.info("申请单登记applyNo:{},状态status:{},更新结果:{}", applyNo, status, i); } public AppointmentDO getByApplyNo(String applyNo) { return appointmentMapper.getByApplyNo(applyNo); } }