yxh
4 天以前 1f23e7dfc186c65434eda46fd6756d41fd01e867
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/queue/QueueServiceImpl.java
@@ -2,14 +2,18 @@
import java.util.*;
import java.util.concurrent.*;
import java.util.stream.Collectors;
import javax.annotation.Resource;
import cn.lihu.jh.module.ecg.Utils;
import cn.lihu.jh.module.ecg.controller.admin.room.vo.MonitorInfoVO;
import cn.lihu.jh.module.ecg.dal.dataobject.appointment.AppointmentDO;
import cn.lihu.jh.module.ecg.dal.dataobject.checktype.CheckTypeDO;
import cn.lihu.jh.module.ecg.dal.dataobject.devrent.DevRentDO;
import cn.lihu.jh.module.ecg.dal.mysql.appointment.AppointmentMapper;
import cn.lihu.jh.module.ecg.dal.mysql.checktype.CheckTypeMapper;
import cn.lihu.jh.module.ecg.dal.mysql.devrent.DevRentMapper;
import cn.lihu.jh.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.lihu.jh.module.ecg.enums.DevRentStateEnum;
import cn.lihu.jh.module.system.api.oauth2.OAuth2TokenApi;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
@@ -18,6 +22,8 @@
import org.apache.cxf.common.util.CollectionUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.validation.annotation.Validated;
import cn.lihu.jh.module.ecg.dal.dataobject.room.RoomDO;
@@ -65,6 +71,13 @@
    @Resource
    private DevRentMapper devRentMapper;
    /**
     * 用于「设备领用/装机」取数时过滤出 {@code need_dev_ready = 1} 的检查项目
     * (见 {@link #filterNeedDevReady})。
     */
    @Resource
    private CheckTypeMapper checkTypeMapper;
    ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor();
