yxh
5 天以前 68ff33beff11310e830b0615b5a81f83fad0f683
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/queue/QueueServiceTxFunctions.java
@@ -65,7 +65,10 @@
    AtomicInteger openingFlag = new AtomicInteger(0);
    AtomicInteger curSeqNum = new AtomicInteger(0);
    // 【号统一(本需求)】原 curSeqNum(分流时给患者编"内部序号"的内存计数器)已删除。
    // 排队号改为签到时一次性写入 queue.seq_num,之后任何环节都不再赋号:
    // 分流、叫号后补齐候诊、过号、召回、加急、装机都只改状态/诊室/工位。
    // 维护 开放的工位
    ConcurrentHashMap<String, BedBO> mapOpeningBed = new ConcurrentHashMap<>();
@@ -255,25 +258,151 @@
        return null == bedBO ? null : Boolean.TRUE.equals(bedBO.getStopReady());
    }
    public ErrorCode bedDoctorOn(Long roomId, String bedNo, Long docId, String docName) {
    /**
     * 入座(把工位主人设为我)。
     *
     * @param bedId 工位主键(前端请求里带;**优先按它定位**,见下)——可为空,为空时按 roomId+bedNo 定位
     */
    public ErrorCode bedDoctorOn(Long roomId, String bedNo, Long docId, String docName, Integer bedId) {
        BedBO bedBO = mapOpeningBed.get( Utils.formatRoomBed(roomId, bedNo) );
        if (null == bedBO) {
            log.error("bedDoctorOn mapBedVsQueue DONOT existed. " + roomId + " " + bedNo);
            return QUEUE_BED_NOT_EXIST;
        }
        // DB update
        List statusList = new ArrayList<BedStatusEnum>();
        statusList.add(BedStatusEnum.OPENING);
        Integer updateNum = roomMapper.setBedDoctorOn(roomId, bedNo, docId, docName, BedStatusEnum.DOCTOR_ON, statusList);
        // 可以"成为主人"的状态:已开通 / 有医生 / 暂停(**排除"已关闭"**)。
        // 【本次放宽】原来"无主人"时只认"已开通":一旦数据出现"doc_id 为空但状态仍是有医生/暂停"
        // 这种不一致,医生站在工位前却始终坐不上,只报"状态异常",现场无从处理。
        List<BedStatusEnum> claimableStatusList = new ArrayList<>();
        claimableStatusList.add(BedStatusEnum.OPENING);
        claimableStatusList.add(BedStatusEnum.DOCTOR_ON);
        claimableStatusList.add(BedStatusEnum.DOCTOR_PAUSE);
        // 【本次修复】优先按**主键**更新:现场出现过"工位在库里明明空闲、入座却更新 0 行",
        // 已排除"被占用";剩下的可能是 bed_no 与请求值有肉眼难辨的差异
        // (结尾空格 / 大小写 / 全角,JSON 与复制粘贴都看不出来)。前端本来就带 id,用它最稳。
        Integer updateNum = null;
        if (null != bedId) {
            updateNum = roomMapper.setBedDoctorOnById(bedId, docId, docName,
                    BedStatusEnum.DOCTOR_ON, claimableStatusList);
            if (null != updateNum && updateNum > 0) {
                // 校验一下 id 指向的行确实就是请求的工位(不一致说明前端缓存与库不同步,记日志便于排查)
                RoomDO seatRow = roomMapper.getRoom(roomId, bedNo);
                RoomDO idRow = roomMapper.selectById(bedId);
                if (null != idRow && null != seatRow && !idRow.getId().equals(seatRow.getId())) {
                    log.warn("[bedDoctorOn] 按 id 入座成功,但 id 指向的工位与请求的 roomId/bedNo 不是同一行!"
                                    + " id={} → {}/{}, 请求 → {}/{}",
                            bedId, idRow.getRoomId(), idRow.getBedNo(), roomId, bedNo);
                }
            }
        }
        if (null == updateNum || 0 == updateNum) {
            // 回退:按 roomId + bedNo 定位(兼容不带 id 的调用方,如自动入座的老路径)
            updateNum = roomMapper.setBedDoctorOn(roomId, bedNo, docId, docName,
                    BedStatusEnum.DOCTOR_ON, claimableStatusList);
        }
        if ( null==updateNum || 0 == updateNum ) {
            log.error("bedDoctorOn DB invalid status. " + roomId + " " + bedNo);
            // 仍失败 → 把库里**真实状态**摊开,并区分原因(原来一律"状态异常",现场看不出所以然)
            RoomDO rowById = (null == bedId) ? null : roomMapper.selectById(bedId);
            RoomDO rowByRoomBed = roomMapper.getRoom(roomId, bedNo);
            log.warn("[bedDoctorOn] 入座更新 0 行. 请求 roomId={} bedNo=[{}] bedId={} docId={};"
                            + "按id查到={} 按roomId+bedNo查到={}",
                    roomId, bedNo, bedId, docId,
                    null == rowById ? "无" : describeBedRow(rowById),
                    null == rowByRoomBed ? "无" : describeBedRow(rowByRoomBed));
            RoomDO occupant = (null != rowById) ? rowById : rowByRoomBed;
            if (null != occupant && null != occupant.getDocId() && !occupant.getDocId().equals(docId)) {
                log.warn("[bedDoctorOn] 工位已被他人占用,拒绝入座(不挤掉). 占用人={}({})",
                        occupant.getDocName(), occupant.getDocId());
                return ROOM_OCCUPIED_BY_OTHER;
            }
            return ROOM_INVALID_STATUS;
        }
        // 【本需求·一人多工位】入座成功后**不再释放**该医生的其它工位:
        // 节假日值班时一位医生可能同时占「检查工位 + 领用工位 + 装机工位」,
        // 由前端「我的工位」下拉在各工位间切换(写操作仍由 @RequireBedOwner 按工位 doc_id 校验)。
        // 原实现:roomMapper.releaseOtherBedsOfDoc(docId, roomId, bedNo, BedStatusEnum.OPENING);
        // 工位释放改为三种明确途径:① 医生点「离座」;② 闭诊统一清空;③ 管理员「清空工位」/接班医生「接管」。
        bedBO.setStatus(BedStatusEnum.DOCTOR_ON.getStatus());
        addBedToRoutingQueue(bedBO);
        return GlobalErrorCodeConstants.SUCCESS;
    }
    /**
     * 手工把某位「排队中」患者拉入指定工位(工作台"本诊室排队中(未分配)"分区的「拉入」按钮)。
     *
     * <p>与自动分流({@code hurryupOneCheckType})走**同一套**动作:
     * 条件更新「排队中(10) → 准备中(20)」并把 room/bed 写成本工位,然后处理亲和项。
     * 号不变(号在签到时已定)。
     *
     * <p>三重校验:工位存在 → 该行仍是「排队中」→ 检查项目在本工位开展范围内。
     * 条件更新自带并发保护:两个工位同时点「拉入」,只有一个能成功。
     */
    public ErrorCode pullWaitingPatient(Long roomId, String bedNo, Integer queueId) {
        RoomDO bed = (null == roomId || null == bedNo) ? null : roomMapper.getRoom(roomId, bedNo);
        if (null == bed) {
            log.warn("[pullWaitingPatient] 工位不存在. roomId={} bedNo={}", roomId, bedNo);
            return QUEUE_BED_NOT_EXIST;
        }
        QueueDO item = (null == queueId) ? null : queueMapper.selectById(queueId);
        if (null == item) {
            log.warn("[pullWaitingPatient] 排队行不存在. queueId={}", queueId);
            return QUEUE_NOT_EXISTS;
        }
        if (!QueueStatusEnum.WAITING.getStatus().equals(item.getStatus())) {
            log.warn("[pullWaitingPatient] 该患者已不在排队中. queueId={} patId={} status={}",
                    queueId, item.getPatId(), item.getStatus());
            return QUEUE_NOT_WAITING;
        }
        Integer[] bedCheckTypes = bed.getCheckTypes();
        if (null == bedCheckTypes || 0 == bedCheckTypes.length
                || null == item.getBookCheckType()
                || !java.util.Arrays.asList(bedCheckTypes).contains(item.getBookCheckType())) {
            log.warn("[pullWaitingPatient] 检查项目不在本工位范围. queueId={} checkType={} 本工位={}",
                    queueId, null == item ? null : item.getBookCheckType(), java.util.Arrays.toString(bedCheckTypes));
            return QUEUE_CHECK_TYPE_NOT_MATCH;
        }
        Integer updateNum = queueMapper.preemptWaitingPatientById(queueId, roomId, bed.getRoomName(), bedNo,
                QueueStatusEnum.WAITING.getStatus(), QueueStatusEnum.READY.getStatus());
        if (null == updateNum || 0 == updateNum) {
            log.warn("[pullWaitingPatient] 拉入未生效(已被其他工位拉走). queueId={}", queueId);
            return QUEUE_NOT_WAITING;
        }
        // 内存对象与库内保持一致(亲和项要跟随主项的诊室/工位)
        item.setStatus(QueueStatusEnum.READY.getStatus());
        item.setRoomId(roomId);
        item.setRoomName(bed.getRoomName());
        item.setBedNo(bedNo);
        try {
            CheckTypeDO checkTypeDO = getCheckTypeItem(item.getBookCheckType());
            if (null != checkTypeDO && null != checkTypeDO.getAffinityCheckTypes()
                    && checkTypeDO.getAffinityCheckTypes().length > 0) {
                procAffinityWhenPreempt(item, checkTypeDO.getAffinityCheckTypes());
            }
        } catch (Exception e) {
            // 亲和项处理失败不影响主项已成功拉入
            log.error("[pullWaitingPatient] 亲和项处理失败(主项已拉入). queueId={}", queueId, e);
        }
        log.info("[pullWaitingPatient] 已手工拉入. roomId={} bedNo={} queueId={} patId={} seqNum={}",
                roomId, bedNo, queueId, item.getPatId(), item.getSeqNum());
        return GlobalErrorCodeConstants.SUCCESS;
    }
    /** 拼一行工位关键字段,供日志/排查快速对比(尤其看 bed_no 是否与请求一致) */
    private String describeBedRow(RoomDO row) {
        return String.format("id=%s,room_id=%s,bed_no=[%s](len=%d),status=%s,doc_id=%s,doc_name=%s",
                row.getId(), row.getRoomId(), row.getBedNo(),
                null == row.getBedNo() ? -1 : row.getBedNo().length(),
                row.getStatus(), row.getDocId(), row.getDocName());
    }
    public ErrorCode bedDoctorOff(Long roomId, String bedNo, Long docId, String docName) {
@@ -299,6 +428,64 @@
        return GlobalErrorCodeConstants.SUCCESS;
    }
    /**
     * 【本次需求】管理员强制清空工位(不要求是本人)。
     * <p>
     * 与 {@link #bedDoctorOff} 的区别:不清 {@code doc_id=?} 条件,
     * 因此可把「别人占用中」的工位清掉 —— 用于医生下班忘离座、换人使用同一工位的场景。
     * 仍然只清「医生在岗 / 暂停」且 doc_id 非空的工位,空闲工位不会被误动。
     */
    public ErrorCode bedDoctorOffByAdmin(Long roomId, String bedNo) {
        BedBO bedBO = mapOpeningBed.get( Utils.formatRoomBed( roomId, bedNo ) );
        if (null == bedBO) {
            log.error("bedDoctorOffByAdmin mapBedVsQueue DONOT existed. " + roomId + " " + bedNo);
            return QUEUE_BED_NOT_EXIST;
        }
        List statusList = new ArrayList<BedStatusEnum>();
        statusList.add(BedStatusEnum.DOCTOR_ON);
        statusList.add(BedStatusEnum.DOCTOR_PAUSE);
        Integer updateNum = roomMapper.setBedDoctorOffAdmin(roomId, bedNo, BedStatusEnum.OPENING, statusList);
        if ( null==updateNum || 0 == updateNum ) {
            log.error("bedDoctorOffByAdmin DB invalid status. " + roomId + " " + bedNo);
            return ROOM_INVALID_STATUS;
        }
        removeBedFromRoutingQueue(bedBO);
        bedBO.setStatus(BedStatusEnum.OPENING.getStatus());
        log.info("[管理员清空工位] roomId={} bedNo={} 已置为空闲", roomId, bedNo);
        return GlobalErrorCodeConstants.SUCCESS;
    }
    /**
     * 【本次需求】闭诊清空:把所有「医生在岗 / 暂停」的工位置为空闲(doc_id / doc_name 清空)。
     * <p>
     * 与 {@link #closeBiz()} 配合:先清库,随后 {@code startBedReload()} 会按库重新装载内存,
     * 保证内存(mapOpeningBed / 分流队列)与库一致。
     *
     * @return 清空的工位数
     */
    public int closeBizClearBeds() {
        List statusList = new ArrayList<BedStatusEnum>();
        statusList.add(BedStatusEnum.DOCTOR_ON);
        statusList.add(BedStatusEnum.DOCTOR_PAUSE);
        Integer updateNum = roomMapper.clearAllBedDoctors(BedStatusEnum.OPENING, statusList);
        // 内存同步:把工位状态改回空闲,并从分流队列摘除(避免闭诊后仍有工位被派患者)
        // 注意:status 是 Integer 包装类型,必须用 equals 比较(== 只在 -128~127 缓存区间内偶然成立)
        for (BedBO bedBO : mapOpeningBed.values()) {
            if (Objects.equals(BedStatusEnum.DOCTOR_ON.getStatus(), bedBO.getStatus())
                    || Objects.equals(BedStatusEnum.DOCTOR_PAUSE.getStatus(), bedBO.getStatus())) {
                removeBedFromRoutingQueue(bedBO);
                bedBO.setStatus(BedStatusEnum.OPENING.getStatus());
            }
        }
        log.info("[闭诊] 已清空当班工位 {} 个(doc_id / doc_name 置空,状态改为空闲)",
                updateNum == null ? 0 : updateNum);
        return updateNum == null ? 0 : updateNum;
    }
    /* 2024.11.11
    // 常规 或者 领用 时 医生 取下一位患者
    public ErrorCode nextPatient(Long roomId, String bedNo) {
@@ -321,21 +508,75 @@
    // 常规 或者 领用 时 医生 取下一位患者
    public ErrorCode nextPatient(Long roomId, String bedNo) {
        QueueDO firstReadyQueueItem = queueMapper.getBedFirstQueueItemByStatus(roomId, bedNo, QueueStatusEnum.READY.getStatus());
        // 该工位 没有 [候诊中中] 人员
        if (null == firstReadyQueueItem) {
        // ⓪ 【并发加固·本需求】本工位**已有「就诊中(30)」患者时不再叫下一位**。
        //
        // 原先这个判断只在 `QueueServiceImpl#finishNextPatient` 里做过,而它位于
        // singleThreadExecutor 串行区**之外**:两个并发请求(双击 / 多端)都可能读到 0,
        // 随后各自提交一次 nextPatient,串行执行时会各推进一条 READY ⇒
        // 同一工位出现 2 个「就诊中」,患者被"卡"在就诊中、工位也不再补人。
        // 这里在串行区内再判一次(本方法只由该串行线程池调用)。
        List<Integer> onstageStatusList = new ArrayList<>();
        onstageStatusList.add(QueueStatusEnum.ONSTAGE.getStatus());
        Integer onstageNum = queueMapper.bedQueueStatisticByStatus(roomId, bedNo, onstageStatusList);
        if (null != onstageNum && onstageNum > 0) {
            log.warn("[nextPatient] 本工位已有 {} 个「就诊中」患者,跳过本次叫号(防并发双就诊中). 工位={}/{}",
                    onstageNum, roomId, bedNo);
            return GlobalErrorCodeConstants.SUCCESS;
        }
        // ① 候诊中(20) 优先
        QueueDO firstQueueItem = queueMapper.getBedFirstQueueItemByStatus(
                roomId, bedNo, QueueStatusEnum.READY.getStatus());
        boolean fromRecalled = false;
        // ② 候诊区没人时,回退取「已召回(15)」里号最小的一位
        //
        // 【缺陷修复】过号患者"召回"后会先置为 RECALLED(15),再靠 hurryupOneBed 的补人循环
        // 提升成 READY(20)。但补人循环有三道门闸(openingFlag=0 / stopReady=true / 候诊区已满),
        // 任一条成立患者就**一直停在 15**;而叫号此前只认 READY ⇒ 出现死结:
        // 现场表现为"排队号 A2/A3 状态是已召回,点叫号加载不出患者信息,无法检查"(2026-10-29 反馈)。
        // 这里按与 nextInstallPatient(已含 RECALLED_INSTALL)一致的思路直接叫到,
        // 不再依赖补人是否成功。
        if (null == firstQueueItem) {
            firstQueueItem = queueMapper.getBedFirstQueueItemByStatus(
                    roomId, bedNo, QueueStatusEnum.RECALLED.getStatus());
            fromRecalled = null != firstQueueItem;
            if (fromRecalled) {
                log.info("[nextPatient] 候诊区无人,改叫「已召回」患者. 工位={}/{} patId={} seqNum={}",
                        roomId, bedNo, firstQueueItem.getPatId(), firstQueueItem.getSeqNum());
            }
        }
        // 该工位既没有 [候诊中] 也没有 [已召回] 人员
        if (null == firstQueueItem) {
            return QUEUE_NOT_READY_PATIENT;
        }
        // QueueStatusEnum.READY  =>  QueueStatusEnum.ONSTAGE
        firstReadyQueueItem.setStatus(QueueStatusEnum.ONSTAGE.getStatus());
        queueMapper.updateById( firstReadyQueueItem );
        // ③ READY / RECALLED => ONSTAGE:用「主键 + 当前状态」条件更新(并发加固),
        //    0 行说明该行已被另一请求推进,本次放弃(下次再取)。
        int curStatus = fromRecalled
                ? QueueStatusEnum.RECALLED.getStatus() : QueueStatusEnum.READY.getStatus();
        Integer updated = queueMapper.updateStatusByIdIfCurrent(
                firstQueueItem.getId(), curStatus, QueueStatusEnum.ONSTAGE.getStatus());
        if (null == updated || 0 == updated) {
            log.warn("[nextPatient] 候诊行已被并发推进,放弃本次叫号. 工位={}/{} queueId={} 期望状态={}",
                    roomId, bedNo, firstQueueItem.getId(), curStatus);
            return QUEUE_NOT_READY_PATIENT;
        }
        firstQueueItem.setStatus(QueueStatusEnum.ONSTAGE.getStatus());
        // 分流队列中 该工位 【候诊中】患者数量 减一
        BedBO bo = mapOpeningBed.get( Utils.formatRoomBed( roomId, bedNo ) );
        bo.readyPatNum--; // 可能已经在 hurry-up 中改变了值
        refreshBedPriorityLevelInRoutingQueue(bo);
        // 注意:召回的这位本来就不在候诊计数里,不能减;另外 bo 可能为 null(工位不在开诊列表),
        // 原实现直接 bo.readyPatNum-- 会 NPE。
        BedBO bo = mapOpeningBed.get(Utils.formatRoomBed(roomId, bedNo));
        if (null != bo) {
            if (!fromRecalled) {
                bo.readyPatNum--; // 可能已经在 hurry-up 中改变了值
            }
            refreshBedPriorityLevelInRoutingQueue(bo);
        } else {
            log.warn("[nextPatient] 工位不在开诊列表,跳过内存计数与分流刷新. 工位={}/{}", roomId, bedNo);
        }
        return GlobalErrorCodeConstants.SUCCESS;
    }
@@ -396,9 +637,8 @@
            }
        });
        Integer num = queueMapper.getMaxSeqNum();
        curSeqNum = new AtomicInteger(null == num ? 0 : num);
        // 【号统一(本需求)】不再初始化内存计数器 curSeqNum:
        // 排队号来自签到时的 queue.seq_num,分流不再分配号。
        return GlobalErrorCodeConstants.SUCCESS;
    }
@@ -428,10 +668,29 @@
     * 把医生候诊的队列塞满
     */
    public void hurryupOneBed(Long roomId, String bedNo) {
        if (0 == openingFlag.get())
        if (0 == openingFlag.get()) {
            // 【现场排查】未开诊时**整个自动补人都是关闭的**(排队中的病人不会被搬进候诊区)。
            // 原实现是静默 return,现场只能看到"人都在,就是不进来",故这里明确记一条。
            log.warn("[补人] 系统未处于【开诊】状态(openingFlag=0),跳过自动补人. 工位={}/{}(可在监控页点「开诊」)",
                    roomId, bedNo);
            return;
        }
        BedBO bedBO = getBedQueueBO(roomId, bedNo);
        if (null == bedBO) {
            log.warn("[补人] 工位不在「已开诊工位」列表中,跳过. 工位={}/{}", roomId, bedNo);
            return;
        }
        // 【本次加固】用库里的真实候诊人数校正内存计数:
        // readyPatNum 是内存值,若某条路径(撤销/放弃/过号等)没同步,它会虚高,
        // 于是 "候诊区已满" 的判断一直成立、永远不补人(而界面上候诊区其实是空的)。
        int realReadyNum = countReadyInDb(roomId, bedNo);
        if (realReadyNum != bedBO.getReadyPatNum()) {
            log.warn("[补人] 内存候诊人数({}) 与库内({}) 不一致,已按库内校正. 工位={}/{}",
                    bedBO.getReadyPatNum(), realReadyNum, roomId, bedNo);
            bedBO.setReadyPatNum(realReadyNum);
        }
        // 【需求3】「停止候诊」时不再把【已召回】患者放入候诊区。
        // 注意:这**不影响**已有候诊患者被正常叫号(nextPatient 不检查本开关),
@@ -440,26 +699,24 @@
        // 处理 过号-回来 的人
        while (!stopReady && bedBO.getReadyPatNum() < bedBO.getMaxReadyPatNum()) {
            // 查看 当前工位 是否有过号-回来的患者
            Integer updateNum = queueMapper.queueRecalledPatient(
                    bedBO.getRoomId(),
                    bedBO.getRoomName(),
                    bedBO.getBedNo(),
                    curSeqNum.get() + 1,  // 过号回来,排到当前的下一位
                    QueueStatusEnum.RECALLED.getStatus(), // 不需要处理 [安装召回], 原因: 安装工位 不进入分流队列
                    QueueStatusEnum.READY.getStatus());
            if (null == updateNum || 0 == updateNum)
            // 查看 当前工位 是否有「已召回(15)」的患者(排队号最小的那一位)
            QueueDO recalledQueueItem = queueMapper.getBedFirstQueueItemByStatus(
                    bedBO.getRoomId(), bedBO.getBedNo(), QueueStatusEnum.RECALLED.getStatus());
            if (null == recalledQueueItem)
                break;
            // [过号] 患者 => [候诊中] ,处理 检查项目.亲和性 逻辑
            QueueDO recalledQueueItem = queueMapper.getQueueItemBySeqNum(curSeqNum.get() + 1);
            // 【号统一(本需求)】过号召回**不再重排队号**:号是签到时定的,
            // 患者按原号回到候诊序列(需求:「病人过号了,也排着」)。
            // 旧实现(seq_num = 当前号+1,排到队尾)会让他与屏幕上/患者手里的号对不上。
            // 不需要处理 [安装召回], 原因: 安装工位 不进入分流队列
            recalledQueueItem.setStatus(QueueStatusEnum.READY.getStatus());
            queueMapper.updateById(recalledQueueItem);
            // [过号] 患者 => [候诊中] ,处理 检查项目.亲和性 逻辑
            CheckTypeDO checkTypeDO = getCheckTypeItem( recalledQueueItem.getBookCheckType() );
            if ( checkTypeDO.getAffinityCheckTypes().length > 0) {
                procAffinityWhenRecalled(recalledQueueItem, checkTypeDO.getAffinityCheckTypes());
            }
            curSeqNum.getAndIncrement();
            // 可能已经在 nextPatient 中改变了值
            bedBO.readyPatNum++;
@@ -516,29 +773,45 @@
                return;
            // 查看 是否有排队中的患者
            Integer updateNum = queueMapper.preemptWaitingPatientWithBedCheckTypes(
                    bedBO.getRoomId(),
                    bedBO.getRoomName(),
                    bedBO.getBedNo(),
                    curSeqNum.get() + 1,
            //
            // 【号统一(本需求)】分流只做「排队中(10) → 候诊中(20)」的搬迁,**不再分配排队号**:
            // 号在签到时已写入 seq_num;加急只影响这里的取人顺序(SQL 内 jump_flag 优先)。
            // 旧实现是「UPDATE ... 顺带 seq_num=当前号+1,再按号查回该行」——
            // 号不再唯一后"按号查回"会取错人,故改为:先取行 → 按 id 条件更新。
            QueueDO preemptQueueItem = queueMapper.getFirstWaitingByCheckTypes(
                    QueueStatusEnum.WAITING.getStatus(),
                    QueueStatusEnum.READY.getStatus(),
                    bedBO.checkTypes);  //关键点 关键点 关键点 checkType
            // 没有抢到排队患者
            if (null == updateNum || 0 == updateNum) {
            // 没有排队中的患者
            if (null == preemptQueueItem) {
                return;
            }
            QueueDO preemptQueueItem = queueMapper.getQueueItemBySeqNum(curSeqNum.get() + 1);
            // 条件更新:status 必须仍是「排队中」,保证并发下只有一方抢到该行
            Integer updateNum = queueMapper.preemptWaitingPatientById(
                    preemptQueueItem.getId(),
                    bedBO.getRoomId(),
                    bedBO.getRoomName(),
                    bedBO.getBedNo(),
                    QueueStatusEnum.WAITING.getStatus(),
                    QueueStatusEnum.READY.getStatus());
            if (null == updateNum || 0 == updateNum) {
                // 被其它工位抢先:本轮放弃,等下一次补齐触发
                return;
            }
            // 内存对象与库内保持一致 —— 亲和项要跟随主项的诊室/工位
            preemptQueueItem.setStatus(QueueStatusEnum.READY.getStatus());
            preemptQueueItem.setRoomId(bedBO.getRoomId());
            preemptQueueItem.setRoomName(bedBO.getRoomName());
            preemptQueueItem.setBedNo(bedBO.getBedNo());
            CheckTypeDO checkTypeDO = getCheckTypeItem( preemptQueueItem.getBookCheckType() );
            // 抢到排队患者后,处理 检查项目.亲和性 逻辑
            if ( checkTypeDO.getAffinityCheckTypes().length > 0) {
                procAffinityWhenPreempt(preemptQueueItem, checkTypeDO.getAffinityCheckTypes());
            }
            curSeqNum.getAndIncrement();
            // 可能已经在 nextPatient 中改变了值
            bedBO.readyPatNum++;
@@ -587,25 +860,51 @@
        // 【需求3】「停止候诊」的工位不再新增候诊患者。
        // 已有候诊患者仍可正常叫号(本方法只负责"补齐候诊",不参与叫号)。
        if (Boolean.TRUE.equals(bedBO.getStopReady())) {
            // 【现场排查】明确记一条:工位开了「停止候诊」时不会自动补人
            log.warn("[补人] 工位处于【停止候诊】,跳过自动补人. 工位={}/{}(如需接收请关闭该开关)",
                    bedBO.getRoomId(), bedBO.getBedNo());
            return;
        }
        if (bedBO.getReadyPatNum() >= bedBO.getMaxReadyPatNum()) {
            // 【现场排查】候诊区已达上限(上限=各检查项目 check_type.ready_num 的最大值)
            log.info("[补人] 候诊区已达上限,跳过. 工位={}/{} 当前候诊={} 上限={}",
                    bedBO.getRoomId(), bedBO.getBedNo(), bedBO.getReadyPatNum(), bedBO.getMaxReadyPatNum());
            refreshBedPriorityLevelInRoutingQueue(bedBO);
            return;
        }
        int pulledNum = 0;
        while (bedBO.getReadyPatNum() < bedBO.getMaxReadyPatNum()) {
            // 抢占 是否有排队中的患者
            Integer updateNum = queueMapper.preemptWaitingPatientWithBedCheckTypes(
                    bedBO.getRoomId(),
                    bedBO.getRoomName(),
                    bedBO.getBedNo(),
                    curSeqNum.get() + 1,
            //
            // 【号统一(本需求)】与 hurryupOneCheckType 相同:只搬迁状态,**不再分配排队号**。
            QueueDO preemptQueueItem = queueMapper.getFirstWaitingByCheckTypes(
                    QueueStatusEnum.WAITING.getStatus(),
                    QueueStatusEnum.READY.getStatus(),
                    bedBO.checkTypes);
            // 没有抢到排队患者
            if (null == updateNum || 0 == updateNum) {
            // 没有排队中的患者
            if (null == preemptQueueItem) {
                return;
            }
            QueueDO preemptQueueItem = queueMapper.getQueueItemBySeqNum(curSeqNum.get() + 1);
            // 条件更新:status 必须仍是「排队中」,保证并发下只有一方抢到该行
            Integer updateNum = queueMapper.preemptWaitingPatientById(
                    preemptQueueItem.getId(),
                    bedBO.getRoomId(),
                    bedBO.getRoomName(),
                    bedBO.getBedNo(),
                    QueueStatusEnum.WAITING.getStatus(),
                    QueueStatusEnum.READY.getStatus());
            if (null == updateNum || 0 == updateNum) {
                // 被其它工位抢先:本轮放弃,等下一次补齐触发
                return;
            }
            // 内存对象与库内保持一致 —— 亲和项要跟随主项的诊室/工位
            preemptQueueItem.setStatus(QueueStatusEnum.READY.getStatus());
            preemptQueueItem.setRoomId(bedBO.getRoomId());
            preemptQueueItem.setRoomName(bedBO.getRoomName());
            preemptQueueItem.setBedNo(bedBO.getBedNo());
            CheckTypeDO checkTypeDO = getCheckTypeItem( preemptQueueItem.getBookCheckType() );
            // 抢到排队患者后,处理 检查项目.亲和性 逻辑
@@ -613,17 +912,91 @@
                procAffinityWhenPreempt(preemptQueueItem, checkTypeDO.getAffinityCheckTypes());
            }
            curSeqNum.getAndIncrement();
            bedBO.readyPatNum++;
            pulledNum++;
        }
        if (pulledNum > 0) {
            log.info("[补人] 已补入 {} 人. 工位={}/{} 当前候诊={} 上限={}",
                    pulledNum, bedBO.getRoomId(), bedBO.getBedNo(),
                    bedBO.getReadyPatNum(), bedBO.getMaxReadyPatNum());
        }
        // 改变了分流队列顺序
        refreshBedPriorityLevelInRoutingQueue(bedBO);
    }
    /** 库里该工位当前「准备中(20)」的真实人数(用于校正内存计数与现场诊断) */
    private int countReadyInDb(Long roomId, String bedNo) {
        List<Integer> readyStatus = new ArrayList<>();
        readyStatus.add(QueueStatusEnum.READY.getStatus());
        List<QueueDO> readyList = queueMapper.getBedQueueByStatus(roomId, bedNo, readyStatus);
        return null == readyList ? 0 : readyList.size();
    }
    /**
     * 有检查项分流到诊室后,也就是进入[候诊中]后,拉 [排队中] + 具有亲和性的 检查项 来依附该检查项
     * 立即补人(现场自救 + 自助诊断):手动触发一次该工位的自动分流,并把"为什么没补/补了几人"一起返回。
     *
     * <p>用于工作台候诊区为空时的「立即补人」按钮:不必等定时任务、也不用重启去猜是哪道门闸挡住的。
     */
    public Map<String, Object> hurryUpNow(Long roomId, String bedNo) {
        Map<String, Object> data = new LinkedHashMap<>();
        int flag = openingFlag.get();
        data.put("系统开诊状态", 0 == flag ? "已闭诊(此时不会自动补人,请先在监控页点「开诊」)" : "开诊中");
        BedBO bedBO = getBedQueueBO(roomId, bedNo);
        if (null == bedBO) {
            data.put("结论", "该工位不在「已开诊工位」列表里(可能已关闭/本次未开诊),无法补人;请先到选工位页入座或让管理员开诊");
            log.warn("[补人] hurryUpNow 工位不存在. 工位={}/{}", roomId, bedNo);
            return data;
        }
        boolean stopReady = Boolean.TRUE.equals(bedBO.getStopReady());
        int before = countReadyInDb(roomId, bedNo);
        int memoryBefore = bedBO.getReadyPatNum();
        // 先按库内真实值校正内存(内存虚高会导致永久"已满")
        bedBO.setReadyPatNum(before);
        data.put("工位", bedBO.getRoomName() + " " + bedNo);
        data.put("检查项目", null == bedBO.getCheckTypes() ? "[]" : java.util.Arrays.toString(bedBO.getCheckTypes()));
        data.put("停止候诊", stopReady);
        data.put("候诊上限", bedBO.getMaxReadyPatNum());
        data.put("补人前候诊人数", before + (memoryBefore == before ? "" : "(内存曾为 " + memoryBefore + ",已校正)"));
        if (0 == flag) {
            data.put("结论", "系统已闭诊,本次未补人;请先在监控页点「开诊」");
            return data;
        }
        if (stopReady) {
            data.put("结论", "本工位开了「停止候诊」,本次未补人;如需接收患者请关闭该开关");
            return data;
        }
        hurryupOneBed(roomId, bedNo);
        int after = countReadyInDb(roomId, bedNo);
        int pulled = Math.max(0, after - before);
        data.put("补人后候诊人数", after);
        data.put("本次补入", pulled);
        if (pulled > 0) {
            data.put("结论", "已补入 " + pulled + " 人");
        } else if (after >= bedBO.getMaxReadyPatNum()) {
            data.put("结论", "候诊区已达上限(" + after + "/" + bedBO.getMaxReadyPatNum() + "),不再补人");
        } else {
            data.put("结论", "没有匹配本工位检查项目的「排队中」患者(可到工作台查看「本诊室排队中(未分配)」分区)");
        }
        log.info("[补人] hurryUpNow 工位={}/{} 前={} 后={} 结论={}", roomId, bedNo, before, after, data.get("结论"));
        return data;
    }
    /**
     * 有检查项分流到诊室后,也就是进入[候诊中]后,拉 [排队中] + 具有亲和性的 检查项 来依附该检查项。
     *
     * <p>【亲和项与排队号】亲和项目(同一患者、可在一个工位一起做的多个检查项)在候诊区
     * <b>跟随主项显示</b>:这里把亲和项的排队号改成**主项的号**、并搬到主项所在诊室/工位,
     * 界面(工作台/诊间屏/大屏)才能把两者排在一起。
     * <p>注意这**不是**"重新赋号":号仍是签到时定的那个,只是亲和项沿用主项的号,
     * 与"分流/叫号/召回不再改号"并不冲突。
     */
    private void procAffinityWhenPreempt(QueueDO preemptQueueItem, Integer[] affinityCheckTypes) {
        List<QueueDO> affinityItems = queueMapper.getCurPatGivenCheckTypesAndStatus(
@@ -635,33 +1008,91 @@
            queueItem.setRoomId(preemptQueueItem.getRoomId());
            queueItem.setRoomName(preemptQueueItem.getRoomName());
            queueItem.setBedNo(preemptQueueItem.getBedNo());
            queueItem.setSeqNum( preemptQueueItem.getSeqNum());  // 使用同一个 内部序号
            queueItem.setSeqNum( preemptQueueItem.getSeqNum());  // 亲和项沿用主项的排队号(跟随显示)
            queueMapper.updateById(queueItem);
        }
    }
    /**
     * [过号] 患者 => [候诊中] ,处理 检查项目.亲和性 逻辑
     * [过号] 患者 => [候诊中] ,处理 检查项目.亲和性 逻辑。
     *
     * <p>召回时主项的号不变,因此只需让已经跟随的亲和项保持一致(保持同号 → 界面仍排在一起)。
     */
    private void procAffinityWhenRecalled(QueueDO recalledQueueItem, Integer[] affinityCheckTypes) {
        List<QueueDO> affinityItems = queueMapper.getCurPatGivenCheckTypesAndStatus(
                recalledQueueItem.getPatId(), affinityCheckTypes, QueueStatusEnum.AFFINITY.getStatus());
        for (int i = 0; i < affinityItems.size(); i++) {
            QueueDO queueItem = affinityItems.get(i);
            queueItem.setSeqNum( recalledQueueItem.getSeqNum() );  // [过号] 患者 => [候诊中],原内部序号更新
            queueItem.setSeqNum( recalledQueueItem.getSeqNum() );  // 亲和项与主项保持同一个号
            queueMapper.updateById(queueItem);
        }
    }
    /**
     * 工位「候诊区容量上限」= 该工位开展的**各检查项目** {@code check_type.ready_num}
     * (页面字段:「检查类型管理」→「候诊准备人数」)的**最大值**。
     *
     * <p><b>返回 0 表示"不自动分流"</b>:此时排队中(10) 的患者**不会**被主动搬进候诊区(20),
     * 需要医生在候诊区用「拉入」手动补人(现场确认的口径)。
     *
     * <p>【本次加固】
     * <ul>
     *   <li><b>实时读库</b>:原来这份配置只在服务启动时加载一次
     *       ({@code MySpringEventListener} → {@code initCheckType()}),
     *       在页面上改了「候诊准备人数」必须**重启服务**才生效;</li>
     *   <li><b>容错</b>:检查项目在配置里查不到、或 {@code ready_num} 为空 → **跳过并记警告**,
     *       不再抛 NPE。原先这个 NPE 发生在 {@code addBedToRoutingQueue}(入座/开诊/重载都会走),
     *       会连带把入座弄失败。</li>
     * </ul>
     */
    private Integer getBedReadyMax(BedBO bedBO) {
        Integer[] checkTypes = bedBO.getCheckTypes();
        if (null == checkTypes || 0 == checkTypes.length) {
            return 0;
        }
        Map<Integer, CheckTypeDO> fresh = readCheckTypeMapFresh();
        int ret = 0;
        for (Integer checkType : checkTypes) {
            CheckTypeDO checkTypeDO = mapCheckTypeVsReadyMax.get(checkType);
            CheckTypeDO checkTypeDO = fresh.get(checkType);
            if (null == checkTypeDO) {
                // 兼容:实时读失败时回落到启动缓存,两边都没有就跳过(不再 NPE)
                checkTypeDO = getCheckTypeItem(checkType);
            }
            if (null == checkTypeDO) {
                log.warn("[getBedReadyMax] 检查项目在 check_type 里不存在,跳过(按 0 计). checkType={} 工位={}/{}",
                        checkType, bedBO.getRoomId(), bedBO.getBedNo());
                continue;
            }
            Integer tmp = checkTypeDO.getReadyNum();
            ret = ret < tmp ? tmp : ret;  //【候诊中】数量 取大值
            if (null == tmp) {
                log.warn("[getBedReadyMax] 检查项目[候诊准备人数]为空,跳过. checkType={} name={}",
                        checkType, checkTypeDO.getName());
                continue;
            }
            ret = Math.max(ret, tmp);  //【候诊中】数量 取大值
        }
        return ret;
    }
    /**
     * 实时读取「检查项目值 → 检查项目配置」映射(每次装配工位时读一次全表,检查项目只有十几个,量级很小)。
     * 这样管理员在「检查类型管理」里改了「候诊准备人数」**立刻生效,不用重启服务**。
     * 读库失败时回落到启动时加载的缓存({@link #initCheckType()})。
     */
    private Map<Integer, CheckTypeDO> readCheckTypeMapFresh() {
        try {
            List<CheckTypeDO> list = checkTypeMapper.simpleCheckTypeList();
            Map<Integer, CheckTypeDO> map = new HashMap<>();
            for (CheckTypeDO item : list) {
                if (null != item && null != item.getValue()) {
                    map.put(item.getValue(), item);
                }
            }
            return map;
        } catch (Exception e) {
            log.warn("[readCheckTypeMapFresh] 实时读检查项目失败,改用启动时缓存", e);
            return null == mapCheckTypeVsReadyMax ? new HashMap<>() : mapCheckTypeVsReadyMax;
        }
    }
    private void addBedToRoutingQueue(BedBO bedBO) {
@@ -687,6 +1118,14 @@
        List<QueueDO> queueDOList = queueMapper.getBedQueueByStatus(bedBO.getRoomId(), bedBO.getBedNo(), queueStatusList);
        bedBO.setMaxReadyPatNum( getBedReadyMax(bedBO) );
        bedBO.setReadyPatNum( queueDOList.size() );
        // 【现场排查】把"候诊区上限 / 当前人数 / 来源"打出来:
        // 上限=0 表示**不自动分流**(排队中的患者不会被主动搬进候诊区,需在候诊区手动「拉入」)。
        // 现场反馈过"有这么多排队病人,为什么没主动加载到候诊区",一条日志即可自证。
        log.info("[addBedToRoutingQueue] 工位 {}/{} 候诊上限={} 当前候诊={} 检查项目={}"
                        + "(上限=各检查项目 check_type.ready_num 的最大值;0=不自动分流,靠工位手动「拉入」)",
                bedBO.getRoomId(), bedBO.getBedNo(), bedBO.getMaxReadyPatNum(), bedBO.getReadyPatNum(),
                null == bedBO.getCheckTypes() ? "[]" : java.util.Arrays.toString(bedBO.getCheckTypes()));
        Integer[] checkTypes = bedBO.getCheckTypes();
        Arrays.stream(checkTypes).forEach(checkType -> {
@@ -802,6 +1241,19 @@
                item.getStatus() == BedStatusEnum.DOCTOR_ON.getStatus() ||
                item.getStatus() == BedStatusEnum.DOCTOR_PAUSE.getStatus())
            .count());
        // 【本需求·统计按人合并】在座**医生数**:一人多工位时上面的 docBedNum 会按工位累计,
        // 这里按 doc_id 去重,给监控页一个"到底几个医生在岗"的指标。
        List<Integer> seatedStatusList = new ArrayList<>();
        seatedStatusList.add(BedStatusEnum.DOCTOR_ON.getStatus());
        seatedStatusList.add(BedStatusEnum.DOCTOR_PAUSE.getStatus());
        try {
            Integer docNum = roomMapper.countSeatedDoctors(seatedStatusList);
            monitorInfoVO.setDocNum(null == docNum ? 0 : docNum);
        } catch (Exception e) {
            // 统计失败不影响监控页其它数据
            log.warn("[roomBedStatistic] 统计在座医生数失败(不影响其它指标)", e);
            monitorInfoVO.setDocNum(0);
        }
        monitorInfoVO.setRoutingBedNum( hashSetOperatingBedQueue.size() );
        monitorInfoVO.setMapBedVsQueue(mapOpeningBed);
    }