| | |
| | | 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.validation.annotation.Validated; |
| | | |
| | | import javax.annotation.Resource; |
| | | import java.text.SimpleDateFormat; |
| | | import java.time.LocalDate; |
| | | import java.time.LocalDateTime; |
| | | import java.time.LocalTime; |
| | | import java.time.format.DateTimeFormatter; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.Optional; |
| | | import java.util.*; |
| | | import java.util.stream.Collectors; |
| | | |
| | | import static cn.lihu.jh.framework.common.exception.util.ServiceExceptionUtil.exception; |
| | |
| | | @Validated |
| | | @Slf4j |
| | | public class AppointmentServiceImpl implements AppointmentService { |
| | | |
| | | /** |
| | | * 患者性别「未知」的兜底值。 |
| | | * <p> |
| | | * {@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 AppointmentMapper appointmentMapper; |
| | | |
| | | /** |
| | | * 心电业务单判定器。 |
| | | * <p> |
| | | * 用于 {@code S0405}(全院通用状态流)的过滤:非心电单只记日志、不落库, |
| | | * 避免向 appointment 表灌入放射/超声等其他科室的数据。 |
| | | */ |
| | | @Resource |
| | | private HisEcgFilter hisEcgFilter; |
| | | |
| | | @Autowired |
| | | private WebServiceClient webServiceClient; |
| | | |
| | | @Override |
| | | public Long createAppointment(AppointmentSaveReqVO createReqVO) { |
| | | // 插入 |
| | |
| | | return appointment.getId(); |
| | | } |
| | | |
| | | /** 并发冲突后的重试次数与等待间隔(仅用于"插入撞唯一索引"这一种情况) */ |
| | | private static final int APPLY_NO_CONFLICT_RETRY = 3; |
| | | private static final long APPLY_NO_CONFLICT_WAIT_MS = 150L; |
| | | |
| | | /** |
| | | * 按 {@code apply_no} 原子落库:已存在则更新,不存在则插入;并发重复插入时不会留重复行。 |
| | | * |
| | | * <h3>为什么需要它</h3> |
| | | * 原实现是「{@code getByApplyNo} 查一下 → 没有就 insert」,查与插之间没有原子性。 |
| | | * 实测 2026-10-04 12:16:HIS 在 423ms 内把同一张申请单推了两遍,两个线程都读到 0 行、 |
| | | * 都执行了 insert,于是同一 {@code apply_no} 落下两条(id 211740 / 211741)。 |
| | | * 表上原有的唯一索引 {@code uniq-pat-book(pat_id, book_date, book_check_type)} |
| | | * 在 book_date / book_check_type 为 NULL 时不生效,拦不住。 |
| | | * |
| | | * <h3>现在的做法</h3> |
| | | * 数据库侧已加唯一索引 {@code uk_appointment_apply_no_active}(只约束未删除行)。 |
| | | * 这里改成「先尝试插入,撞唯一键就回读并改为更新」: |
| | | * <ul> |
| | | * <li>串行场景:插入成功,行为与原来一致;</li> |
| | | * <li>并发场景:其中一个插入必然失败(DuplicateKeyException),失败方走更新,不再产生重复行;</li> |
| | | * <li>对方事务尚未提交导致回读不到时,短暂等待后重试(最多 {@value #APPLY_NO_CONFLICT_RETRY} 次)。</li> |
| | | * </ul> |
| | | * |
| | | * @return 落库记录的 id |
| | | */ |
| | | private Long atomicSaveByApplyNo(AppointmentSaveReqVO saveReqVO) { |
| | | String applyNo = saveReqVO.getApplyNo(); |
| | | // ① 先按申请单号走"更新"分支(与改造前的语义一致:命中已存在则以本次报文内容更新) |
| | | AppointmentDO exists = (applyNo == null) ? null : appointmentMapper.getByApplyNo(applyNo); |
| | | if (exists != null) { |
| | | saveReqVO.setId(exists.getId()); |
| | | updateAppointment(saveReqVO); |
| | | return exists.getId(); |
| | | } |
| | | |
| | | // ② 不存在 → 尝试插入;撞唯一索引说明有并发插入,转更新重试 |
| | | for (int attempt = 0; attempt <= APPLY_NO_CONFLICT_RETRY; attempt++) { |
| | | try { |
| | | AppointmentDO toInsert = BeanUtils.toBean(saveReqVO, AppointmentDO.class); |
| | | toInsert.setId(null); |
| | | appointmentMapper.insert(toInsert); |
| | | return toInsert.getId(); |
| | | } catch (DuplicateKeyException e) { |
| | | AppointmentDO concurrent = (applyNo == null) ? null : appointmentMapper.getByApplyNo(applyNo); |
| | | if (concurrent != null) { |
| | | log.warn("[atomicSaveByApplyNo][并发重复插入已拦截,改为更新已有记录] applyNo={} existingId={}", |
| | | applyNo, concurrent.getId()); |
| | | saveReqVO.setId(concurrent.getId()); |
| | | updateAppointment(saveReqVO); |
| | | return concurrent.getId(); |
| | | } |
| | | // 对方可能还没提交:等一下再试(最后仍拿不到就上抛,由外层记录错误日志) |
| | | if (attempt < APPLY_NO_CONFLICT_RETRY) { |
| | | try { |
| | | Thread.sleep(APPLY_NO_CONFLICT_WAIT_MS); |
| | | } catch (InterruptedException ie) { |
| | | Thread.currentThread().interrupt(); |
| | | throw e; |
| | | } |
| | | AppointmentDO retry = appointmentMapper.getByApplyNo(applyNo); |
| | | if (retry != null) { |
| | | saveReqVO.setId(retry.getId()); |
| | | updateAppointment(saveReqVO); |
| | | return retry.getId(); |
| | | } |
| | | } else { |
| | | throw e; |
| | | } |
| | | } |
| | | } |
| | | throw exception(APPOINTMENT_CREATE_FAIL); |
| | | } |
| | | |
| | | @Override |
| | | public void updateAppointment(AppointmentSaveReqVO updateReqVO) { |
| | | // 校验存在 |
| | |
| | | // 更新 |
| | | 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 |
| | |
| | | @Override |
| | | public List<AppointmentDO> queryAndCreateAppointmentByPatId(String patCode) { |
| | | // 从医院平台查询 |
| | | List<AppointmentDO> appointmentExtermalList = getAppointmentExtermal(patCode); |
| | | // |
| | | // 【本需求】匹配口径从「申请单号 / 就诊流水号 / 住院号 / 身份证号」扩展为**再加上 pat_id** |
| | | // (见 AppointmentMapper.getCurrentCode 的 SQL)。原因: |
| | | // 手动新建的预约(/ecg/appointment/create)只有 pat_id, |
| | | // apply_no / episode_id / hospital_no / pat_identity_id 全为空, |
| | | // 签到页按患者编号查询时**永远查不到** → 页面提示「请先预约」→ 无法签到。 |
| | | List<AppointmentDO> 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()); |
| | | // |
| | | // 【本需求】优先按**主键 id** 判断「是否已存在」: |
| | | // getCurrentCode 是本地库查询(select * 会把 id 带回来), |
| | | // 而原逻辑用 getByEpisodeIdAndApplyNo(episodeId, applyNo) 判断, |
| | | // 手动新建的行这两个字段是 NULL/空 ⇒ `= NULL` 永远不匹配 ⇒ 每打开一次签到页就**重复插入**一条。 |
| | | AppointmentDO appointmentDO = null; |
| | | if (null != appointmentExtermal.getId()) { |
| | | appointmentDO = appointmentMapper.selectById(appointmentExtermal.getId()); |
| | | } |
| | | if (null == 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()) { |
| | | // 【本次修复】原写法 `appointmentDO.getBookDate() != appointmentExtermal.getBookDate()` |
| | | // 是**引用比较**(LocalDate 两个不同实例即使值相同也会判为不等), |
| | | // 会导致每次都白跑一次 updateById(无谓改写 update_time)。改为值比较。 |
| | | if (!java.util.Objects.equals(appointmentDO.getBookDate(), appointmentExtermal.getBookDate())) { |
| | | appointmentMapper.updateById(appointmentExtermal); |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 查询[queue表]补充 book_seq_num 到返回值中 |
| | | // 查询[queue表]补充「排队号」到返回值中 |
| | | // |
| | | // 【号统一(本需求)】排队号改为在签到时一次性写进 queue.seq_num,不再写 book_seq_num。 |
| | | // 因此这里取 seq_num;对外字段名仍叫 bookSeqNum(需求要求接口字段保留不动)。 |
| | | // 过滤 null:存量行(改造前签到的)seq_num 可能为空,而 toMap 的 value 为 null 会抛 NPE。 |
| | | if (!appointmentExtermalList.isEmpty()) { |
| | | List<Long> appointIdlist = appointmentExtermalList.stream().map(appointmentDO -> appointmentDO.getId()).toList(); |
| | | List<QueueDO> simpleQueueDOList = queueService.selectBookSeqNumByAppointIdList(appointIdlist); |
| | | List<QueueDO> simpleQueueDOList = queueService.selectQueueByAppointIdList(appointIdlist); |
| | | if (!simpleQueueDOList.isEmpty()) { |
| | | Map<Long, Integer> mapAppointIdVsBookSeqNo = simpleQueueDOList.stream().collect(Collectors.toMap(QueueDO::getAppointId, QueueDO::getBookSeqNum)); |
| | | appointmentExtermalList.forEach(appointmentExterma -> appointmentExterma.setBookSeqNum(mapAppointIdVsBookSeqNo.get(appointmentExterma.getId()))); |
| | | Map<Long, Integer> mapAppointIdVsSeqNo = simpleQueueDOList.stream() |
| | | .filter(queueDO -> null != queueDO.getAppointId() && null != queueDO.getSeqNum()) |
| | | .collect(Collectors.toMap(QueueDO::getAppointId, QueueDO::getSeqNum, (a, b) -> a)); |
| | | appointmentExtermalList.forEach(appointmentExterma -> appointmentExterma.setBookSeqNum(mapAppointIdVsSeqNo.get(appointmentExterma.getId()))); |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | @Override |
| | | public PageResult<AppointmentDO> getAppointmentPage(AppointmentPageReqVO pageReqVO) { |
| | | //1. 先根据patId去查 |
| | | PageResult<AppointmentDO> 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); |
| | | } |
| | | |
| | |
| | | AppointmentDO appointment = getAppointment(confirmReqVO.getId()); |
| | | if (null == appointment) throw exception(PATIENT_NOT_EXISTS); |
| | | |
| | | // 手动预约,直接设置预约日期为当天 |
| | | if (confirmReqVO.getIsVip() == 1) { |
| | | boolean isVipFlag = (null != confirmReqVO.getIsVip() && 1 == confirmReqVO.getIsVip()); |
| | | boolean forceCheckIn = Boolean.TRUE.equals(confirmReqVO.getForceCheckIn()); |
| | | // 注意:这里的"是否当天"必须在改写 bookDate 之前判断(下面会把非当天改为当天) |
| | | boolean bookDateIsToday = DateUtils.isToday(appointment.getBookDate()); |
| | | |
| | | // 手动预约 / VIP 加号:预约日期直接按当天签到 |
| | | if (isVipFlag) { |
| | | appointment.setBookDate(LocalDate.now()); |
| | | bookDateIsToday = true; |
| | | } |
| | | |
| | | // 【本需求】非当天预约的签到:先提醒、确认后允许签入(前端弹窗确认后带 forceCheckIn=true) |
| | | // ① 未确认(前端还没让用户确认)→ 仍返回 APPOINTMENT_NOT_TODAY,前端据此弹「非当天,是否签到」; |
| | | // ② 已确认 → 允许签入,并且**强制走预留号通道**,避免占用当天同时段的正常预约号; |
| | | // ③ 预约日期改写为当天:队列/大屏/工作台都按 book_date = 今天 过滤, |
| | | // 若保留原日期,患者签入后不会出现在任何列表里。 |
| | | if (!bookDateIsToday) { |
| | | if (!forceCheckIn) { |
| | | throw exception(APPOINTMENT_NOT_TODAY); |
| | | } |
| | | log.info("[appoitmentConfirm] 非当天预约签入(改用预留号,预约日期改今天). appointmentId={} 原预约日期={} checkType={} timeslot={}", |
| | | appointment.getId(), appointment.getBookDate(), confirmReqVO.getBookCheckType(), |
| | | confirmReqVO.getBookTimeslot()); |
| | | appointment.setBookDate(LocalDate.now()); |
| | | } |
| | | |
| | | if (!DateUtils.isToday(appointment.getBookDate())) throw exception(APPOINTMENT_NOT_TODAY); |
| | | |
| | | |
| | | Integer newSeqNo = null; |
| | | if (confirmReqVO.getIsVip() == 0) { |
| | | newSeqNo = queueSequenceService.distributeSeqNo(confirmReqVO.getBookCheckType(), confirmReqVO.getBookTimeslot()); |
| | | } else { |
| | | newSeqNo = queueSequenceService.distributeVipSeqNo(confirmReqVO.getBookCheckType(), confirmReqVO.getBookTimeslot()); |
| | | } |
| | | // 【需求:排队序号规则】按 check_type.seq_rule 决定取号方式: |
| | | // 0=预约号(用预约系统已下发的号;当前 HIS 未下发,会自动回退到签到序号) |
| | | // 1=签到序号(当天同检查类型连续递增,每 9 个预留 1 个给加急) |
| | | // 2=时段号(按 bookTimeslot 从号池取号 —— 改造前的原有行为,也是未配置时的默认) |
| | | // |
| | | // 【本需求:按时段预约】增加 VIP/加号标记: |
| | | // · VIP/加号/非当天签入 → 本段预留号 → 总预留池(time_slot=0) |
| | | // · 普通 → 本段普通号 → 本段预留号 → 总预留池 |
| | | // 没有预约时段(bookTimeslot 为空/0/字典外)时也能签到:直接落到总预留池。 |
| | | boolean urgent = isHisUrgent(appointment); |
| | | boolean vip = isVipFlag || urgent || !bookDateIsToday; |
| | | Integer newSeqNo = queueSequenceService.distributeSeqNoByRule( |
| | | confirmReqVO.getBookCheckType(), |
| | | confirmReqVO.getBookTimeslot(), |
| | | appointment.getBookSeqNum(), |
| | | urgent, |
| | | vip); |
| | | log.info("[appoitmentConfirm][分配排队序号] appointmentId={} checkType={} timeslot={} 规则取号={} 加急={} VIP/加号={}", |
| | | appointment.getId(), confirmReqVO.getBookCheckType(), confirmReqVO.getBookTimeslot(), |
| | | newSeqNo, urgent, vip); |
| | | |
| | | try { |
| | | QueueSaveReqVO queueSaveReqVO = new QueueSaveReqVO(); |
| | |
| | | queueSaveReqVO.setBookDate(appointment.getBookDate()); |
| | | queueSaveReqVO.setBookTimeslot(confirmReqVO.getBookTimeslot()); /*appointment.getBookTimeslot()*/ |
| | | queueSaveReqVO.setBookCheckType(appointment.getBookCheckType()); |
| | | queueSaveReqVO.setBookSeqNum(newSeqNo); |
| | | // 【号统一(本需求)】签到时取的号**直接写入 seq_num**(不再写 book_seq_num)。 |
| | | // 之后任何环节都不再改这个号:分流、过号、召回、加急、装机均只改状态/工位。 |
| | | // 取号规则仍由 check_type.seq_rule 决定(预约号 / 签到序号 / 时段号),见 distributeSeqNoByRule。 |
| | | queueSaveReqVO.setSeqNum(newSeqNo); |
| | | queueSaveReqVO.setIsVip(confirmReqVO.getIsVip()); |
| | | queueSaveReqVO.setPassed((byte) 0); |
| | | queueSaveReqVO.setExpired((byte) 0); |
| | | // 需求2:ISCB「是否床边」随队列一起落库,供屏幕/页面展示 |
| | | queueSaveReqVO.setIsBedside(appointment.getIsBedside()); |
| | | // HIS 加急标记(jjbz=J)→ 排队加急,与人工插队共用 jump_flag。 |
| | | // 【号统一后】加急**不改排队号**(号在下面 setSeqNum 时已经定死), |
| | | // 而是靠排序体现:分流取人、叫号取人、工作台/诊间屏/大屏列表都是 |
| | | // "jump_flag desc 优先 → seq_num 升序",置 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) { |
| | |
| | | return newSeqNo; |
| | | } |
| | | |
| | | private PatDetails getPatDetails(AppointmentDO appointment) { |
| | | PatDetails patDetails = new PatDetails(); |
| | | /** |
| | | * 判断该预约是否为 HIS 标记的加急单。 |
| | | * <p> |
| | | * 依据 {@code appointment.jjbz}(来源 HIS 报文 {@code zdy.jjbz}): |
| | | * {@code J} / {@code 1} / {@code Y} 视为加急,其余(如 {@code N})为常规。 |
| | | * <p> |
| | | * <b>注意</b>:实测 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()); |
| | |
| | | |
| | | Optional.ofNullable(encounter.get("location")).map(location -> (Map<String, Object>) location).map(location -> (Map<String, Object>) location.get("serviceDeliveryLocation")).map(serviceDeliveryLocation -> (Map<String, Object>) serviceDeliveryLocation.get("location")).map(location -> (Map<String, Object>) location.get("id")).map(id -> (Map<String, Object>) id.get("item")).map(item -> (String) item.get("extension")).ifPresent(appointment::setPatBedNo); |
| | | |
| | | Optional.ofNullable(dataMap.get("controlActProcess")).map(controlActProcess -> (Map<String, Object>) controlActProcess).map(controlActProcess -> (Map<String, Object>) controlActProcess.get("subject")).map(subject -> (Map<String, Object>) subject.get("observationRequest")).map(observationRequest -> (Map<String, Object>) 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<String, Object> 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<String, Object> 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); |
| | | } |
| | | |
| | | // 预约时间/时间段/诊室(S0201ECG 报文的 component2.observationRequest.ZDY,实测形如: |
| | | // ZDY={time=2026-10-06 08:00-08:30, area=..., roomname=6号诊室(厅右侧), roomcode=, jcxmcode=201652, jcxmname=平板运动试验} |
| | | // 原实现只把它当"检查类型"用,没解析预约信息,导致这条流建出来的记录 |
| | | // book_date / book_timeslot / book_time / room_name 全为空(实测 211741、211962 就是这种半条数据)。 |
| | | Object zdyRaw = c2.get("ZDY"); |
| | | if (zdyRaw == null) { |
| | | zdyRaw = c2.get("zdy"); |
| | | } |
| | | if (zdyRaw instanceof Map) { |
| | | Map<String, Object> zdyMap = (Map<String, Object>) zdyRaw; |
| | | Object timeObj = zdyMap.get("time"); |
| | | String timeRange = timeObj == null ? null : timeObj.toString(); |
| | | Integer timeslot = parseBookTimeslot(timeRange); |
| | | if (timeslot != null) { |
| | | appointment.setBookTimeslot(timeslot); |
| | | // 由时间段起始推算预约时刻(如 8000830 → 08:00);book_date 见下方 ④ 的统一推导 |
| | | LocalTime startTime = timeOfTimeslot(timeslot); |
| | | if (startTime != null) { |
| | | appointment.setBookTime(LocalDateTime.of(LocalDate.now(), startTime)); |
| | | } |
| | | } else { |
| | | // 时间段解析不出来时,退回用 effectiveTime(报文里另一个预约时刻字段) |
| | | LocalDateTime bookTime = parseDateTime(digText(c2, "effectiveTime", "any", "value")); |
| | | if (bookTime != null) { |
| | | appointment.setBookTime(bookTime); |
| | | appointment.setBookDate(bookTime.toLocalDate()); |
| | | } |
| | | } |
| | | Object roomName = zdyMap.get("roomname"); |
| | | if (roomName != null && StringUtils.isNotEmpty(roomName.toString())) { |
| | | appointment.setRoomName(truncate(roomName.toString(), ROOM_NAME_MAX_LEN)); |
| | | } |
| | | } |
| | | |
| | | // 医嘱号:component2.observationRequest.id.item[root=2.16.156.10011.1.28].extension |
| | | // |
| | | // 依据(HIS《交互服务通用接口文档》): |
| | | // · 6.1 申请单新增服务(本项目所用 S0201ECG)的示例中该节点注释即为「医嘱ID」: |
| | | // <id><!--医嘱ID--><item root="2.16.156.10011.1.28" extension=""/></id> |
| | | // · 6.4/6.5 检查预约状态服务的示例中同样注释为「医嘱号」: |
| | | // <!-- 医嘱号 --> ... <id root="2.16.156.10011.1.28" extension="11"/> |
| | | // 实测 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); |
| | | } |
| | | } |
| | | |
| | | // 医生开单时间(报文路径:controlActProcess.subject.observationRequest.author.time.value) |
| | | // |
| | | // 【为什么不用一行 Optional 链直接 parse】 |
| | | // 原实现写死 `SimpleDateFormat("yyyyMMddHHmmss")`,格式不符只会 **静默丢弃** |
| | | // (catch 里仅 printStackTrace,不落日志字段)——线上出现问题时无从定位。 |
| | | // 现改为显式分支 + 结构化 WARN 日志,至少把「报文原值 / 申请单号」记下来。 |
| | | String doctorTimeRaw = digText(dataMap, "controlActProcess", "subject", |
| | | "observationRequest", "author", "time", "value"); |
| | | // 该处尚未执行到下方的 setApplyNo(原 L575),故单独取一次申请单号,仅用于日志定位 |
| | | String applyNoForLog = digText(dataMap, "controlActProcess", "subject", |
| | | "observationRequest", "id", "item", "extension"); |
| | | if (StringUtils.isNotEmpty(doctorTimeRaw)) { |
| | | // 原样采纳 HIS 所发的值(不做取舍,避免误判反而写坏数据)。 |
| | | // 若为「日期值」(时分秒=000000,实测约 61%),parseHisDoctorTime 会打 WARN |
| | | // 记录原始报文值 + 申请单号,便于向 HIS 侧反馈;写入行为与改动前一致。 |
| | | java.util.Date parsedDoctorTime = parseHisDoctorTime(doctorTimeRaw, applyNoForLog); |
| | | if (parsedDoctorTime != null) { |
| | | appointment.setDoctorTime(parsedDoctorTime); |
| | | } |
| | | } else { |
| | | log.warn("[handleAppointmentCreate][报文缺少开单时间 author/time/value,doctor_time 将为空] applyNo={}", |
| | | applyNoForLog); |
| | | } |
| | | |
| | | //设置开单医生姓名 |
| | | Optional.ofNullable(dataMap.get("controlActProcess")).map(controlActProcess -> (Map<String, Object>) controlActProcess).map(controlActProcess -> (Map<String, Object>) controlActProcess.get("subject")).map(subject -> (Map<String, Object>) subject.get("observationRequest")).map(observationRequest -> (Map<String, Object>) observationRequest.get("author")).map(author -> (Map<String, Object>) author.get("assignedEntity")).map(assignedEntity -> (Map<String, Object>) assignedEntity.get("assignedPerson")).map(assignedPerson -> (Map<String, Object>) assignedPerson.get("name")).map(name -> (Map<String, Object>) name.get("item")).map(item -> (Map<String, Object>) item.get("part")).map(part -> (String) part.get("value")).ifPresent(appointment::setDoctor); |
| | | |
| | | //设置开单医生编码 |
| | | Optional.ofNullable(dataMap.get("controlActProcess")).map(controlActProcess -> (Map<String, Object>) controlActProcess).map(controlActProcess -> (Map<String, Object>) controlActProcess.get("subject")).map(subject -> (Map<String, Object>) subject.get("observationRequest")).map(observationRequest -> (Map<String, Object>) observationRequest.get("author")).map(author -> (Map<String, Object>) author.get("assignedEntity")).map(assignedEntity -> (Map<String, Object>) assignedEntity.get("id")).map(id -> (Map<String, Object>) id.get("item")).map(item -> (String) item.get("extension")).ifPresent(appointment::setDoctorCode); |
| | | |
| | | |
| | | // 设置患者来源类型 |
| | | Optional.ofNullable(encounter.get("code")).map(code -> (Map<String, Object>) code).map(code -> (String) code.get("code")).map(Integer::parseInt).ifPresent(appointment::setPatSrc); |
| | |
| | | Optional.ofNullable(encounter.get("subject")).map(subject -> (Map<String, Object>) subject).map(subject -> (Map<String, Object>) subject.get("patient")).map(patient -> (Map<String, Object>) patient.get("patientPerson")).ifPresent(patientPerson -> { |
| | | // 设置患者ID |
| | | Optional.ofNullable(encounter.get("subject")).map(subject -> (Map<String, Object>) subject).map(subject -> (Map<String, Object>) subject.get("patient")).map(patient -> (Map<String, Object>) patient.get("id")).map(id -> (Map<String, Object>) id).map(id -> (List<?>) id.get("item")).filter(items -> items.size() > 1).map(items -> (Map<String, Object>) items.get(1)).map(item -> (String) item.get("extension")).ifPresent(appointment::setPatId); |
| | | |
| | | // 设置患者门诊号 |
| | | Optional.ofNullable(encounter.get("subject")).map(subject -> (Map<String, Object>) subject).map(subject -> (Map<String, Object>) subject.get("patient")).map(patient -> (Map<String, Object>) patient.get("id")).map(id -> (Map<String, Object>) id).map(id -> (List<?>) id.get("item")).filter(items -> items.size() > 1).map(items -> (Map<String, Object>) items.get(2)).map(item -> (String) item.get("extension")).ifPresent(appointment::setOutpatientNo); |
| | | |
| | | // 设置患者住院号 |
| | | Optional.ofNullable(encounter.get("subject")).map(subject -> (Map<String, Object>) subject).map(subject -> (Map<String, Object>) subject.get("patient")).map(patient -> (Map<String, Object>) patient.get("id")).map(id -> (Map<String, Object>) id).map(id -> (List<?>) id.get("item")).filter(items -> items.size() > 1).map(items -> (Map<String, Object>) items.get(3)).map(item -> (String) item.get("extension")).ifPresent(appointment::setHospitalNo); |
| | | |
| | | // 设置患者姓名 |
| | | Optional.ofNullable(patientPerson.get("name")).map(name -> (Map<String, Object>) name).map(name -> (Map<String, Object>) name.get("item")).map(item -> (Map<String, Object>) item.get("part")).map(part -> (String) part.get("value")).ifPresent(appointment::setPatName); |
| | |
| | | |
| | | // 设置患者身份证号 |
| | | Optional.ofNullable(patientPerson.get("id")).map(id -> (Map<String, Object>) id).map(id -> (List<?>) id.get("item")).filter(items -> !items.isEmpty()).map(items -> (Map<String, Object>) items.get(0)).map(item -> (String) item.get("extension")).ifPresent(appointment::setPatIdentityId); |
| | | }); |
| | | |
| | | // 设置科室和病区信息 |
| | | Optional.ofNullable(encounter.get("location")).map(location -> (Map<String, Object>) location).map(location -> (Map<String, Object>) location.get("serviceDeliveryLocation")).map(location -> (Map<String, Object>) location.get("serviceProviderOrganization")).ifPresent(org -> { |
| | | // 设置科室代码和名称 |
| | | Optional.ofNullable(org.get("id")).map(id -> (Map<String, Object>) id).map(id -> (Map<String, Object>) id.get("item")).map(item -> (String) item.get("extension")).ifPresent(appointment::setPatDeptCode); |
| | | |
| | | Optional.ofNullable(org.get("name")).map(name -> (Map<String, Object>) name).map(name -> (Map<String, Object>) name.get("item")).map(item -> (Map<String, Object>) item.get("part")).map(part -> (String) part.get("value")).ifPresent(appointment::setPatDeptDesc); |
| | | |
| | | // 设置病区代码和名称 |
| | | Optional.ofNullable(org.get("asOrganizationPartOf")).map(wardOrg -> (Map<String, Object>) wardOrg).map(wardOrg -> (Map<String, Object>) wardOrg.get("wholeOrganization")).ifPresent(wardOrg -> { |
| | | Optional.ofNullable(wardOrg.get("id")).map(id -> (Map<String, Object>) id).map(id -> (Map<String, Object>) id.get("item")).map(item -> (String) item.get("extension")).ifPresent(appointment::setPatWardCode); |
| | | |
| | | Optional.ofNullable(wardOrg.get("name")).map(name -> (Map<String, Object>) name).map(name -> (Map<String, Object>) name.get("item")).map(item -> (Map<String, Object>) item.get("part")).map(part -> (String) part.get("value")).ifPresent(appointment::setPatWardDesc); |
| | | }); |
| | | //患者医保卡 |
| | | Optional.ofNullable(patientPerson.get("id")).map(id -> (Map<String, Object>) id).map(id -> (List<?>) id.get("item")).filter(items -> !items.isEmpty()).map(items -> (Map<String, Object>) items.get(1)).map(item -> (String) item.get("extension")).ifPresent(appointment::setMedicalCard); |
| | | }); |
| | | |
| | | // 设置支付状态 |
| | |
| | | // 设置申请单号 |
| | | Optional.ofNullable(dataMap.get("controlActProcess")).map(controlActProcess -> (Map<String, Object>) controlActProcess).map(controlActProcess -> (Map<String, Object>) controlActProcess.get("subject")).map(subject -> (Map<String, Object>) subject.get("observationRequest")).map(observationRequest -> (Map<String, Object>) observationRequest.get("id")).map(id -> (Map<String, Object>) id.get("item")).map(item -> (String) item.get("extension")).ifPresent(appointment::setApplyNo); |
| | | |
| | | // 预约日期:报文里的预约时间段 ZDY.time 形如 "2026-10-06 08:00-08:30"。 |
| | | // 上面解析时间段时只拿到了时分(用于 book_timeslot/book_time),这里补日期: |
| | | // 若只拿到时间段而日期为空,就从 ZDY.time 的日期部分补上,并据此修正 book_time 的日期。 |
| | | String zdyTimeRange = digText(dataMap, "controlActProcess", "subject", "observationRequest", |
| | | "component2", "observationRequest", "ZDY", "time"); |
| | | if (StringUtils.isEmpty(zdyTimeRange)) { |
| | | zdyTimeRange = digText(dataMap, "controlActProcess", "subject", "observationRequest", |
| | | "component2", "observationRequest", "zdy", "time"); |
| | | } |
| | | if (StringUtils.isNotEmpty(zdyTimeRange)) { |
| | | String[] parts = zdyTimeRange.trim().split("\\s+"); |
| | | if (parts.length >= 1) { |
| | | LocalDateTime parsedDate = parseDateTime(parts[0] + " 00:00"); |
| | | if (parsedDate != null) { |
| | | LocalDate zdyBookDate = parsedDate.toLocalDate(); |
| | | if (appointment.getBookDate() == null) { |
| | | appointment.setBookDate(zdyBookDate); |
| | | } |
| | | // book_time 之前只是用"今天"占位,这里换成真实预约日期 |
| | | if (appointment.getBookTime() != null) { |
| | | appointment.setBookTime(appointment.getBookTime().withYear(zdyBookDate.getYear()) |
| | | .withMonth(zdyBookDate.getMonthValue()).withDayOfMonth(zdyBookDate.getDayOfMonth())); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | //设置检查类型 |
| | | // String checkType = Optional.ofNullable(dataMap.get("controlActProcess")).map(controlActProcess -> (Map<String, Object>) controlActProcess).map(controlActProcess -> (Map<String, Object>) controlActProcess.get("component2")).map(component2 -> (Map<String, Object>) component2.get("observationRequest")).map(observationRequest -> (Map<String, Object>) observationRequest.get("code")).map(code -> (String) code.get("code")).orElseThrow(() -> exception(APPOINTMENT_CREATE_FAIL)); |
| | | String checkType = Optional.ofNullable(dataMap.get("controlActProcess")).map(controlActProcess -> (Map<String, Object>) controlActProcess).map(controlActProcess -> (Map<String, Object>) controlActProcess.get("subject")).map(subject -> (Map<String, Object>) subject.get("observationRequest")).map(observationRequest -> (Map<String, Object>) observationRequest.get("component2")).map(component2 -> (Map<String, Object>) component2.get("observationRequest")).map(observationRequest -> (Map<String, Object>) observationRequest.get("code")).map(code -> (String) code.get("code")).orElseThrow(() -> exception(APPOINTMENT_CREATE_FAIL)); |
| | | if (checkType != null || checkType != "") |
| | | appointment.setBookCheckType(Integer.valueOf(HisCheckCodeEnum.getByCode(checkType.substring(1)))); |
| | | String checkName = Optional.ofNullable(dataMap.get("controlActProcess")).map(controlActProcess -> (Map<String, Object>) controlActProcess).map(controlActProcess -> (Map<String, Object>) controlActProcess.get("subject")).map(subject -> (Map<String, Object>) subject.get("observationRequest")).map(observationRequest -> (Map<String, Object>) observationRequest.get("component2")).map(component2 -> (Map<String, Object>) component2.get("observationRequest")).map(observationRequest -> (Map<String, Object>) observationRequest.get("code")).map(code -> (Map<String, Object>) 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 { |
| | | // 名称映射不到就退回「按 HIS 检查代码映射」: |
| | | // 实测运动试验在 HIS 侧改过名(平板运动心电 → 平板运动试验), |
| | | // 只认名称会让 book_check_type 落空;而报文里的 code(如 201652)是稳定的。 |
| | | Integer fallback = resolveBookCheckTypeFromComponent2(dataMap); |
| | | if (fallback != null) { |
| | | appointment.setBookCheckType(fallback); |
| | | log.info("[handleAppointmentCreate][体检患者名称未映射,已按检查代码兜底] checkName={} bookCheckType={}", |
| | | checkName, fallback); |
| | | } 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<String, Object>) location).map(location -> (Map<String, Object>) location.get("serviceDeliveryLocation")).map(location -> (Map<String, Object>) location.get("serviceProviderOrganization")).ifPresent(org -> { |
| | | // 设置科室代码和名称 |
| | | Optional.ofNullable(org.get("id")).map(id -> (Map<String, Object>) id).map(id -> (Map<String, Object>) id.get("item")).map(item -> (String) item.get("extension")).ifPresent(appointment::setPatDeptCode); |
| | | |
| | | Optional.ofNullable(org.get("name")).map(name -> (Map<String, Object>) name).map(name -> (Map<String, Object>) name.get("item")).map(item -> (Map<String, Object>) item.get("part")).map(part -> (String) part.get("value")).ifPresent(appointment::setPatDeptDesc); |
| | | |
| | | // 设置病区代码和名称 |
| | | Optional.ofNullable(org.get("asOrganizationPartOf")).map(wardOrg -> (Map<String, Object>) wardOrg).map(wardOrg -> (Map<String, Object>) wardOrg.get("wholeOrganization")).ifPresent(wardOrg -> { |
| | | Optional.ofNullable(wardOrg.get("id")).map(id -> (Map<String, Object>) id).map(id -> (Map<String, Object>) 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<String, Object>) name).map(name -> (Map<String, Object>) name.get("item")).map(item -> (Map<String, Object>) item.get("part")).map(part -> (String) part.get("value")).ifPresent(appointment::setPatWardDesc); |
| | | } |
| | | }); |
| | | }); |
| | | |
| | | |
| | | // 将AppointmentDO转换为AppointmentSaveReqVO并保存 |
| | | AppointmentSaveReqVO saveReqVO = BeanUtils.toBean(appointment, AppointmentSaveReqVO.class); |
| | | saveReqVO.setStatus("1"); |
| | | return createAppointment(saveReqVO); |
| | | |
| | | // 按申请单号检查是否已存在。 |
| | | // 【本次改动 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 上,否则更新/插入用的还是"未合并"的报文值 |
| | | saveReqVO = BeanUtils.toBean(appointment, AppointmentSaveReqVO.class); |
| | | } |
| | | // 交给原子落库:不存在则插入,已存在则更新;并发重复插入会被唯一索引拦下并转为更新, |
| | | // 不会再出现"同一 apply_no 两条记录"(原实现在这里"查一次再插",并发下必然漏)。 |
| | | return atomicSaveByApplyNo(saveReqVO); |
| | | } catch (Exception e) { |
| | | log.error("[handleAppointmentCreate][处理预约创建失败 dataMap({})]", dataMap, e); |
| | | throw exception(APPOINTMENT_CREATE_FAIL); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 更新前合并:本次报文未提供(为 null)的字段,沿用数据库中的原值。 |
| | | * <p> |
| | | * 目的:{@code S0201ECG} 的某些字段在部分场景下会缺失(例如体检患者无预约时间、 |
| | | * 床头屏消息无科室信息)。若直接以报文内容整体覆盖,会把已有数据清空。 |
| | | * 这是"部分更新"语义,与更新流的 {@code ifPresent} 逐字段赋值保持一致。 |
| | | * <p> |
| | | * 注意:在 {@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 |
| | |
| | | } |
| | | return null; |
| | | }).ifPresent(existingAppointment::setPaid); |
| | | //设置检查类型 |
| | | String checkType = Optional.ofNullable(dataMap.get("controlActProcess")).map(controlActProcess -> (Map<String, Object>) controlActProcess).map(controlActProcess -> (Map<String, Object>) controlActProcess.get("subject")).map(subject -> (Map<String, Object>) subject.get("observationRequest")).map(observationRequest -> (Map<String, Object>) observationRequest.get("component2")).map(component2 -> (Map<String, Object>) component2.get("observationRequest")).map(observationRequest -> (Map<String, Object>) observationRequest.get("code")).map(code -> (String) code.get("code")).orElseThrow(() -> exception(APPOINTMENT_CREATE_FAIL)); |
| | | if (checkType != null || checkType != "") |
| | | existingAppointment.setBookCheckType(Integer.valueOf(HisCheckCodeEnum.getByCode(checkType.substring(1)))); |
| | | // 设置检查类型 |
| | | 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); |
| | |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 从报文 {@code component2.observationRequest.code.code} 解析本地检查类型值。 |
| | | * |
| | | * <h3>修正的缺陷</h3> |
| | | * 原实现为: |
| | | * <pre> |
| | | * if (checkType != null || checkType != "") // 恒为 true,且逻辑应为 && |
| | | * setBookCheckType(Integer.valueOf( |
| | | * HisCheckCodeEnum.getByCode(checkType.substring(1)))); // 无长度校验 |
| | | * </pre> |
| | | * 存在三处问题: |
| | | * <ol> |
| | | * <li>{@code ||} 使判断恒真;</li> |
| | | * <li>{@code substring(1)} 直接截断,报文值长度不足时抛 |
| | | * {@code StringIndexOutOfBoundsException}(实测日志中有类似异常);</li> |
| | | * <li>{@code HisCheckCodeEnum.getByCode()} 可能返回 null, |
| | | * 随后 {@code Integer.valueOf(null)} 抛 {@code NumberFormatException} |
| | | * (实测日志中确有 {@code Cannot parse null string})。</li> |
| | | * </ol> |
| | | * 现改为:HIS 代码兼容带/不带 {@code Y} 前缀(对应原 substring(1) 语义), |
| | | * 且仅在能映射到本地 {@code check_type} 值时才返回,否则返回 null 由调用方保留原值。 |
| | | * |
| | | * @return 本地检查类型值(如 1000);无法解析时返回 null |
| | | */ |
| | | private Integer resolveBookCheckTypeFromComponent2(Map<String, Object> 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})。 |
| | | * |
| | | * <h3>⚠️ 该服务是全院通用的</h3> |
| | | * 接口文档 6.3 定义其 action 为 {@code S0405|S0405HIS|S040501HIS},**不区分科室**。 |
| | | * 医院会把所有检查科室(CT、MRI、DR、超声、骨密度…)的检查状态一并推送。 |
| | | * 实测 2026-09-17:本服务收到 1225 次请求,其中仅 3.5% 的申请单号属心电业务, |
| | | * 其余为放射/超声等科室的单(如 {@code 2126091700001} 胸部CT平扫 / 急诊CT室)。 |
| | | * |
| | | * <h3>处理策略</h3> |
| | | * <ol> |
| | | * <li>提取申请单号与检查状态码,并对状态码做**归一化**(去前导零、校验合法性)</li> |
| | | * <li>查本地预约记录: |
| | | * <ul> |
| | | * <li><b>存在</b> → 更新状态;状态为 3(检查登记)时同时写入登记时间</li> |
| | | * <li><b>不存在 + 是心电单</b> → 记 WARN 并**插入新记录**(Q4 要求容错新增)</li> |
| | | * <li><b>不存在 + 非心电单</b> → 仅记 DEBUG 日志,**不插入**(避免灌入无关数据)</li> |
| | | * </ul> |
| | | * </li> |
| | | * </ol> |
| | | */ |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public void handleAppointmentStateUpdate(Map<String, Object> dataMap) { |
| | | if (dataMap == null) { |
| | | throw exception(APPOINTMENT_STATUS_UPDATE_FAIL); |
| | | } |
| | | // 从dataMap中提取申请单号 |
| | | String applyNo = Optional.ofNullable(dataMap.get("controlActProcess")).map(controlActProcess -> (Map<String, Object>) controlActProcess).map(controlActProcess -> (Map<String, Object>) controlActProcess.get("subject")).map(subject -> (Map<String, Object>) subject.get("placerGroup")).map(placerGroup -> (Map<String, Object>) placerGroup.get("component2")).map(component2 -> (Map<String, Object>) component2.get("observationRequest")).map(observationRequest -> (Map<String, Object>) observationRequest.get("id")).map(id -> (Map<String, Object>) id.get("item")).map(item -> (String) item.get("extension")).orElseThrow(() -> exception(APPOINTMENT_CREATE_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); |
| | | |
| | | // 如果记录不存在,则调用handleAppointmentCreate进行新增 |
| | | if (existingAppointment == null) { |
| | | handleAppointmentCreate(dataMap); |
| | | if (existingAppointment != null) { |
| | | // 状态流只允许改 status / register_date: |
| | | // 原实现把查出来的整行对象再 updateById 写回(读-改-写),会把 book_date / book_check_type |
| | | // 等字段按旧快照覆盖。实测 2026-10-05 07:58,一笔预约刚被 S050401 写好预约时间与检查类型, |
| | | // 3.6 秒后 S0405 状态流又把整行写回一次,check_type 被写成了空。 |
| | | // 这里改成只更新状态(register_date 仅在状态=登记时写入,由 updateStatusByApplyNo 处理)。 |
| | | Date registerDate = HisCheckStatusEnum.REGISTERED.getCode().toString().equals(status) |
| | | ? new Date() : null; |
| | | Integer updated = registerDate != null |
| | | ? appointmentMapper.updateStatusByApplyNo(applyNo, status, registerDate) |
| | | : appointmentMapper.updateStatusOnlyByApplyNo(applyNo, status); |
| | | log.info("[handleAppointmentStateUpdate][仅更新状态] applyNo={} status={} 影响行数={}", |
| | | applyNo, status, updated); |
| | | 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<String, Object> 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; |
| | | } |
| | | |
| | | // 从dataMap中提取申请单号 |
| | | // String status = Optional.ofNullable(dataMap.get("controlActProcess")).map(controlActProcess -> (Map<String, Object>) controlActProcess).map(controlActProcess -> (Map<String, Object>) controlActProcess.get("subject")).map(subject -> (Map<String, Object>) subject.get("placerGroup")).map(placerGroup -> (Map<String, Object>) placerGroup.get("component2")).map(component2 -> (Map<String, Object>) component2.get("observationRequest")).map(observationRequest -> (Map<String, Object>) observationRequest.get("component1")).map(component1 -> (Map<String, Object>) component1.get("processStep")).map(processStep -> (Map<String, Object>) processStep.get("code")).map(code -> (Map<String, Object>) code.get("displayName")).map(displayName -> (String) displayName.get("value")).orElseThrow(() -> exception(APPOINTMENT_CREATE_FAIL)); |
| | | String status = Optional.ofNullable(dataMap.get("controlActProcess")).map(controlActProcess -> (Map<String, Object>) controlActProcess).map(controlActProcess -> (Map<String, Object>) controlActProcess.get("subject")).map(subject -> (Map<String, Object>) subject.get("placerGroup")).map(placerGroup -> (Map<String, Object>) placerGroup.get("component2")).map(component2 -> (Map<String, Object>) component2.get("observationRequest")).map(observationRequest -> (Map<String, Object>) observationRequest.get("component1")).map(component1 -> (Map<String, Object>) component1.get("processStep")).map(processStep -> (Map<String, Object>) processStep.get("code")).map(code -> (String) code.get("code")).orElseThrow(() -> exception(APPOINTMENT_CREATE_FAIL)); |
| | | |
| | | existingAppointment.setStatus(status); |
| | | // 将AppointmentDO转换为AppointmentSaveReqVO并更新 |
| | | AppointmentSaveReqVO updateReqVO = BeanUtils.toBean(existingAppointment, AppointmentSaveReqVO.class); |
| | | updateAppointment(updateReqVO); |
| | | // 5. 是心电单但本地无记录 → 容错新增(Q4 要求) |
| | | log.warn("[handleAppointmentStateUpdate][心电业务单但本地无记录,执行容错新增] applyNo={} status={} dept={}", |
| | | applyNo, status, hisDeptCode); |
| | | insertAppointmentFromStateMessage(applyNo, status, dataMap); |
| | | } |
| | | |
| | | /** |
| | | * 从 S0405 报文中构造并插入一条预约记录。 |
| | | * |
| | | * <h3>⚠️ 已知的数据缺失</h3> |
| | | * {@code S0405} 报文**不包含检查项目信息**(无 {@code component2.observationRequest.code}), |
| | | * 因此无法确定 {@code book_check_type}(检查类型)。实测确认:申请单号后缀 |
| | | * 与检查类型**无对应关系**(同为 {@code ...1101} 后缀的记录存在 1000/100/1100 等多种类型), |
| | | * 故不做猜测,保持为空并输出 WARN,待后续 {@code S0202ECG}(申请单更新) |
| | | * 或人工补录修正。 |
| | | * <p> |
| | | * 同理 {@code book_date}(预约日期)也不在报文中,保持为空 —— 这与现有数据一致 |
| | | * (库中约 48% 记录 {@code book_date} 为空,预约日期由 S050401/S040501HIS 后续补入)。 |
| | | */ |
| | | private void insertAppointmentFromStateMessage(String applyNo, String status, Map<String, Object> 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<String, Object> 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<String, Object>) encIdItems.get(1)).get("extension"))); |
| | | } |
| | | // 患者信息 |
| | | Map<String, Object> 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<String, Object>) patIdItems.get(1)).get("extension"))); |
| | | } |
| | | if (patIdItems.size() > 2 && patIdItems.get(2) instanceof Map) { |
| | | appointment.setOutpatientNo(str(((Map<String, Object>) patIdItems.get(2)).get("extension"))); |
| | | } |
| | | if (patIdItems.size() > 3 && patIdItems.get(3) instanceof Map) { |
| | | appointment.setHospitalNo(str(((Map<String, Object>) 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<String, Object> 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); |
| | | } |
| | | |
| | | /** |
| | | * 从dataMap中提取encounter数据 |
| | | * 取 S0405 报文中的 {@code ZDY} 节点。 |
| | | * <p> |
| | | * 实测路径:{@code controlActProcess.subject.placerGroup.component2.observationRequest |
| | | * .component1.processStep.code.ZDY},内容形如: |
| | | * <pre> |
| | | * ZDY={roomcode=111, roomname=急诊CT室, jcxmcode=1080,3016,3014, |
| | | * jcxmname=胸部CT平扫,上腹部CT平扫,下腹部CT平扫, |
| | | * pdh=000336, yysd=00:00-01:00, isjj=1} |
| | | * </pre> |
| | | * 注意:{@code observationRequest.code} 在报文中**可能是空字符串而非 Map**, |
| | | * 故必须用 {@link #dig(Object, String...)} 做类型安全导航。 |
| | | */ |
| | | private Map<String, Object> extractZdyFromStateMessage(Map<String, Object> dataMap) { |
| | | return dig(dataMap, "controlActProcess", "subject", "placerGroup", "component2", |
| | | "observationRequest", "component1", "processStep", "code", "ZDY"); |
| | | } |
| | | |
| | | /** 从 Map 中取字符串字段(可为空) */ |
| | | private String extractZdyValueFromMap(Map<String, Object> 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(); |
| | | } |
| | | |
| | | /** |
| | | * 从 S050401/S050501(检查预约)报文的 {@code ZDY} 节点解析本地 {@code book_check_type}。 |
| | | * |
| | | * <h3>为什么需要这个方法</h3> |
| | | * {@code S050401/S050501} 报文的检查项目放在 {@code ZDY.jcxmcode / ZDY.jcxmname} |
| | | * (形如 {@code jcxmcode=201652, jcxmname=平板运动试验}), |
| | | * 与 {@code S0201ECG} 的 {@code component2.observationRequest.code.code} **不是同一路径**。 |
| | | * 原先只有 S0201ECG 那条路径解析检查类型,于是「S050401 先到、S0201ECG 未到」时走 |
| | | * {@link #insertAppointmentFromCheckAppointment} 容错新增, |
| | | * {@code book_check_type} 会**永远为空** —— 实测 2026-09-29~10-09 共 279 条 |
| | | * (如 {@code apply_no=8826092420371106},报文里 {@code jcxmcode=201652} 明明存在)。 |
| | | * |
| | | * <h3>解析顺序</h3> |
| | | * <ol> |
| | | * <li><b>名称优先</b>:{@code jcxmname} → {@link HisCheckNameEnum#getByName}。 |
| | | * 该枚举已含「平板运动试验」等 HIS 改名别名,且内置规范化匹配;</li> |
| | | * <li><b>代码兜底</b>:{@code jcxmcode} → {@link HisCheckCodeEnum}, |
| | | * 兼容带/不带 {@code Y} 前缀。</li> |
| | | * </ol> |
| | | * |
| | | * <h3>多值处理</h3> |
| | | * 实测该字段可能为逗号分隔多值(如 S0405 的 {@code jcxmcode=1080,3016,3014})。 |
| | | * 策略:**按顺序取第一个能映射成功的值**;先整体试探(单值场景),再逐段试探。 |
| | | * |
| | | * @param zdy 报文中的 {@code ZDY} 节点,可为 null |
| | | * @return 本地检查类型值(如 400);无法解析时返回 null(调用方保持原值不写) |
| | | */ |
| | | private Integer resolveBookCheckTypeFromZdy(Map<String, Object> zdy, String applyNo) { |
| | | if (zdy == null) { |
| | | log.warn("[resolveBookCheckTypeFromZdy][报文无 ZDY 节点,无法解析检查类型] applyNo={}", applyNo); |
| | | return null; |
| | | } |
| | | String jcxmName = extractZdyValueFromMap(zdy, "jcxmname"); |
| | | String jcxmCode = extractZdyValueFromMap(zdy, "jcxmcode"); |
| | | |
| | | // ① 名称映射(优先:可读,且枚举已覆盖 HIS 改名与规范化差异) |
| | | Integer byName = mapCheckNameToType(jcxmName); |
| | | if (byName != null) { |
| | | return byName; |
| | | } |
| | | // ② 代码映射(兜底:实测 HIS 侧改过名,代码更稳定) |
| | | Integer byCode = mapCheckCodeToType(jcxmCode); |
| | | if (byCode != null) { |
| | | log.info("[resolveBookCheckTypeFromZdy][名称未映射,已按检查代码兜底] " |
| | | + "applyNo={} jcxmname={} jcxmcode={} bookCheckType={}", |
| | | applyNo, jcxmName, jcxmCode, byCode); |
| | | return byCode; |
| | | } |
| | | |
| | | log.warn("[resolveBookCheckTypeFromZdy][检查项目名称与代码均无法映射,book_check_type 保持原值] " |
| | | + "applyNo={} jcxmname={} jcxmcode={}", applyNo, jcxmName, jcxmCode); |
| | | return null; |
| | | } |
| | | |
| | | /** 检查项目名称 → 本地检查类型(支持逗号分隔多值,取第一个可映射的) */ |
| | | private Integer mapCheckNameToType(String rawName) { |
| | | if (StringUtils.isEmpty(rawName)) { |
| | | return null; |
| | | } |
| | | for (String candidate : splitMultiValue(rawName)) { |
| | | // 去空白:HIS 报文里偶见「平板运动试验 」这类尾随空格 |
| | | String mapped = HisCheckNameEnum.getByName(candidate.replaceAll("\\s+", "")); |
| | | Integer value = parseIntegerQuietly(mapped); |
| | | if (value != null) { |
| | | return value; |
| | | } |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | /** HIS 检查代码 → 本地检查类型(支持逗号分隔多值;兼容带/不带 Y 前缀) */ |
| | | private Integer mapCheckCodeToType(String rawCode) { |
| | | if (StringUtils.isEmpty(rawCode)) { |
| | | return null; |
| | | } |
| | | for (String candidate : splitMultiValue(rawCode)) { |
| | | String code = candidate.replaceAll("\\s+", ""); |
| | | if (code.isEmpty()) { |
| | | continue; |
| | | } |
| | | // 兼容两种报文形态:带前缀(Y1202065)与不带前缀(201652) |
| | | String[] tries = code.length() > 1 |
| | | ? new String[]{code, code.substring(1)} |
| | | : new String[]{code}; |
| | | for (String t : tries) { |
| | | Integer value = parseIntegerQuietly(HisCheckCodeEnum.getByCode(t)); |
| | | if (value != null) { |
| | | return value; |
| | | } |
| | | } |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * 拆分「可能的多值」字段。 |
| | | * <p> |
| | | * 先返回**整体**(覆盖单值场景,也避免把名称里本就含逗号的项拆坏), |
| | | * 再返回按 {@code ,} / {@code ,} 拆出的各段。 |
| | | */ |
| | | private List<String> splitMultiValue(String raw) { |
| | | List<String> result = new ArrayList<>(); |
| | | if (StringUtils.isEmpty(raw)) { |
| | | return result; |
| | | } |
| | | result.add(raw); |
| | | for (String part : raw.split("[,,]")) { |
| | | String p = part == null ? "" : part.trim(); |
| | | if (!p.isEmpty() && !p.equals(raw)) { |
| | | result.add(p); |
| | | } |
| | | } |
| | | return result; |
| | | } |
| | | |
| | | /** {@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} 导致**整个事务回滚**。 |
| | | * |
| | | * <p><b>血泪教训</b>: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<String, Object> root, String... path) { |
| | | Object cur = root; |
| | | for (String key : path) { |
| | | if (!(cur instanceof Map)) { |
| | | return Collections.emptyList(); |
| | | } |
| | | cur = ((Map<String, Object>) cur).get(key); |
| | | } |
| | | return cur instanceof List ? (List<?>) cur : Collections.emptyList(); |
| | | } |
| | | |
| | | /** |
| | | * 类型安全的层级导航:逐级取 Map 子节点。 |
| | | * |
| | | * <p><b>为什么需要它</b>:HIS 报文经 Jackson 反序列化后,同一字段的类型**并不稳定**。 |
| | | * 例如 {@code observationRequest.code} 在部分报文中是 {@code Map}({@code code={code=7,...}}), |
| | | * 在另一些报文中是**空字符串**({@code code=})。若用 |
| | | * {@code .map(m -> (Map<String,Object>) m.get("code"))} 这种链式强制转换, |
| | | * 遇到字符串会抛 {@code ClassCastException},**导致整条消息处理中断**。 |
| | | * |
| | | * <p>实测生产日志 2026-09-28 中该类异常出现 **287 次**,正是由此写法引起 |
| | | * ({@code AppointmentServiceImpl.lambda$extractCheckCodeFromComponent2})。 |
| | | * |
| | | * @param root 起始节点 |
| | | * @param path 依次要取的键 |
| | | * @return 末端节点;任一环节不是 Map 或缺失时返回 null(**不抛异常**) |
| | | */ |
| | | @SuppressWarnings("unchecked") |
| | | private static Map<String, Object> dig(Map<String, Object> root, String... path) { |
| | | Object cur = root; |
| | | for (String key : path) { |
| | | if (!(cur instanceof Map)) { |
| | | return null; |
| | | } |
| | | cur = ((Map<String, Object>) cur).get(key); |
| | | } |
| | | return cur instanceof Map ? (Map<String, Object>) 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"; |
| | | |
| | | /** |
| | | * HIS「开单时间」({@code author/time/value}) 的时间格式。 |
| | | * 注意:{@link SimpleDateFormat} **不是线程安全**的,故这里只保存 pattern 字符串, |
| | | * 每次都新建实例(见 {@link #parseHisDoctorTime})。 |
| | | */ |
| | | private static final String HIS_DOCTOR_TIME_PATTERN = "yyyyMMddHHmmss"; |
| | | |
| | | /** |
| | | * 判断 HIS 开单时间是否为「日期值」(形如 {@code 20261001000000},时分秒全 0)。 |
| | | * |
| | | * <p>用 {@code endsWith} 而不是正则:语义直白且无转义坑 |
| | | * (曾写过 {@code value.matches("\\d{6}000000")},因转义层级出错导致永不匹配)。 |
| | | */ |
| | | private boolean isHisTimeOnlyDate(String value) { |
| | | if (value == null || value.length() != 14) { |
| | | return false; |
| | | } |
| | | // 后 6 位(HHmmss)全为 '0' 即为「日期值」形态 |
| | | return value.endsWith("000000"); |
| | | } |
| | | |
| | | /** |
| | | * 在 HL7 的 {@code id} 节点中,按 {@code root} 匹配并取出 {@code extension}。 |
| | | * <p> |
| | | * 兼容三种真实形态(均已在报文/文档中出现): |
| | | * <pre> |
| | | * id={item={root=..., extension=...}} // 单个 Map |
| | | * id={item=[{root=..., extension=...}, {...}]} // List(多标识,须按 root 区分) |
| | | * {root=..., extension=...} // 直接就是标识本身 |
| | | * </pre> |
| | | * <p> |
| | | * <b>为什么不复用 {@link #digText}</b>:{@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<String, Object>) idNode).get("item") : idNode; |
| | | if (item == null) { |
| | | return null; |
| | | } |
| | | List<Object> candidates = new ArrayList<>(); |
| | | if (item instanceof List) { |
| | | candidates.addAll((List<Object>) item); |
| | | } else { |
| | | candidates.add(item); |
| | | } |
| | | for (Object cand : candidates) { |
| | | if (!(cand instanceof Map)) { |
| | | continue; |
| | | } |
| | | Map<String, Object> m = (Map<String, Object>) 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)。 |
| | | * |
| | | * <h4>字段来源</h4> |
| | | * {@code controlActProcess.subject.observationRequest.zdy.ISCB}, |
| | | * 取值 {@code 0=否 1=是}。 |
| | | * |
| | | * <p>该字段**在 HIS 官方《交互服务通用接口文档》中未定义** —— 文档里 25 个 |
| | | * {@code zdy} 块共 19 个字段,实测真实报文却有 26 个,{@code ISCB} 属其中 |
| | | * 7 个未公开扩展字段之一({@code ISCB/ISDN/BLLSH/SWCJBS/KDYSSJH/KLX/VTEGWBZ})。 |
| | | * 实测样本中 {@code ISCB} 恒为 0。 |
| | | * |
| | | * <h4>与「床边检查类型」的区别</h4> |
| | | * {@code check_type.value=1000} 的名称是"床边心电图(常规+频谱)",那是**一个检查项目**; |
| | | * 本标记表达的是**同一项目、患者需到床边做**,两者语义不同,不可互相替代。 |
| | | * |
| | | * <p>抽成静态方法是为了可单元测试(见 {@code HisParseSafetyTest})。 |
| | | * |
| | | * @param dataMap HIS 报文根节点 |
| | | * @return {@code "0"}/{@code "1"};字段缺失或为空时返回 null |
| | | */ |
| | | static String findBedsideFlag(Map<String, Object> dataMap) { |
| | | if (dataMap == null) { |
| | | return null; |
| | | } |
| | | Object cap = dataMap.get("controlActProcess"); |
| | | if (!(cap instanceof Map)) { |
| | | return null; |
| | | } |
| | | Object subject = ((Map<String, Object>) cap).get("subject"); |
| | | if (!(subject instanceof Map)) { |
| | | return null; |
| | | } |
| | | Object obs = ((Map<String, Object>) subject).get("observationRequest"); |
| | | if (!(obs instanceof Map)) { |
| | | return null; |
| | | } |
| | | Object zdy = ((Map<String, Object>) obs).get("zdy"); |
| | | if (!(zdy instanceof Map)) { |
| | | return null; |
| | | } |
| | | Object iscb = ((Map<String, Object>) 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<String, Object> root, String... path) { |
| | | Object cur = root; |
| | | for (int i = 0; i < path.length; i++) { |
| | | if (cur instanceof Map) { |
| | | cur = ((Map<String, Object>) 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<String, Object>) list.get(0)).get(path[i]); |
| | | } else { |
| | | return null; |
| | | } |
| | | } |
| | | if (cur == null) { |
| | | return null; |
| | | } |
| | | if (cur instanceof Map) { |
| | | Map<String, Object> m = (Map<String, Object>) 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<String, Object> dataMap) { |
| | | // 类型安全:code 可能是 Map,也可能是空字符串 |
| | | return digText(dataMap, "controlActProcess", "subject", "placerGroup", "component2", |
| | | "observationRequest", "code", "code"); |
| | | } |
| | | |
| | | /** 从报文的 {@code component2.observationRequest.location...} 取执行科室编码(可为空) */ |
| | | private String extractDeptCodeFromComponent2(Map<String, Object> dataMap) { |
| | | return digText(dataMap, "controlActProcess", "subject", "placerGroup", "component2", |
| | | "observationRequest", "location", "serviceDeliveryLocation", |
| | | "serviceProviderOrganization", "id", "item", "extension"); |
| | | } |
| | | |
| | | /** |
| | | * 从 dataMap 中提取 encounter 数据。 |
| | | * |
| | | * <p>报文路径:{@code controlActProcess.subject.observationRequest.componentOf1.encounter}。 |
| | | * |
| | | * <p><b>注意</b>:{@code componentOf1} 位于 {@code observationRequest} 之下, |
| | | * 与 {@code id}、{@code code}、{@code component2} 同级;**不是** {@code subject} 的直接子节点。 |
| | | * 构造测试报文或解析日志时容易搞错层级,此处逐级判空以便定位。 |
| | | */ |
| | | @SuppressWarnings("unchecked") |
| | | private Map<String, Object> extractEncounter(Map<String, Object> dataMap) { |
| | | return Optional.ofNullable(dataMap.get("controlActProcess")).map(controlActProcess -> (Map<String, Object>) controlActProcess).map(controlActProcess -> (Map<String, Object>) controlActProcess.get("subject")).map(subject -> (Map<String, Object>) subject.get("observationRequest")).map(observationRequest -> (Map<String, Object>) observationRequest.get("componentOf1")).map(componentOf1 -> (Map<String, Object>) componentOf1.get("encounter")).orElse(null); |
| | | Map<String, Object> cap = (Map<String, Object>) dataMap.get("controlActProcess"); |
| | | if (cap == null) { |
| | | return null; |
| | | } |
| | | Map<String, Object> subject = (Map<String, Object>) cap.get("subject"); |
| | | if (subject == null) { |
| | | return null; |
| | | } |
| | | Map<String, Object> observationRequest = (Map<String, Object>) subject.get("observationRequest"); |
| | | if (observationRequest == null) { |
| | | return null; |
| | | } |
| | | Object componentOf1 = observationRequest.get("componentOf1"); |
| | | if (componentOf1 == null) { |
| | | return null; |
| | | } |
| | | return (Map<String, Object>) ((Map<String, Object>) componentOf1).get("encounter"); |
| | | } |
| | | |
| | | /** |
| | | * 检查预约状态新增/更新 |
| | | * (action: {@code S050401}/{@code S050402} 新增、{@code S050501}/{@code S050502} 更新)。 |
| | | * |
| | | * <h3>本次改动</h3> |
| | | * <ol> |
| | | * <li><b>删除死代码</b>:原实现在记录不存在时调用 {@code handleAppointmentCreate(dataMap)}, |
| | | * 但本服务的报文结构是 {@code subject.actAppointment},而 create 期望 |
| | | * {@code subject.observationRequest} —— 两者完全不兼容,{@code extractEncounter} |
| | | * 必然返回 null 并抛异常;原紧随其后的 {@code log.warn("未找到预约记录")} |
| | | * 因此**永远不会执行**。现改为明确记日志 + 容错新增。</li> |
| | | * <li><b>状态归一化</b>:{@code ZDY.statusCode} 是**预约状态**(如 1=已预约), |
| | | * 与 {@code S0405} 的**检查状态编码**语义不同,但同写入 {@code status} 列。 |
| | | * 这里统一走 {@link HisCheckStatusEnum#normalize(String)} 校验。</li> |
| | | * <li><b>容错新增</b>(Q4 要求):记录不存在时记 WARN 并依据本报文插入新记录。</li> |
| | | * <li><b>启用未使用字段</b>(Q7 要求):{@code ZDY.remark}(注意事项)、 |
| | | * {@code ZDY.area}(位置)、{@code ZDY.roomcode}/{@code roomname}(房间)、 |
| | | * {@code patient.id.item[4]}(预约排序号)。</li> |
| | | * <li><b>空值防御</b>:原实现直接 {@code LocalDateTime.parse(bookTimeStr)} |
| | | * 与 {@code Integer.parseInt(bookSrc)},报文缺字段时会抛异常导致整条消息失败。</li> |
| | | * </ol> |
| | | */ |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public void handleCheckAppointmentUpdate(Map<String, Object> dataMap) { |
| | | Map<String, Object> controlActProcess = (Map<String, Object>) dataMap.get("controlActProcess"); |
| | | if (controlActProcess == null) { |
| | | throw exception(HIS_MESSAGE_PARSE_FAIL); |
| | | } |
| | | Map<String, Object> subject = (Map<String, Object>) controlActProcess.get("subject"); |
| | | Map<String, Object> actAppointment = subject == null ? null |
| | | : (Map<String, Object>) 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<String, Object> zdyNode = (Map<String, Object>) actAppointment.get("ZDY"); |
| | | String checkDeptCode = extractDeptCodeFromCheckAppointment(actAppointment); |
| | | if (!hisEcgFilter.isEcgOrderWithZdy("S050401/S050501", applyNo, checkDeptCode, zdyNode)) { |
| | | return; |
| | | } |
| | | |
| | | // 2. 解析预约时间(可能为空,原实现会直接抛异常) |
| | | Map<String, Object> zdy = zdyNode; |
| | | Map<String, Object> effectiveTime = (Map<String, Object>) actAppointment.get("effectiveTime"); |
| | | Map<String, Object> anyTime = effectiveTime == null ? null |
| | | : (Map<String, Object>) 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<String, Object> code = (Map<String, Object>) 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)); |
| | | } |
| | | // 检查类型:**仅在为空时补** |
| | | // 策略 (a):不覆盖已有值,避免改坏数据(例如该单曾由 S0201ECG 正确写入过类型)。 |
| | | // 与容错新增分支保持同一逻辑。 |
| | | if (appointment.getBookCheckType() == null) { |
| | | Integer checkTypeValue = resolveBookCheckTypeFromZdy(zdy, applyNo); |
| | | if (checkTypeValue != null) { |
| | | appointment.setBookCheckType(checkTypeValue); |
| | | } |
| | | } |
| | | } |
| | | // 预约排序号(patient.id.item[4]) |
| | | Integer seqNum = extractBookSeqNum(actAppointment); |
| | | if (seqNum != null) { |
| | | appointment.setBookSeqNum(seqNum); |
| | | } |
| | | appointmentMapper.updateById(appointment); |
| | | } |
| | | |
| | | /** |
| | | * 取检查预约报文中的执行/申请科室编码。 |
| | | * <p> |
| | | * 报文路径:{@code actAppointment.subject.patient.providerOrganization.id.item.extension} |
| | | * (实测样例 {@code ZY012})。仅用于心电判定的"依据 C"(需配置白名单才生效)。 |
| | | */ |
| | | private String extractDeptCodeFromCheckAppointment(Map<String, Object> actAppointment) { |
| | | return digText(actAppointment, "subject", "patient", "providerOrganization", "id", |
| | | "item", "extension"); |
| | | } |
| | | |
| | | /** |
| | | * 预约排序号在报文中的 root 标识。 |
| | | * <p> |
| | | * 接口文档 6.4 的示例里把「预约排序号」注在 {@code item[4]} 位置, |
| | | * 但**实际报文的 root 与位置不一致**:真实报文(2026-09-17 日志)为 |
| | | * <pre> |
| | | * 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 预约排序号 ← 实际在此 |
| | | * </pre> |
| | | * 因此**按 root 匹配**而非按位置取值,避免位置漂移导致取错字段。 |
| | | */ |
| | | private static final String ROOT_BOOK_SEQ_NUM = "2.16.156.10011.2.5.1.11"; |
| | | |
| | | /** |
| | | * 取「预约排序号」。 |
| | | * <p> |
| | | * 报文路径:{@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<String, Object> actAppointment) { |
| | | List<?> items = asList(actAppointment, "subject", "patient", "id", "item"); |
| | | for (Object it : items) { |
| | | if (!(it instanceof Map)) { |
| | | continue; |
| | | } |
| | | Map<String, Object> item = (Map<String, Object>) 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; |
| | | } |
| | | |
| | | /** |
| | | * 取检查预约报文的申请单号。 |
| | | * <p> |
| | | * 优先 {@code referencedOrder[0].actOrder.id.extension}(与 S0201ECG 创建的申请单号一致, |
| | | * 实测 {@code 2126091700001});取不到时回退到 {@code actAppointment.id.extension} |
| | | * (接口文档定义的预约号,实测 {@code 3360270})。 |
| | | */ |
| | | @SuppressWarnings("unchecked") |
| | | private String extractApplyNoFromCheckAppointment(Map<String, Object> actAppointment) { |
| | | Object referencedOrders = actAppointment.get("referencedOrder"); |
| | | if (referencedOrders instanceof List) { |
| | | List<?> list = (List<?>) referencedOrders; |
| | | if (!list.isEmpty() && list.get(0) instanceof Map) { |
| | | Map<String, Object> first = (Map<String, Object>) list.get(0); |
| | | Map<String, Object> actOrder = (Map<String, Object>) first.get("actOrder"); |
| | | if (actOrder != null) { |
| | | Map<String, Object> id = (Map<String, Object>) actOrder.get("id"); |
| | | if (id != null && id.get("extension") != null) { |
| | | return id.get("extension").toString(); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | Map<String, Object> id = (Map<String, Object>) 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<String, Object> 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); |
| | | |
| | | // 患者来源(pat_src):与 S0201ECG 保持同一做法 —— 直接取报文中的患者类型代码。 |
| | | // |
| | | // ⚠️ 已知风险(保留现状,不在本次改动范围内): |
| | | // 实测 S050401 报文对本条体检单给出的是 code=3 / displayName=体检, |
| | | // 而本地字典(ecg_patient_source)与数据分布都表明「体检 = 4、住院 = 3」: |
| | | // 全库 pat_src=4 的 55,729 条科室均为「健康管理中心」,88 前缀(体检)单 55,874 条为 pat_src=4。 |
| | | // 即报文 code 与本地字典编码并不一致。此处**刻意沿用报文原值**以与 S0201ECG 行为一致, |
| | | // 如需按名称纠正(体检→4),应统一在两处一起改,避免两条流写出不同值。 |
| | | Map<String, Object> codeNode = (Map<String, Object>) actAppointment.get("code"); |
| | | Integer patSrcValue = codeNode == null ? null : parseIntegerQuietly(str(codeNode.get("code"))); |
| | | if (patSrcValue != null) { |
| | | appointment.setPatSrc(patSrcValue); |
| | | } else { |
| | | log.warn("[insertAppointmentFromCheckAppointment][报文缺患者类型(actAppointment.code.code)," |
| | | + "pat_src 将为空] applyNo={} code={}", applyNo, codeNode); |
| | | } |
| | | |
| | | Map<String, Object> zdy = (Map<String, Object>) 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)); |
| | | } |
| | | // 检查类型:报文里的 ZDY.jcxmcode / ZDY.jcxmname |
| | | // |
| | | // 【修复】原先此处**完全没有**解析检查类型,导致「S050401 先到、且尚无 S0201ECG 记录」 |
| | | // 时容错新增出来的 appointment.book_check_type 永远为空。 |
| | | // 实测 2026-09-29~10-09 共 279 条(如 apply_no=8826092420371106, |
| | | // 报文 ZDY 里 jcxmcode=201652 / jcxmname=平板运动试验 明明存在)。 |
| | | if (appointment.getBookCheckType() == null) { |
| | | Integer checkTypeValue = resolveBookCheckTypeFromZdy(zdy, applyNo); |
| | | if (checkTypeValue != null) { |
| | | appointment.setBookCheckType(checkTypeValue); |
| | | } |
| | | } |
| | | } |
| | | // 预约排序号(patient.id.item[4]) |
| | | Integer bookSeqNum = extractBookSeqNum(actAppointment); |
| | | if (bookSeqNum != null) { |
| | | appointment.setBookSeqNum(bookSeqNum); |
| | | } |
| | | |
| | | // 患者信息 |
| | | Map<String, Object> subjectNode = (Map<String, Object>) actAppointment.get("subject"); |
| | | if (subjectNode != null) { |
| | | Map<String, Object> patient = (Map<String, Object>) subjectNode.get("patient"); |
| | | if (patient != null) { |
| | | List<?> patIdItems = Optional.ofNullable(patient.get("id")) |
| | | .map(id -> (Map<String, Object>) 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<String, Object>) patIdItems.get(1)).get("extension")); |
| | | } |
| | | if (patIdItems.size() > 2) { |
| | | appointment.setOutpatientNo((String) ((Map<String, Object>) patIdItems.get(2)).get("extension")); |
| | | } |
| | | if (patIdItems.size() > 3) { |
| | | appointment.setHospitalNo((String) ((Map<String, Object>) patIdItems.get(3)).get("extension")); |
| | | } |
| | | // 就诊流水号(episode_id):patient.id.item[5] |
| | | // |
| | | // 与 S0201ECG 同源(那边取自 placerGroup.componentOf1.encounter.id.item[1], |
| | | // 实测两处值相同:本条均为 882609242037)。 |
| | | // 【修复】原先容错新增**完全没有**写入就诊流水号,导致该字段为空。 |
| | | if (patIdItems.size() > 5) { |
| | | String episodeId = (String) ((Map<String, Object>) patIdItems.get(5)).get("extension"); |
| | | if (StringUtils.isNotEmpty(episodeId)) { |
| | | appointment.setEpisodeId(episodeId); |
| | | } |
| | | } |
| | | // 预约排序号已在上方通过 extractBookSeqNum 统一赋值,此处不再重复处理 |
| | | // 患者姓名 |
| | | Optional.ofNullable(patient.get("patientPerson")) |
| | | .map(p -> (Map<String, Object>) p) |
| | | .map(p -> (Map<String, Object>) p.get("name")) |
| | | .map(n -> (Map<String, Object>) n.get("item")) |
| | | .map(i -> (Map<String, Object>) i.get("part")) |
| | | .map(part -> (String) part.get("value")) |
| | | .ifPresent(appointment::setPatName); |
| | | // 病人科室(providerOrganization)—— Q7 启用 |
| | | Optional.ofNullable(patient.get("providerOrganization")) |
| | | .map(org -> (Map<String, Object>) org) |
| | | .ifPresent(org -> { |
| | | Optional.ofNullable(org.get("id")) |
| | | .map(id -> (Map<String, Object>) id) |
| | | .map(id -> (Map<String, Object>) id.get("item")) |
| | | .map(item -> (Map<String, Object>) item) |
| | | .map(item -> (String) item.get("extension")) |
| | | .ifPresent(appointment::setPatDeptCode); |
| | | Optional.ofNullable(org.get("name")) |
| | | .map(n -> (Map<String, Object>) n) |
| | | .map(n -> (Map<String, Object>) n.get("item")) |
| | | .map(i -> (Map<String, Object>) 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); |
| | | } |
| | | // 患者固有属性回填(生日 / 电话 / 身份证 / 地址) |
| | | // |
| | | // 【为什么需要】S050401 报文的 patient.patientPerson **只带姓名**, |
| | | // 不含生日/电话/身份证(S0201ECG 有、S0405 也有,但这两条报文在本场景没有先到), |
| | | // 于是容错新增出来的记录这些字段全为空,与 S0201ECG 建的单相比"缺字段"。 |
| | | // |
| | | // 【为什么这样补是准确的】这些是**患者固有属性**(不是流水属性), |
| | | // 同一 pat_id 的历史记录里必然一致(全库填充率 99%)。 |
| | | // 注意:开单医生/开单时间属流水属性,**刻意不继承**(那属于推断,可能张冠李戴)。 |
| | | inheritPatientDemographics(appointment, applyNo); |
| | | |
| | | AppointmentSaveReqVO saveReqVO = BeanUtils.toBean(appointment, AppointmentSaveReqVO.class); |
| | | createAppointment(saveReqVO); |
| | | } |
| | | |
| | | /** |
| | | * 容错新增时,从该患者最近一条记录继承**患者固有属性**。 |
| | | * |
| | | * <p>只补「本次为空」的字段,绝不覆盖报文已提供的值。 |
| | | * 继承失败(无历史记录 / 查询异常)不影响落库,仅记日志。</p> |
| | | * |
| | | * <p><b>刻意不继承</b>的字段:{@code doctor}/{@code doctor_code}/{@code doctor_time} |
| | | * (开单医生与开单时间属流水属性,报文未提供时只能推断,实测同患者同日一致率 90%, |
| | | * 存在张冠李戴风险,故按业务确认保持为空)、{@code register_date} |
| | | * (只应在 HIS 报「检查登记」状态时写入)。</p> |
| | | * |
| | | * @param target 本次容错新增的对象(就地补全) |
| | | * @param applyNo 申请单号,仅用于日志 |
| | | */ |
| | | private void inheritPatientDemographics(AppointmentDO target, String applyNo) { |
| | | if (StringUtils.isEmpty(target.getPatId())) { |
| | | return; |
| | | } |
| | | // 三项关键人口学信息报文都给了就无需继承(报文值优先) |
| | | if (target.getPatBirthday() != null |
| | | && StringUtils.isNotEmpty(target.getPatIdentityId()) |
| | | && StringUtils.isNotEmpty(target.getPatMobile())) { |
| | | return; |
| | | } |
| | | AppointmentDO sibling; |
| | | try { |
| | | sibling = appointmentMapper.getLatestByPatId(target.getPatId()); |
| | | } catch (Exception e) { |
| | | log.warn("[inheritPatientDemographics][查询同患者历史记录失败,跳过继承] applyNo={} patId={} err={}", |
| | | applyNo, target.getPatId(), e.getMessage()); |
| | | return; |
| | | } |
| | | if (sibling == null) { |
| | | log.warn("[inheritPatientDemographics][该患者无历史记录可继承,患者信息将不完整] " |
| | | + "applyNo={} patId={}", applyNo, target.getPatId()); |
| | | return; |
| | | } |
| | | StringBuilder inherited = new StringBuilder(); |
| | | if (target.getPatBirthday() == null && sibling.getPatBirthday() != null) { |
| | | target.setPatBirthday(sibling.getPatBirthday()); |
| | | inherited.append("pat_birthday "); |
| | | } |
| | | if (StringUtils.isEmpty(target.getPatIdentityId()) && StringUtils.isNotEmpty(sibling.getPatIdentityId())) { |
| | | target.setPatIdentityId(sibling.getPatIdentityId()); |
| | | inherited.append("pat_identity_id "); |
| | | } |
| | | if (StringUtils.isEmpty(target.getPatMobile()) && StringUtils.isNotEmpty(sibling.getPatMobile())) { |
| | | target.setPatMobile(sibling.getPatMobile()); |
| | | inherited.append("pat_mobile "); |
| | | } |
| | | if (StringUtils.isEmpty(target.getPatPhone()) && StringUtils.isNotEmpty(sibling.getPatPhone())) { |
| | | target.setPatPhone(sibling.getPatPhone()); |
| | | inherited.append("pat_phone "); |
| | | } |
| | | if (StringUtils.isEmpty(target.getPatAddr()) && StringUtils.isNotEmpty(sibling.getPatAddr())) { |
| | | target.setPatAddr(sibling.getPatAddr()); |
| | | inherited.append("pat_addr "); |
| | | } |
| | | if (inherited.length() > 0) { |
| | | log.info("[inheritPatientDemographics][S050401 报文不含患者人口学信息," |
| | | + "已从同患者最近记录继承] applyNo={} patId={} fromApplyNo={} fields=[{}]", |
| | | applyNo, target.getPatId(), sibling.getApplyNo(), inherited.toString().trim()); |
| | | } |
| | | } |
| | | |
| | | /** 解析 {@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; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 解析 HIS 报文中的「开单时间」({@code author/time/value})。 |
| | | * |
| | | * <h3>背景(实测结论,2026-10 排查)</h3> |
| | | * 库中约 <b>61%</b> 的 {@code appointment.doctor_time} 为 {@code yyyy-MM-dd 00:00:00}。 |
| | | * 经核查**不是**本项目的格式化缺陷: |
| | | * <ul> |
| | | * <li>列类型是 {@code datetime},写入的是 {@code java.util.Date},保留时分秒;</li> |
| | | * <li>真实报文样例中该字段形如 {@code 20260917005255}(带时分秒);</li> |
| | | * <li>有真实时刻的记录呈正常工作时段分布(06:00→08:00 为高峰), |
| | | * 而落在开诊前的 00:00-05:59 的仅 15 条 —— 说明该时段本就几乎不开单;</li> |
| | | * <li>按就诊类别看:门诊({@code book_src=1}) 99.6%、住院({@code book_src=2}) 90.5% |
| | | * 为 0 点,且该现象贯穿 2026 全年(与 2026-09-27 的 {@code register_date} |
| | | * 截断缺陷无关)。</li> |
| | | * </ul> |
| | | * ⇒ 结论:**是 HIS 侧把该字段的时分秒发成了 {@code 000000}**(很可能是「只精确到日」 |
| | | * 或补录/批量回传的医嘱)。 |
| | | * |
| | | * <h3>本方法做什么</h3> |
| | | * <ul> |
| | | * <li>按 {@code yyyyMMddHHmmss} 解析;</li> |
| | | * <li>识别「时分秒 = 000000」的**日期值**,打出带**原始报文值 + 申请单号**的 WARN |
| | | * —— 原实现只 {@code printStackTrace()},出问题时无从定位;</li> |
| | | * <li><b>仍然写入</b>(保留日期部分):因为「距今天数」「按日期排序/筛选」都用日期, |
| | | * 写入 NULL 会让这些功能退化,且该值确为 HIS 所发、原样保存更可追溯。</li> |
| | | * </ul> |
| | | * |
| | | * @param rawValue HIS 报文原值(形如 {@code 20260917005255}) |
| | | * @param applyNo 申请单号,仅用于日志定位 |
| | | * @return 解析后的时间;格式非法时返回 {@code null}(不写库) |
| | | */ |
| | | private java.util.Date parseHisDoctorTime(String rawValue, String applyNo) { |
| | | String v = rawValue == null ? null : rawValue.trim(); |
| | | if (StringUtils.isEmpty(v)) { |
| | | return null; |
| | | } |
| | | if (!v.matches("\\d{14}")) { |
| | | // 实测 HIS 文档样例中存在 12 位(yyyyMMddHHmm)甚至空值,统一记 WARN 便于向 HIS 反馈 |
| | | log.warn("[parseHisDoctorTime][开单时间格式非 14 位,无法解析,doctor_time 将为空] " |
| | | + "applyNo={} raw={} length={}", applyNo, v, v.length()); |
| | | return null; |
| | | } |
| | | try { |
| | | // ⚠️ 必须 setLenient(false):SimpleDateFormat 默认宽松, |
| | | // 会把非法日期静默滚成合法值(如 13 月 → 次年 1 月),导致脏数据入库。 |
| | | // 实测:lenient 下 "20261301000000" 被解析成 2027-01-01 且不报错。 |
| | | SimpleDateFormat sdf = new SimpleDateFormat(HIS_DOCTOR_TIME_PATTERN); |
| | | sdf.setLenient(false); |
| | | java.util.Date parsed = sdf.parse(v); |
| | | if (isHisTimeOnlyDate(v)) { |
| | | log.warn("[parseHisDoctorTime][HIS 开单时间时分秒为 000000(日期值)," |
| | | + "doctor_time 将保存为当日 00:00:00 —— 该值由 HIS 报文原样而来] " |
| | | + "applyNo={} raw={}", applyNo, v); |
| | | } |
| | | return parsed; |
| | | } catch (Exception e) { |
| | | log.warn("[parseHisDoctorTime][开单时间解析失败,doctor_time 将为空] applyNo={} raw={} err={}", |
| | | applyNo, v, e.getMessage()); |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 由 {@code book_timeslot}(hhmmhhmm,如 8000830=08:00-08:30、13301400=13:30-14:00)取预约时刻(只含时分)。 |
| | | * <p>用来给 S0201ECG 这条流补 {@code book_time}:原实现整条流不写预约时间, |
| | | * 建出来的记录 book_date/book_timeslot/book_time 全空(实测 211741、211962)。</p> |
| | | */ |
| | | private LocalTime timeOfTimeslot(Integer timeslot) { |
| | | if (timeslot == null) { |
| | | return null; |
| | | } |
| | | try { |
| | | String s = String.valueOf(timeslot); |
| | | if (s.length() < 5) { |
| | | return null; |
| | | } |
| | | String start = s.substring(0, s.length() - 4); // 前一段 = 开始时间 hhmm |
| | | int hhmm = Integer.parseInt(start); |
| | | int hour = hhmm / 100; |
| | | int minute = hhmm % 100; |
| | | if (hour > 23 || minute > 59) { |
| | | return null; |
| | | } |
| | | return LocalTime.of(hour, minute); |
| | | } catch (Exception e) { |
| | | log.warn("[timeOfTimeslot][时间段解析失败,忽略] timeslot={}", timeslot); |
| | | 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; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 【本需求:紧急患者不排队直接检查】按「申请单号 / 就诊号 / 患者编号」查近一个月的预约记录。 |
| | | * <p> |
| | | * 窗口 [今天-30 天, 今天+30 天],按本窗口开展的检查项目过滤,结果按**预约时间倒序**。 |
| | | */ |
| | | @Override |
| | | public List<AppointmentDO> getEmergencyCandidates(String code, String checkTypes) { |
| | | if (StringUtils.isEmpty(code)) { |
| | | return new ArrayList<>(); |
| | | } |
| | | List<Integer> checkTypeList = splitToIntList(checkTypes); |
| | | List<AppointmentDO> list = appointmentMapper.getEmergencyCandidates(code.trim(), checkTypeList); |
| | | log.info("[getEmergencyCandidates] 紧急加做候选查询. code={} checkTypes={} 命中={}", |
| | | code, checkTypes, null == list ? 0 : list.size()); |
| | | return null == list ? new ArrayList<>() : list; |
| | | } |
| | | |
| | | /** 把 `100,200` 这类逗号分隔串解析成 List<Integer>;非法项忽略 */ |
| | | private List<Integer> splitToIntList(String raw) { |
| | | List<Integer> result = new ArrayList<>(); |
| | | if (StringUtils.isEmpty(raw)) { |
| | | return result; |
| | | } |
| | | for (String part : raw.split("[,,]")) { |
| | | Integer v = parseIntegerQuietly(part); |
| | | if (null != v && !result.contains(v)) { |
| | | result.add(v); |
| | | } |
| | | } |
| | | return result; |
| | | } |
| | | |
| | | /** 宽松 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 handleCheckAppointmentUpdate(Map<String, Object> dataMap) { |
| | | // 从dataMap中获取controlActProcess |
| | | public void handleAppointmentStatusUpdate(Map<String, Object> dataMap) { |
| | | if (dataMap == null) { |
| | | throw exception(APPOINTMENT_STATUS_REGISTER_UPDATE_FAIL); |
| | | } |
| | | //获取applyNo |
| | | Map<String, Object> controlActProcess = (Map<String, Object>) dataMap.get("controlActProcess"); |
| | | Map<String, Object> subject = (Map<String, Object>) controlActProcess.get("subject"); |
| | | Map<String, Object> actAppointment = (Map<String, Object>) subject.get("actAppointment"); |
| | | Map<String, Object> placerGroup = (Map<String, Object>) subject.get("placerGroup"); |
| | | Map<String, Object> component2 = (Map<String, Object>) placerGroup.get("component2"); |
| | | Map<String, Object> observationRequest = (Map<String, Object>) component2.get("observationRequest"); |
| | | Map<String, Object> id = (Map<String, Object>) observationRequest.get("id"); |
| | | Map<String, Object> item = (Map<String, Object>) id.get("item"); |
| | | String applyNo = (String) item.get("extension"); |
| | | |
| | | // 获取申请编号 |
| | | List<Map<String, Object>> referencedOrders = (List<Map<String, Object>>) actAppointment.get("referencedOrder"); |
| | | String applyNo = null; |
| | | if (!CollectionUtils.isEmpty(referencedOrders)) { |
| | | Map<String, Object> actOrder = (Map<String, Object>) referencedOrders.get(0).get("actOrder"); |
| | | Map<String, Object> id = (Map<String, Object>) actOrder.get("id"); |
| | | applyNo = (String) id.get("extension"); |
| | | //获取status |
| | | Map<String, Object> component1 = (Map<String, Object>) observationRequest.get("component1"); |
| | | Map<String, Object> processStep = (Map<String, Object>) component1.get("processStep"); |
| | | Map<String, Object> codeMap = (Map<String, Object>) processStep.get("code"); |
| | | String status = String.valueOf(codeMap.get("code")); |
| | | Date date = null; |
| | | if (status.equals("3")) { |
| | | //记录登记时间 |
| | | date = new Date(); |
| | | } |
| | | |
| | | if (applyNo == null) { |
| | | log.warn("[handleCheckAppointmentUpdate][未找到申请编号]"); |
| | | return; |
| | | // 【诊断日志】登记时间写入前后带类型信息,用于快速定位「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); |
| | | |
| | | // 根据applyNo查询预约记录 |
| | | AppointmentDO appointment = appointmentMapper.getByApplyNo(applyNo); |
| | | if (appointment == null) { |
| | | log.warn("[handleCheckAppointmentUpdate][未找到预约记录 applyNo({})]", applyNo); |
| | | return; |
| | | } |
| | | |
| | | // 获取预约时间 |
| | | Map<String, Object> effectiveTime = (Map<String, Object>) actAppointment.get("effectiveTime"); |
| | | Map<String, Object> any = (Map<String, Object>) effectiveTime.get("any"); |
| | | String bookTimeStr = (String) any.get("value"); |
| | | |
| | | // 获取预约来源 |
| | | Map<String, Object> code = (Map<String, Object>) actAppointment.get("code"); |
| | | String bookSrc = (String) code.get("code"); |
| | | |
| | | // 解析预约时间 |
| | | LocalDateTime bookTime = LocalDateTime.parse(bookTimeStr, DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); |
| | | LocalDate bookDate = bookTime.toLocalDate(); |
| | | |
| | | // 获取时间段 |
| | | String timeRange = (String) ((Map<String, Object>) actAppointment.get("ZDY")).get("time"); |
| | | String[] time1 = timeRange.split(" "); |
| | | String[] times = time1[1].split("-"); |
| | | String startTime = times[0].trim(); |
| | | String endTime = times[1].trim(); |
| | | |
| | | // 转换时间段格式为hhmmhhmm |
| | | int bookTimeslot = Integer.parseInt(startTime.replace(":", "")) * 10000 + Integer.parseInt(endTime.replace(":", "")); |
| | | |
| | | // 更新预约信息 |
| | | appointment.setBookDate(bookDate); |
| | | appointment.setBookTimeslot(bookTimeslot); |
| | | appointment.setBookTime(bookTime); |
| | | appointment.setBookSrc(Integer.parseInt(bookSrc)); |
| | | |
| | | // 保存更新 |
| | | appointmentMapper.updateById(appointment); |
| | | } |
| | | |
| | | public AppointmentDO getByApplyNo(String applyNo) { |