@@ -182,9 +195,41 @@
        return ECG_INNER_ERROR;
    }
    /**
     * 「本诊室排队中(未分配工位)」的患者列表。
     *
     * <p>工作台候诊列表按 {@code room_id + bed_no} 查,只看得到**已分流到本工位**的患者;
     * 而"排队中(10)"的患者 {@code bed_no} 为空(大屏会显示、工作台看不到),
     * 这里按**本工位开展的检查项目**把他们单独查出来,供医生手工拉入。
     *
     * <p>工位不存在或没配检查项目时返回空列表(记警告),不抛异常 —— 列表为空不影响主流程。
     */
    @Override
    public ErrorCode startBedDoctorOn(Long roomId, String bedNo, Long docId, String docName) {
        Future<ErrorCode> future = singleThreadExecutor.submit(new BedDoctorOnCallable(queueServiceTxFunctions, roomId, bedNo, docId, docName));
    public List<QueueDO> getUnassignedWaitingPatient(Long roomId, String bedNo) {
        RoomDO bed = (null == roomId || null == bedNo) ? null : roomMapper.getRoom(roomId, bedNo);
        if (null == bed || null == bed.getCheckTypes() || 0 == bed.getCheckTypes().length) {
            log.warn("[getUnassignedWaitingPatient] 工位不存在或未配置检查项目,返回空. roomId={} bedNo={}",
                    roomId, bedNo);
            return new ArrayList<>();
        }
        return queueMapper.selectUnassignedWaitingByCheckTypes(
                QueueStatusEnum.WAITING.getStatus(), bed.getCheckTypes());
    }
    /**
     * 手工把某位「排队中」患者拉入本工位(→ 准备中)。
     *
     * <p>权限由 Controller 上的 {@code @RequireBedOwner} 保证:只有本工位主人能拉。
     * 这里只做业务校验与条件更新(并发安全:{@code and status = 10}),并复用自动分流的亲和项逻辑。
     */
    @Override
    public ErrorCode pullWaitingPatient(Long roomId, String bedNo, Integer queueId) {
        return queueServiceTxFunctions.pullWaitingPatient(roomId, bedNo, queueId);
    }
    @Override
    public ErrorCode startBedDoctorOn(Long roomId, String bedNo, Long docId, String docName, Integer bedId) {
        Future<ErrorCode> future = singleThreadExecutor.submit(new BedDoctorOnCallable(queueServiceTxFunctions, roomId, bedNo, docId, docName, bedId));
        try {
            ErrorCode ret = future.get();
@@ -213,6 +258,27 @@
        }
        //System.out.println("startBedDoctorOff ========");
        return ECG_INNER_ERROR;
    }
    /**
     * 【本次需求】管理员强制清空工位(不要求是本人)。
     * <p>
     * 与 {@link #startBedDoctorOff} 共用同一个单线程执行器,保证同一时刻只处理一个工位操作,
     * 避免与「叫号 / 入座 / 离座」并发改同一行。
     */
    @Override
    public ErrorCode startBedDoctorOffByAdmin(Long roomId, String bedNo) {
        Future<ErrorCode> future = singleThreadExecutor.submit(
                () -> queueServiceTxFunctions.bedDoctorOffByAdmin(roomId, bedNo));
        try {
            return future.get();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.error("[管理员清空工位] 被中断", e);
        } catch (ExecutionException e) {
            log.error("[管理员清空工位] 执行失败", e.getCause() != null ? e.getCause() : e);
        }
        return ECG_INNER_ERROR;
    }
@@ -252,16 +318,81 @@
    @Override
    public void startHurryUpOneBed(Long roomId, String bedNo) {
        singleThreadExecutor.execute(() -> {
            queueServiceTxFunctions.hurryupOneBed(roomId, bedNo);
        });
        // 【缺陷修复】必须等事务提交后再跑:见 runAfterCommit 的说明
        runAfterCommit(() -> singleThreadExecutor.execute(() -> {
            try {
                queueServiceTxFunctions.hurryupOneBed(roomId, bedNo);
            } catch (Exception e) {
                // 原实现不捕获:异常只留在工作线程里,日志一条都没有,现场无从排查
                log.error("[hurryupOneBed] 自动补人执行异常. 工位={}/{}(不影响主流程,下一轮会重试)",
                        roomId, bedNo, e);
            }
        }));
    }
    /**
     * 把「触发自动分流」推迟到**事务提交之后**再执行;不在事务里则直接执行。
     *
     * <p>【缺陷修复·现场反馈】运动试验签到后,患者一直停在医生工作台的
     * 「本诊室排队中(未分配工位)」,必须手工点「拉入」才进候诊区。
     *
     * <p>原因:{@link #queue} 是 {@code @Transactional},插入「排队中」行之后**立即**把
     * 分流任务丢给 {@code singleThreadExecutor}。工作线程用的是**另一个数据库连接**,
     * READ COMMITTED 下看不到主事务尚未提交的那一行 ⇒
     * {@code getFirstWaitingByCheckTypes} 返回 null ⇒ 这次分流什么都没做就退出;
     * 事务提交后又没有任何补偿 ⇒ 患者永远留在「排队中」。
     *
     * <p>为什么低频检查项目(运动试验、食道电生理)几乎必然中招、高频的却"看起来正常":
     * 执行器平时空闲,任务能**立刻**执行(必然早于提交);高频项目任务会排队,
     * 轮到时事务往往已经提交,于是侥幸成功。
     */
    private void runAfterCommit(Runnable task) {
        if (TransactionSynchronizationManager.isSynchronizationActive()) {
            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
                @Override
                public void afterCommit() {
                    task.run();
                }
            });
        } else {
            task.run();
        }
    }
    @Override
    public Map<String, Object> hurryUpNow(Long roomId, String bedNo) {
        // 同步执行:要立刻返回"补人前后人数 + 结论",异步就看不到结果了
        return queueServiceTxFunctions.hurryUpNow(roomId, bedNo);
    }
    @Override
    public void startHurryUpOneCheckType(Integer checkType) {
        singleThreadExecutor.execute(() -> {
            queueServiceTxFunctions.hurryupOneCheckType(checkType);
        });
        // 【缺陷修复】必须等事务提交后再跑:见 runAfterCommit 的说明
        runAfterCommit(() -> singleThreadExecutor.execute(() -> {
            try {
                queueServiceTxFunctions.hurryupOneCheckType(checkType);
            } catch (Exception e) {
                log.error("[hurryupOneCheckType] 自动分流执行异常. checkType={}(不影响主流程,下一轮会重试)",
                        checkType, e);
            }
        }));
    }
    /**
     * 兜底:对**有医生在座**的工位触发一次自动补人(由 {@code QueueAutoRefillJob} 周期调用)。
     *
     * <p>注意用的是 {@code hurryupAllSeatedBeds} 而不是 {@code hurryupAllBed}:
     * 后者会把患者推进"已开通但没医生"的工位,周期跑会放大该副作用。
     */
    @Override
    public void startHurryUpAll() {
        runAfterCommit(() -> singleThreadExecutor.execute(() -> {
            try {
                queueServiceTxFunctions.hurryupAllSeatedBeds();
            } catch (Exception e) {
                log.error("[hurryupAllSeatedBeds] 周期补人执行异常(下一轮继续)", e);
            }
        }));
    }
    @Override
