| | |
| | | import java.text.SimpleDateFormat; |
| | | import java.time.LocalDate; |
| | | import java.time.LocalDateTime; |
| | | import java.time.LocalTime; |
| | | import java.time.format.DateTimeFormatter; |
| | | import java.util.*; |
| | | import java.util.stream.Collectors; |
| | |
| | | |
| | | // 返回 |
| | | 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 |
| | |
| | | @Override |
| | | public List<AppointmentDO> queryAndCreateAppointmentByPatId(String 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++) { |
| | |
| | | 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()))); |
| | | } |
| | | } |
| | | |
| | |
| | | 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; |
| | | } |
| | | |
| | | if (!DateUtils.isToday(appointment.getBookDate())) throw exception(APPOINTMENT_NOT_TODAY); |
| | | |
| | | // 【本需求】非当天预约的签到:先提醒、确认后允许签入(前端弹窗确认后带 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()); |
| | | } |
| | | |
| | | // 【需求:排队序号规则】按 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); |
| | | log.info("[appoitmentConfirm][分配排队序号] appointmentId={} checkType={} timeslot={} 规则取号={} 加急={}", |
| | | urgent, |
| | | vip); |
| | | log.info("[appoitmentConfirm][分配排队序号] appointmentId={} checkType={} timeslot={} 规则取号={} 加急={} VIP/加号={}", |
| | | appointment.getId(), confirmReqVO.getBookCheckType(), confirmReqVO.getBookTimeslot(), |
| | | newSeqNo, urgent); |
| | | 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。 |
| | | // queue 表按 "order by jump_flag desc, book_timeslot, create_time" 取号,置 1 即优先。 |
| | | // 【号统一后】加急**不改排队号**(号在下面 setSeqNum 时已经定死), |
| | | // 而是靠排序体现:分流取人、叫号取人、工作台/诊间屏/大屏列表都是 |
| | | // "jump_flag desc 优先 → seq_num 升序",置 1 即排到最前。 |
| | | if (urgent) { |
| | | queueSaveReqVO.setJumpFlag((byte) 1); |
| | | log.info("[appoitmentConfirm][HIS 加急患者,排队标记为加急] applyNo={} patId={}", |
| | |
| | | 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《交互服务通用接口文档》): |
| | |
| | | } |
| | | } |
| | | |
| | | // 医生开单时间 |
| | | 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("time")).map(time -> (String) time.get("value")).map(str -> { |
| | | try { |
| | | return new SimpleDateFormat("yyyyMMddHHmmss").parse(str); |
| | | } catch (Exception e) { |
| | | e.printStackTrace(); |
| | | return null; |
| | | // 医生开单时间(报文路径: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); |
| | | } |
| | | }).filter(Objects::nonNull).ifPresent(appointment::setDoctorTime); |
| | | } 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("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 (value != null) { |
| | | appointment.setBookCheckType(value); |
| | | } else { |
| | | log.warn("[handleAppointmentCreate][体检患者检查项目名称无法映射] checkName={}", checkName); |
| | | // 名称映射不到就退回「按 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 (appointmentDO != null) { |
| | | // 保留原记录中本次报文未提供的字段,避免被空值覆盖 |
| | | mergeUnsetFields(appointment, appointmentDO); |
| | | // 合并结果要回到 saveReqVO 上,否则更新/插入用的还是"未合并"的报文值 |
| | | saveReqVO = BeanUtils.toBean(appointment, AppointmentSaveReqVO.class); |
| | | saveReqVO.setId(appointmentDO.getId()); |
| | | updateAppointment(saveReqVO); |
| | | return appointmentDO.getId(); |
| | | } |
| | | |
| | | return createAppointment(saveReqVO); |
| | | // 交给原子落库:不存在则插入,已存在则更新;并发重复插入会被唯一索引拦下并转为更新, |
| | | // 不会再出现"同一 apply_no 两条记录"(原实现在这里"查一次再插",并发下必然漏)。 |
| | | return atomicSaveByApplyNo(saveReqVO); |
| | | } catch (Exception e) { |
| | | log.error("[handleAppointmentCreate][处理预约创建失败 dataMap({})]", dataMap, e); |
| | | throw exception(APPOINTMENT_CREATE_FAIL); |
| | |
| | | // 3. 本地记录匹配 |
| | | AppointmentDO existingAppointment = appointmentMapper.getByApplyNo(applyNo); |
| | | if (existingAppointment != null) { |
| | | existingAppointment.setStatus(status); |
| | | if (HisCheckStatusEnum.REGISTERED.getCode().toString().equals(status)) { |
| | | existingAppointment.setRegisterDate(new Date()); |
| | | } |
| | | AppointmentSaveReqVO updateReqVO = BeanUtils.toBean(existingAppointment, AppointmentSaveReqVO.class); |
| | | updateAppointment(updateReqVO); |
| | | // 状态流只允许改 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; |
| | | } |
| | | |
| | |
| | | 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} 列宽上限 */ |
| | |
| | | |
| | | /** 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}。 |
| | |
| | | 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); |
| | |
| | | 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(房间编码)目前仍无对应列,暂不保存。 |
| | |
| | | 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]) |
| | |
| | | } |
| | | 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 统一赋值,此处不再重复处理 |
| | | // 患者姓名 |
| | |
| | | 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(不抛异常) */ |
| | |
| | | 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; |
| | | } |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 【本需求:紧急患者不排队直接检查】按「申请单号 / 就诊号 / 患者编号」查近一个月的预约记录。 |
| | | * <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+")) { |