yxh
6 天以前 68ff33beff11310e830b0615b5a81f83fad0f683
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/appointment/AppointmentServiceImpl.java
@@ -37,6 +37,7 @@
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;
@@ -104,6 +105,79 @@
        // 返回
        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
@@ -183,6 +257,12 @@
    @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++) {
@@ -190,26 +270,47 @@
            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())));
            }
        }
@@ -315,27 +416,52 @@
        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();
@@ -348,14 +474,19 @@
            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={}",
@@ -471,6 +602,40 @@
                    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《交互服务通用接口文档》):
@@ -491,15 +656,29 @@
                }
            }
            // 医生开单时间
            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);
@@ -563,6 +742,33 @@
            // 设置申请单号
            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));
@@ -582,7 +788,17 @@
                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);
@@ -627,13 +843,12 @@
            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);
@@ -904,12 +1119,18 @@
        // 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;
        }
@@ -1062,6 +1283,121 @@
        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} 列宽上限 */
@@ -1139,6 +1475,27 @@
    /** 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}。
@@ -1442,6 +1799,15 @@
            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);
@@ -1548,6 +1914,23 @@
        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(房间编码)目前仍无对应列,暂不保存。
@@ -1559,6 +1942,18 @@
            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])
@@ -1584,6 +1979,17 @@
                }
                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 统一赋值,此处不再重复处理
                // 患者姓名
@@ -1628,8 +2034,84 @@
            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(不抛异常) */
@@ -1641,6 +2123,96 @@
            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;
        }
    }
@@ -1680,6 +2252,38 @@
        }
    }
    /**
     * 【本需求:紧急患者不排队直接检查】按「申请单号 / 就诊号 / 患者编号」查近一个月的预约记录。
     * <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&lt;Integer&gt;;非法项忽略 */
    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+")) {