@@ -318,9 +449,7 @@
    @Override
    public PageResult<QueueDO> getqueuePage(QueuePageReqVO pageReqVO) {
        PageResult<QueueDO> queueDOPageResult = queueMapper.selectPage(pageReqVO);
        queueDOPageResult.getList().forEach(queueDO -> {
            queueDO.setTolerance(appointmentMapper.getByApplyNo(queueDO.getApplyNo()).getTolerance());
        });
        fillAppointmentInfo(queueDOPageResult.getList());
        return queueDOPageResult;
    }
@@ -357,8 +486,8 @@
    }
    @Override
    public List<QueueDO> selectBookSeqNumByAppointIdList(List<Long> appointIdList) {
        return queueMapper.selectBookSeqNumByAppointIdList(appointIdList);
    public List<QueueDO> selectQueueByAppointIdList(List<Long> appointIdList) {
        return queueMapper.selectQueueByAppointIdList(appointIdList);
    }
    // 常规检查 叫号
@@ -419,28 +548,105 @@
        // 从 DB 把 [安装中]的人 设置为 [过号-安装]
        Integer ret = queueMapper.updateBedQueueStatus(roomId, bedNo, QueueStatusEnum.INSTALLING.getStatus(), QueueStatusEnum.PASSED_INSTALL.getStatus());
        startNextPatient(roomId, bedNo);
        // 【既有笔误修复】这里原本调的是 startNextPatient(常规检查取人:候诊中 → 就诊中),
        // 而装机过号后应当继续取**安装队列**里的下一位(已领用/已召回-安装 → 安装中)。
        // 由于装机工位不参与分流、没有"候诊中"患者,原调用实际上什么也不会发生,
        // 表现为"装机过号后不会自动叫下一位"。
        startNextInstallPatient(roomId, bedNo);
    }
    public List<QueueDO> getBedQueueByStatus(Long roomId, String bedNo, List<Integer> statusList) {
        List<QueueDO> queueDOList = queueMapper.getBedQueueByStatus(roomId, bedNo, statusList);
        if (!CollectionUtils.isEmpty(queueDOList)) {
            queueDOList.forEach(queueDO -> {
                queueDO.setTolerance(appointmentMapper.getByApplyNo(queueDO.getApplyNo()).getTolerance());
            });
        }
        fillAppointmentInfo(queueDOList);
        return queueDOList;
    }
    /**
     * 从预约单补齐 queue 表上没有的患者信息。
     *
     * <p>queue 表不存这些字段,但医生端列表接口需要展示,所以查询后按 applyNo 回查预约单填充:
     * 多耐药、性别、出生日期、登记时间、开单医师、开单日期。</p>
     *
     * <h3>⚠️ 性能修复:由 N+1 改为一次批量查询</h3>
     * <p><b>原实现</b>在 {@code forEach} 里对每一行调用
     * {@code appointmentMapper.getByApplyNo(applyNo)},即**每行一次 SQL**。
     * 列表每页 10 条 ⇒ 10 次查询;而该 SQL
     * ({@code WHERE apply_no = ? AND deleted = 0})在修复前**用不上索引**,
     * 每次都是全表扫描 appointment(实测 16.6 万行,约 549 ms/次),
     * 一页就要 4.5~5.5 秒 —— 这是「队列列表打开很慢」的直接原因。</p>
     *
     * <p><b>现实现</b>改为:先收集本页所有 applyNo(去重)→ **一次**
     * {@code getByApplyNoList} 批量取回 → 内存里按 applyNo 建映射再回填。
     * 一页 SQL 从 10 次降到 1 次。</p>
     *
     * <p>注:配套已于 {@code 26_add_index_appointment_apply_no.sql}
     * 给 {@code appointment.apply_no} 加上索引;
     * 但即便有索引,N+1 仍是 10 次往返,故两处一起改才是最优。</p>
     */
    @Override
    public void fillAppointmentInfo(List<QueueDO> queueDOList) {
        if (CollectionUtils.isEmpty(queueDOList)) {
            return;
        }
        // 1. 收集本次需要回查的申请单号(跳过空值)。
        //    用 LinkedHashSet 去重:同一页里理论上 apply_no 唯一,但去重后 IN 列表更短、更安全。
        Set<String> applyNoSet = new LinkedHashSet<>();
        for (QueueDO queueDO : queueDOList) {
            if (queueDO != null && queueDO.getApplyNo() != null && !queueDO.getApplyNo().isEmpty()) {
                applyNoSet.add(queueDO.getApplyNo());
            }
        }
        if (applyNoSet.isEmpty()) {
            return;
        }
        // 2. 一次批量取回(SQL 内部已带 deleted = 0)。
        //    注意:MySQL 的 IN () 会语法错误,故必须保证列表非空 —— 上面已判空。
        List<AppointmentDO> appointmentList = appointmentMapper.getByApplyNoList(new ArrayList<>(applyNoSet));
        // 3. 按 applyNo 建映射;个别 applyNo 查不到预约单是正常情况(历史脏数据),跳过即可。
        Map<String, AppointmentDO> appointmentMap = appointmentList.stream()
                .filter(item -> item != null && item.getApplyNo() != null)
                .collect(Collectors.toMap(AppointmentDO::getApplyNo, item -> item, (a, b) -> a));
        // 4. 内存回填,不再逐条查库
        queueDOList.forEach(queueDO -> {
            if (queueDO == null || queueDO.getApplyNo() == null || queueDO.getApplyNo().isEmpty()) {
                return;
            }
            AppointmentDO appointment = appointmentMap.get(queueDO.getApplyNo());
            if (null == appointment) {
                return;
            }
            queueDO.setTolerance(appointment.getTolerance());
            // 需求2:床边标记(HIS zdy.ISCB)。queue 表已有该列,
            // 但对「22_* 脚本执行前就已入队」的行,靠这里回查补填,保证屏幕/页面能看到。
            if (queueDO.getIsBedside() == null) {
                queueDO.setIsBedside(appointment.getIsBedside());
            }
            queueDO.setPatGender(appointment.getPatGender());
            queueDO.setPatBirthday(appointment.getPatBirthday());
            queueDO.setRegisterDate(appointment.getRegisterDate());
            queueDO.setDoctor(appointment.getDoctor());
            queueDO.setDoctorTime(appointment.getDoctorTime());
            // 需求1:补平铺的患者信息来源字段(病历区/床号/电话/来源/就诊卡号等)
            queueDO.setPatSrc(appointment.getPatSrc());
            queueDO.setPatMobile(appointment.getPatMobile());
            queueDO.setPatPhone(appointment.getPatPhone());
            queueDO.setPatWardDesc(appointment.getPatWardDesc());
            queueDO.setPatBedNo(appointment.getPatBedNo());
            queueDO.setMedicalCard(appointment.getMedicalCard());
            queueDO.setDoctorAdviceNo(appointment.getDoctorAdviceNo());
            queueDO.setOutpatientNo(appointment.getOutpatientNo());
            queueDO.setPatIdentityId(appointment.getPatIdentityId());
        });
    }
    @Override
    public List<QueueDO> getRoomQueueByStatus(Long roomId, List<Integer> statusList) {
        List<QueueDO> queueDOList = queueMapper.getRoomQueueByStatus(roomId, statusList);
        if (!CollectionUtils.isEmpty(queueDOList)) {
            queueDOList.forEach(queueDO -> {
                queueDO.setTolerance(appointmentMapper.getByApplyNo(queueDO.getApplyNo()).getTolerance());
            });
        }
        fillAppointmentInfo(queueDOList);
        return queueDOList;
    }
