yxh
5 天以前 68ff33beff11310e830b0615b5a81f83fad0f683
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/queue/QueueServiceImpl.java
@@ -2,6 +2,7 @@
import java.util.*;
import java.util.concurrent.*;
import java.util.stream.Collectors;
import javax.annotation.Resource;
import cn.lihu.jh.module.ecg.Utils;
@@ -192,9 +193,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();
@@ -223,6 +256,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;
    }
@@ -265,6 +319,12 @@
        singleThreadExecutor.execute(() -> {
            queueServiceTxFunctions.hurryupOneBed(roomId, bedNo);
        });
    }
    @Override
    public Map<String, Object> hurryUpNow(Long roomId, String bedNo) {
        // 同步执行:要立刻返回"补人前后人数 + 结论",异步就看不到结果了
        return queueServiceTxFunctions.hurryUpNow(roomId, bedNo);
    }
    @Override
@@ -365,8 +425,8 @@
    }
    @Override
    public List<QueueDO> selectBookSeqNumByAppointIdList(List<Long> appointIdList) {
        return queueMapper.selectBookSeqNumByAppointIdList(appointIdList);
    public List<QueueDO> selectQueueByAppointIdList(List<Long> appointIdList) {
        return queueMapper.selectQueueByAppointIdList(appointIdList);
    }
    // 常规检查 叫号
@@ -427,7 +487,11 @@
        // 从 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) {
@@ -439,19 +503,57 @@
    /**
     * 从预约单补齐 queue 表上没有的患者信息。
     *
     * queue 表不存这些字段,但医生端列表接口需要展示,所以查询后按 applyNo 回查预约单填充:
     * 多耐药、性别、出生日期、登记时间、开单医师、开单日期。
     * <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 (null == queueDO.getApplyNo() || queueDO.getApplyNo().isEmpty()) {
            if (queueDO == null || queueDO.getApplyNo() == null || queueDO.getApplyNo().isEmpty()) {
                return;
            }
            AppointmentDO appointment = appointmentMapper.getByApplyNo(queueDO.getApplyNo());
            AppointmentDO appointment = appointmentMap.get(queueDO.getApplyNo());
            if (null == appointment) {
                return;
            }
@@ -624,6 +726,19 @@
    public void closeBiz() {
        queueServiceTxFunctions.setOpeningFlag(0);
        // 【本次需求】闭诊时清空当班工位(doc_id / doc_name 置空,状态改回空闲)。
        //
        // 背景:工位不再因「空闲超时 / 令牌失效 / 刷新页面」被释放(避免医生离开一会儿就丢工位),
        // 因此需要一个兜底 —— 每天闭诊时统一清空,防止医生下班忘离座导致工位长期被占用。
        // 先清库,再由下面的 startBedReload() 按库重新装载内存,保证二者一致。
        try {
            queueServiceTxFunctions.closeBizClearBeds();
        } catch (Exception e) {
            // 清空失败不能阻断闭诊流程本身
            log.error("[闭诊] 清空当班工位失败(不影响闭诊)", e);
        }
        startBedReload();
    }
@@ -635,6 +750,25 @@
        // 会在 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;
    }
@@ -764,7 +898,7 @@
        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()) {
@@ -774,14 +908,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;
    }
@@ -811,7 +938,7 @@
        //
        // 背景:上面两处查询的条件是 `room_id = ? AND status IN (...)`
        // (getRoomQueueByStatus 甚至**不含 bed_no**),**完全不看 book_check_type**。
        // 而自动分流(preemptWaitingPatientWithBedCheckTypes)是**按工位 checkTypes 过滤**的,
        // 而自动分流(QueueMapper#getFirstWaitingByCheckTypes + preemptWaitingPatientById)是**按工位 checkTypes 过滤**的,
        // 所以只有"人工把患者拉进来"这条路径会漏 —— 表现为
        // 「设备领用」页错误出现常规心电图(100)、平板运动心电(400)等**不需领用设备**的患者。
        //
@@ -828,7 +955,7 @@
        // 故这里另起一个 final 变量,而不是复用上面的 queueDOList(它已被重新赋值)。
        final List<QueueDO> installQueueList = queueDOList;
        //把 [亲和的] 挪到主项后面
        //把 [亲和的] 挪到主项后面(同排队号 + 同患者才算一组,见 Utils#insertAffinityItemsAfterMain)
        List<QueueDO> affinityReceivedList = new ArrayList<>();
        ListIterator<QueueDO> iterator = installQueueList.listIterator();
        while (iterator.hasNext()) {
@@ -838,14 +965,7 @@
                affinityReceivedList.add(item);
            }
        }
        affinityReceivedList.forEach(affinityReceivedItem -> {
            for (int i = installQueueList.size() - 1; i >= 0; i--) {
                if (affinityReceivedItem.getSeqNum() == installQueueList.get(i).getSeqNum()) {
                    installQueueList.add(i + 1, affinityReceivedItem);
                    break;
                }
            }
        });
        Utils.insertAffinityItemsAfterMain(installQueueList, affinityReceivedList);
        return installQueueList;
    }