@@ -453,18 +659,33 @@
                patientStatisticVO.setReadyNum(item.getTotalInStatus());
            } else if (QueueStatusEnum.FINISH.getStatus() == item.getStatus()) {
                patientStatisticVO.setFinishedNum(item.getTotalInStatus());
            } else if (QueueStatusEnum.PASSED.getStatus() == item.getStatus()) {
                patientStatisticVO.setPassedNum(item.getTotalInStatus());
            } else if (QueueStatusEnum.PASSED.getStatus() == item.getStatus()
                    || QueueStatusEnum.PASSED_WAITING.getStatus() == item.getStatus()) {
                // 【需求4 口径统一】过号 = 已过号(5) + 已过号-排队(3)。
                // 原先只算 5,而列表与屏幕都含 3,导致"过号"数字比实际少。
                int cur = patientStatisticVO.getPassedNum() == null ? 0 : patientStatisticVO.getPassedNum();
                patientStatisticVO.setPassedNum(cur + item.getTotalInStatus());
            }
        });
        // 统计 该工位类型的【排队中】人员数量
        // 【Q7 确认】此处刻意统计**全项目**(该工位所含检查类型)的"排队中"总数,
        // 语义为"全院还有多少人等着做这类检查",与"本工位一眼可见的候诊人数"互补。
        // 且已补 deleted=0 / book_date=今日(见 QueueMapper.checkTypeAndStatusStatistic)。
        RoomDO roomDO = getRoomDO(roomId, bedNo);
        Integer[] checkTypes = roomDO.getCheckTypes();
        List<Integer> statusList = new ArrayList<>();
        statusList.add(QueueStatusEnum.WAITING.getStatus());
        Integer num = queueMapper.checkTypeAndStatusStatistic(checkTypes, statusList);
        patientStatisticVO.setQueuingNum(num);
        int queuingNum = 0;
        // 【防御】checkTypes 为空时不能拼 `book_check_type in ()`(MySQL 语法错误)。
        // 实测该场景会让统计接口 500,进而导致医生端顶部统计整块报错。
        if (checkTypes != null && checkTypes.length > 0) {
            List<Integer> statusList = new ArrayList<>();
            statusList.add(QueueStatusEnum.WAITING.getStatus());
            Integer num = queueMapper.checkTypeAndStatusStatistic(checkTypes, statusList);
            queuingNum = num == null ? 0 : num;
        } else {
            log.warn("[getPatientStatistic][工位未配置检查类型,排队中按 0 计] roomId={} bedNo={}", roomId, bedNo);
        }
        patientStatisticVO.setQueuingNum(queuingNum);
        return patientStatisticVO;
    }
@@ -506,8 +727,19 @@
        bedQueueStatisticDOList.forEach(item -> {
            if (QueueStatusEnum.READY.getStatus() == item.getStatus()) {
                patientStatisticVO.setReadyNum(item.getTotalInStatus());
            } else if (QueueStatusEnum.PASSED.getStatus() == item.getStatus()) {
                patientStatisticVO.setPassedNum(item.getTotalInStatus());
            } else if (QueueStatusEnum.PASSED.getStatus() == item.getStatus()
                    || QueueStatusEnum.PASSED_WAITING.getStatus() == item.getStatus()) {
                // 【需求4 口径统一】过号 = 已过号(5) + 已过号-排队(3)。
                //
                // 与 getPatientStatistic 保持同一口径。原实现只判 PASSED(5),
                // 而领用界面的列表/屏幕都含 status=3,导致「过号」数字比实际少
                // —— 这正是用户反馈的「统计与实际不符」。
                //
                // 另:原实现用 setPassedNum(item.getTotalInStatus())(**覆盖**而非累加),
                // 因 SQL 是 group by status,status=3 与 status=5 是两行,
                // 覆盖会只保留最后一行 → 改为累加。
                int cur = patientStatisticVO.getPassedNum() == null ? 0 : patientStatisticVO.getPassedNum();
                patientStatisticVO.setPassedNum(cur + item.getTotalInStatus());
            }
        });
@@ -527,10 +759,17 @@
        // 统计 该工位类型的【排队中】人员数量
        RoomDO roomDO = getRoomDO(roomId, bedNo);
        Integer[] checkTypes = roomDO.getCheckTypes();
        List<Integer> statusList = new ArrayList<>();
        statusList.add(QueueStatusEnum.WAITING.getStatus());
        Integer num = queueMapper.checkTypeAndStatusStatistic(checkTypes, statusList);
        patientStatisticVO.setQueuingNum(num);
        Integer num = 0;
        // 【防御】checkTypes 为空时不能拼 `book_check_type in ()`(MySQL 语法错误),
        // 实测该场景会让统计接口 500、医生端顶部统计整块报错。与 getPatientStatistic 保持一致。
        if (checkTypes != null && checkTypes.length > 0) {
            List<Integer> statusList = new ArrayList<>();
            statusList.add(QueueStatusEnum.WAITING.getStatus());
            num = queueMapper.checkTypeAndStatusStatistic(checkTypes, statusList);
        } else {
            log.warn("[getBedDevReadyStatistic][工位未配置检查类型,排队中按 0 计] roomId={} bedNo={}", roomId, bedNo);
        }
        patientStatisticVO.setQueuingNum(num == null ? 0 : num);
        return patientStatisticVO;
    }
@@ -548,6 +787,19 @@
    public void closeBiz() {
        queueServiceTxFunctions.setOpeningFlag(0);
        // 【本次需求】闭诊时清空当班工位(doc_id / doc_name 置空,状态改回空闲)。
        //
        // 背景:工位不再因「空闲超时 / 令牌失效 / 刷新页面」被释放(避免医生离开一会儿就丢工位),
        // 因此需要一个兜底 —— 每天闭诊时统一清空,防止医生下班忘离座导致工位长期被占用。
        // 先清库,再由下面的 startBedReload() 按库重新装载内存,保证二者一致。
        try {
            queueServiceTxFunctions.closeBizClearBeds();
        } catch (Exception e) {
            // 清空失败不能阻断闭诊流程本身
            log.error("[闭诊] 清空当班工位失败(不影响闭诊)", e);
        }
        startBedReload();
    }
@@ -559,7 +811,93 @@
        // 会在 HurryUpOnBed 里面处理 检查项目.亲和性
        startHurryUpOneBed(roomId, bedNo);
        // 【诊断】补人循环有三道门闸(未开诊 / 已停止候诊 / 候诊区已满),
        // 任一条成立时患者会**停在「已召回(15)」不进候诊区**。
        // 现在 nextPatient 支持直接从「已召回」叫号(2026-10-29 修复),这里只补一条日志方便现场排查。
        if (null != updateNum && updateNum > 0) {
            try {
                QueueDO stillRecalled = queueMapper.getBedQueueItemByPatAndStatus(
                        roomId, bedNo, patId, checkType, QueueStatusEnum.RECALLED.getStatus());
                if (null != stillRecalled) {
                    log.warn("[recallPatient] 已置为「已召回」,但未进入候诊区"
                                    + "(可能原因:未开诊 / 已停止候诊 / 候诊区已满),叫号时将直接从「已召回」取人. "
                                    + "工位={}/{} patId={} seqNum={}",
                            roomId, bedNo, patId, stillRecalled.getSeqNum());
                }
            } catch (Exception e) {
                log.warn("[recallPatient] 检查「已召回」是否进入候诊区时异常(不影响召回结果). 工位={}/{} patId={}",
                        roomId, bedNo, patId, e);
            }
        }
        return updateNum;
    }
    // ==================== 需求2:撤销「叫号 / 检查完成」 ====================
    /**
     * 撤销「检查完成」:把本工位今日的指定患者从 已就诊(40) 退回 **候诊中(20)**。
     * <p>
     * <b>Q9 确认</b>:撤销后回到**候诊区**(而非"就诊中"),便于医生重新叫号。
     * <p>
     * 收尾必须调 {@link #startHurryUpOneBed}:它会重新计算该工位的
     * {@code readyPatNum} 并刷新分流队列优先级。若不同步内存计数,
     * {@code hurryupOneCheckType} 会抛出 {@code exceed max queue number}。
     *
     * @return 受影响行数(0 表示没找到该患者今日的已完成记录)
     */
    @Override
    public Integer undoFinishPatient(Long roomId, String bedNo, String patId, Integer checkType) {
        Integer updateNum = queueMapper.undoFinishPatient(roomId, bedNo, patId, checkType,
                QueueStatusEnum.FINISH.getStatus(), QueueStatusEnum.READY.getStatus());
        if (null != updateNum && updateNum > 0) {
            startHurryUpOneBed(roomId, bedNo);
        }
        return updateNum;
    }
    /**
     * 撤销「误点叫号」:把本工位当前 就诊中(30) 的患者退回 **候诊中(20)**。
     *
     * @return 受影响行数(0 表示当前没有就诊中的患者)
     */
    @Override
    public Integer undoCallPatient(Long roomId, String bedNo) {
        Integer updateNum = queueMapper.undoCallPatient(roomId, bedNo,
                QueueStatusEnum.ONSTAGE.getStatus(), QueueStatusEnum.READY.getStatus());
        if (null != updateNum && updateNum > 0) {
            startHurryUpOneBed(roomId, bedNo);
        }
        return updateNum;
    }
    @Override
    public QueueDO getBedLastFinishedQueueItem(Long roomId, String bedNo) {
        return queueMapper.getBedLastFinishedQueueItem(roomId, bedNo, QueueStatusEnum.FINISH.getStatus());
    }
    // ==================== 需求3:停止候诊开关 ====================
    @Override
    public ErrorCode setStopReady(Long roomId, String bedNo, boolean stop) {
        // 走单线程执行器,与 bedDoctorPause/Resume 保持一致的串行化处理,
        // 避免与分流队列的并发修改冲突。
        Future<ErrorCode> future = singleThreadExecutor.submit(
                () -> queueServiceTxFunctions.setStopReady(roomId, bedNo, stop));
        try {
            return future.get();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.error("[setStopReady] 被中断 roomId={} bedNo={}", roomId, bedNo, e);
        } catch (ExecutionException e) {
            log.error("[setStopReady] 执行异常 roomId={} bedNo={}", roomId, bedNo, e);
        }
        return ECG_INNER_ERROR;
    }
    @Override
    public Boolean getStopReady(Long roomId, String bedNo) {
        return queueServiceTxFunctions.getStopReady(roomId, bedNo);
    }
    @Override
@@ -614,10 +952,14 @@
        queueStatusList.add(QueueStatusEnum.READY.getStatus());
        queueStatusList.add(QueueStatusEnum.ONSTAGE.getStatus());
        queueStatusList.add(QueueStatusEnum.PASSED.getStatus());
        // 【需求4 口径统一】补上 PASSED_WAITING(3)「已过号-排队」。
        // 原先列表只含 5,而大屏/诊间屏查询含 3,导致同一患者在不同界面出现与否不一致;
        // 统计侧也将 3 计入"过号"(见 getPatientStatistic / getBedDevReadyStatistic)。
        queueStatusList.add(QueueStatusEnum.PASSED_WAITING.getStatus());
        queueStatusList.add(QueueStatusEnum.AFFINITY.getStatus());
        List<QueueDO> queueDOList = getBedQueueByStatus(roomId, bedNo, queueStatusList);
        //把 [亲和的] 挪到主项后面
        //把 [亲和的] 挪到主项后面(同排队号 + 同患者才算一组,见 Utils#insertAffinityItemsAfterMain)
        List<QueueDO> affinityWaitingList = new ArrayList<>();
        ListIterator<QueueDO> iterator = queueDOList.listIterator();
        while (iterator.hasNext()) {
@@ -627,14 +969,7 @@
                affinityWaitingList.add(item);
            }
        }
        affinityWaitingList.forEach(affinityWaitingItem -> {
            for (int i = queueDOList.size() - 1; i >= 0; i--) {
                if (affinityWaitingItem.getSeqNum() == queueDOList.get(i).getSeqNum()) {
                    queueDOList.add(i + 1, affinityWaitingItem);
                    break;
                }
            }
        });
        Utils.insertAffinityItemsAfterMain(queueDOList, affinityWaitingList);
        return queueDOList;
    }
@@ -659,9 +994,31 @@
        queueStatusList.add(QueueStatusEnum.INSTALLING.getStatus());
        queueDOList.addAll(0, getBedQueueByStatus(roomId, bedNo, queueStatusList)); // 当前工位的
        //把 [亲和的] 挪到主项后面
        // ======================================================================
        // 【修复】只保留「需要设备领用」的检查项目。
        //
        // 背景:上面两处查询的条件是 `room_id = ? AND status IN (...)`
        // (getRoomQueueByStatus 甚至**不含 bed_no**),**完全不看 book_check_type**。
        // 而自动分流(QueueMapper#getFirstWaitingByCheckTypes + preemptWaitingPatientById)是**按工位 checkTypes 过滤**的,
        // 所以只有"人工把患者拉进来"这条路径会漏 —— 表现为
        // 「设备领用」页错误出现常规心电图(100)、平板运动心电(400)等**不需领用设备**的患者。
        //
        // 判定依据用配置 `check_type.need_dev_ready`:
        //   1 = 需要设备领用(如 动态心电 200、动态血压 300)
        //   0 = 不需要(常规心电图、平板运动心电 …)
        //
        // 放在服务层而不是改 SQL:一次查询得到集合后在内存过滤,逻辑集中、易读,
        // 且不需要为多数据源/动态表名额外调整 SQL。本诊室待安装患者量级很小(几十行)。
        // ======================================================================
        queueDOList = filterNeedDevReady(queueDOList);
        // 注意:下面用 lambda,捕获的变量必须是 effectively final;
        // 故这里另起一个 final 变量,而不是复用上面的 queueDOList(它已被重新赋值)。
        final List<QueueDO> installQueueList = queueDOList;
        //把 [亲和的] 挪到主项后面(同排队号 + 同患者才算一组,见 Utils#insertAffinityItemsAfterMain)
        List<QueueDO> affinityReceivedList = new ArrayList<>();
        ListIterator<QueueDO> iterator = queueDOList.listIterator();
        ListIterator<QueueDO> iterator = installQueueList.listIterator();
        while (iterator.hasNext()) {
            QueueDO item = iterator.next();
            if (item.getStatus() == QueueStatusEnum.AFFINITY_INSTALL.getStatus()) {
@@ -669,16 +1026,46 @@
                affinityReceivedList.add(item);
            }
        }
        affinityReceivedList.forEach(affinityReceivedItem -> {
            for (int i = queueDOList.size() - 1; i >= 0; i--) {
                if (affinityReceivedItem.getSeqNum() == queueDOList.get(i).getSeqNum()) {
                    queueDOList.add(i + 1, affinityReceivedItem);
                    break;
                }
        Utils.insertAffinityItemsAfterMain(installQueueList, affinityReceivedList);
        return installQueueList;
    }
    /**
     * 只保留「需要设备领用」({@code check_type.need_dev_ready = 1})的检查项目的患者。
     *
     * <p>用于「设备领用 / 装机」取数:这些工位只应处理需要领用设备的检查项目,
     * 否则会出现常规心电图等患者混入设备流程。
     *
     * <p>配置异常(查不到检查项目)时**返回原列表**,宁可多显示也不要把患者"藏起来"。
     */
    private List<QueueDO> filterNeedDevReady(List<QueueDO> queueDOList) {
        if (null == queueDOList || queueDOList.isEmpty()) {
            return queueDOList;
        }
        List<CheckTypeDO> needDevList = checkTypeMapper.selectList(
                new LambdaQueryWrapperX<CheckTypeDO>().eq(CheckTypeDO::getNeedDevReady, 1));
        if (null == needDevList || needDevList.isEmpty()) {
            log.warn("[filterNeedDevReady] 未配置任何[需要设备领用]的检查项目,跳过过滤以免患者全部不可见");
            return queueDOList;
        }
        Set<Integer> needDevCheckTypes = new HashSet<>();
        needDevList.forEach(ct -> {
            if (null != ct.getValue()) {
                needDevCheckTypes.add(ct.getValue());
            }
        });
        return queueDOList;
        List<QueueDO> filtered = new ArrayList<>();
        for (QueueDO item : queueDOList) {
            if (needDevCheckTypes.contains(item.getBookCheckType())) {
                filtered.add(item);
            } else {
                log.info("[filterNeedDevReady] 过滤掉不需要设备领用的患者. patId={} 检查项目={} 状态={}",
                        item.getPatId(), item.getBookCheckType(), item.getStatus());
            }
        }
        return filtered;
    }
    private RoomDO getRoomDO(Long roomId, String bedNo) {