package cn.lihu.jh.module.ecg.service.queue; import cn.lihu.jh.framework.common.exception.ErrorCode; import cn.lihu.jh.framework.common.exception.enums.GlobalErrorCodeConstants; import cn.lihu.jh.framework.common.util.object.BeanUtils; 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.checktype.CheckTypeDO; import cn.lihu.jh.module.ecg.dal.dataobject.queue.QueueDO; import cn.lihu.jh.module.ecg.dal.dataobject.room.RoomDO; import cn.lihu.jh.module.ecg.dal.mysql.call.CallMapper; import cn.lihu.jh.module.ecg.dal.mysql.checktype.CheckTypeMapper; import cn.lihu.jh.module.ecg.dal.mysql.devrent.DevRentMapper; import cn.lihu.jh.module.ecg.dal.mysql.queue.QueueMapper; import cn.lihu.jh.module.ecg.dal.mysql.room.RoomMapper; import cn.lihu.jh.module.ecg.enums.BedOpTypeEnum; import cn.lihu.jh.module.ecg.enums.BedStatusEnum; import cn.lihu.jh.module.ecg.enums.QueueStatusEnum; import cn.lihu.jh.module.infra.api.config.ConfigApi; import cn.lihu.jh.module.system.api.oauth2.OAuth2TokenApi; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.validation.annotation.Validated; import javax.annotation.Resource; import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; import static cn.lihu.jh.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.lihu.jh.framework.common.pojo.CommonResult.error; import static cn.lihu.jh.module.ecg.enums.ErrorCodeConstants.*; /** * 排队 Service 数据库事务相关的方法 * * @author 芋道源码 */ @Component @Validated @Slf4j public class QueueServiceTxFunctions { @Resource private ConfigApi configApi; @Resource private OAuth2TokenApi oAuth2TokenApi; @Resource private QueueMapper queueMapper; @Resource private RoomMapper roomMapper; @Resource private CallMapper callMapper; @Resource private DevRentMapper devRentMapper; @Resource private CheckTypeMapper checkTypeMapper; AtomicInteger openingFlag = new AtomicInteger(0); // 【号统一(本需求)】原 curSeqNum(分流时给患者编"内部序号"的内存计数器)已删除。 // 排队号改为签到时一次性写入 queue.seq_num,之后任何环节都不再赋号: // 分流、叫号后补齐候诊、过号、召回、加急、装机都只改状态/诊室/工位。 // 维护 开放的工位 ConcurrentHashMap mapOpeningBed = new ConcurrentHashMap<>(); // 装机工位..不进入分流队列 ConcurrentHashMap> mapCheckTypeRoutingQueue = new ConcurrentHashMap<>(); Map mapCheckTypeVsReadyMax = null; /** * 已关闭 或者 关闭中,可以开通工位 * @param roomId * @param bedNo * @return */ public ErrorCode bedOpen(Long roomId, String roomName, String bedNo) { BedBO bedBO2 = mapOpeningBed.get( Utils.formatRoomBed(roomId, bedNo) ); if (null != bedBO2) { log.error("bedOpen mapBedVsQueue has existed. " + roomId + " " + bedNo); return QUEUE_BED_EXIST; } // DB update List statusList = new ArrayList(); statusList.add(BedStatusEnum.CLOSED); Integer updateNum = roomMapper.setBedOpeningOpening(roomId, bedNo, BedStatusEnum.OPENING, statusList); if ( null==updateNum || 0 == updateNum ) { log.error("bedOpen DB invalid status. " + roomId + " " + bedNo); return ROOM_INVALID_STATUS; } RoomDO roomDO = roomMapper.getRoom(roomId, bedNo); // 新增..工位队列 BedBO bedBO = new BedBO(); bedBO.setRoomId(roomId); bedBO.setRoomName(roomName); bedBO.setBedNo(bedNo); bedBO.setStatus(BedStatusEnum.OPENING.getStatus()); bedBO.setCheckTypes( roomDO.getCheckTypes() ); bedBO.setOpType( roomDO.getOpType() ); mapOpeningBed.put( Utils.formatRoomBed(roomId, bedNo), bedBO); return GlobalErrorCodeConstants.SUCCESS; } public ErrorCode bedClose(Long roomId, String bedNo) { BedBO bedBO = mapOpeningBed.get( Utils.formatRoomBed(roomId, bedNo) ); if (null == bedBO) { log.error("bedClose mapBedVsQueue DONOT existed. " + roomId + " " + bedNo); return QUEUE_BED_NOT_EXIST; } // 检查是否还有患者需要处理 List queueStatusList = new ArrayList<>(); queueStatusList.add(QueueStatusEnum.READY.getStatus()); queueStatusList.add(QueueStatusEnum.ONSTAGE.getStatus()); queueStatusList.add(QueueStatusEnum.INSTALLING.getStatus()); queueStatusList.add(QueueStatusEnum.PASSED.getStatus()); queueStatusList.add(QueueStatusEnum.PASSED_INSTALL.getStatus()); queueStatusList.add(QueueStatusEnum.RECALLED.getStatus()); queueStatusList.add(QueueStatusEnum.RECALLED_INSTALL.getStatus()); queueStatusList.add(QueueStatusEnum.AFFINITY.getStatus()); queueStatusList.add(QueueStatusEnum.AFFINITY_INSTALL.getStatus()); List queueDOList = queueMapper.getBedQueueByStatus(roomId, bedNo, queueStatusList); if (queueDOList.size() > 0) return QUEUE_HAVE_PATIENT; // DB update List statusList = new ArrayList(); statusList.add(BedStatusEnum.OPENING); statusList.add(BedStatusEnum.DOCTOR_ON); statusList.add(BedStatusEnum.DOCTOR_PAUSE); Integer updateNum = roomMapper.setBedOpeningClosed(roomId, bedNo, BedStatusEnum.CLOSED, statusList); if ( null==updateNum || 0 == updateNum ) { log.error("bedClose DB invalid status. " + roomId + " " + bedNo); return ROOM_INVALID_STATUS; } mapOpeningBed.remove( Utils.formatRoomBed(roomId, bedNo) ); removeBedFromRoutingQueue(bedBO); return GlobalErrorCodeConstants.SUCCESS; } public ErrorCode bedDoctorPause(Long roomId, String bedNo, Long docId, String docName) { BedBO bedBO = mapOpeningBed.get( Utils.formatRoomBed(roomId, bedNo) ); if (null == bedBO) { log.error("bedDoctorPause mapBedVsQueue DONOT existed. " + roomId + " " + bedNo); return QUEUE_BED_NOT_EXIST; } // DB update List statusList = new ArrayList(); statusList.add(BedStatusEnum.DOCTOR_ON); Integer updateNum = roomMapper.setBedDoctorStatus(roomId, bedNo, docId, BedStatusEnum.DOCTOR_PAUSE, statusList); if ( null==updateNum || 0 == updateNum ) { log.error("bedDoctorPause DB invalid status. " + roomId + " " + bedNo); return ROOM_INVALID_STATUS; } bedBO.setStatus(BedStatusEnum.DOCTOR_PAUSE.getStatus()); removeBedFromRoutingQueue(bedBO); return GlobalErrorCodeConstants.SUCCESS; } public ErrorCode bedDoctorResume(Long roomId, String bedNo, Long docId, String docName) { BedBO bedBO = mapOpeningBed.get( Utils.formatRoomBed(roomId, bedNo) ); if (null == bedBO || !bedBO.getStatus().equals(BedStatusEnum.DOCTOR_PAUSE.getStatus())) { log.error("bedDoctorResume mapBedVsQueue DONOT existed OR NOT Paused. " + roomId + " " + bedNo); return QUEUE_BED_NOT_EXIST; } // DB update List statusList = new ArrayList(); statusList.add(BedStatusEnum.DOCTOR_PAUSE); Integer updateNum = roomMapper.setBedDoctorStatus(roomId, bedNo, docId, BedStatusEnum.DOCTOR_ON, statusList); if ( null==updateNum || 0 == updateNum ) { log.error("bedDoctorResume DB invalid status. " + roomId + " " + bedNo); return ROOM_INVALID_STATUS; } bedBO.setStatus(BedStatusEnum.DOCTOR_ON.getStatus()); addBedToRoutingQueue(bedBO); return GlobalErrorCodeConstants.SUCCESS; } // ==================== 需求3:停止候诊开关 ==================== /** * 设置「停止候诊」开关。 *

* {@code stop=true}:该工位**不再新增**候诊患者(移出分流队列), * 但**已有候诊患者仍可正常叫号**({@code nextPatient} 不检查此开关)。 *

* {@code stop=false}:恢复正常,重新加入分流队列开始接收患者 * (前提是工位状态为"医生在座",与 {@code bedReload} 的判定一致)。 *

* 状态写入 {@code clinic_room.stop_ready},以在 {@code bedReload} 后保持。 * * @return 成功返回 SUCCESS;工位不存在返回 {@link #QUEUE_BED_NOT_EXIST} */ public ErrorCode setStopReady(Long roomId, String bedNo, boolean stop) { BedBO bedBO = mapOpeningBed.get( Utils.formatRoomBed(roomId, bedNo) ); if (null == bedBO) { log.error("[setStopReady] 工位不存在. roomId={} bedNo={}", roomId, bedNo); return QUEUE_BED_NOT_EXIST; } // 先落库(失败则不改内存,避免状态不一致) // // 【部署保护】若数据库尚未执行升级脚本(缺 clinic_room.stop_ready 列), // roomMapper.setBedStopReady(...) 会在 Mapper 层直接抛 SQL 异常 // (@Update 注解方法没有返回值可判断,异常直接冒泡)。 // 这里显式捕获并转成明确的业务错误码,避免把原始 SQL 错误暴露给前端, // 便于现场一眼看出是"部署遗漏"而不是"功能坏了"。 Integer updateNum; try { updateNum = roomMapper.setBedStopReady(roomId, bedNo, stop ? 1 : 0); } catch (Exception e) { log.error("[setStopReady] 写库失败,请确认已执行 _prep/11_需求升级_汇总SQL.sql" + "(clinic_room.stop_ready). roomId={} bedNo={}", roomId, bedNo, e); return QUEUE_STOP_READY_NOT_CONFIGURED; } if (null == updateNum || 0 == updateNum) { // 工位可能尚未在 clinic_room 登记,仅告警但仍允许改内存, // 以免因 DB 记录缺失导致医生完全无法使用该功能。 log.warn("[setStopReady] DB 未更新到行,仅更新内存. roomId={} bedNo={} stop={}", roomId, bedNo, stop); } bedBO.setStopReady(stop); // 同步分流队列:停止 → 移出;恢复 → 加入(仅在医生在座时) if (stop) { removeBedFromRoutingQueue(bedBO); } else if (BedStatusEnum.DOCTOR_ON.getStatus().equals(bedBO.getStatus())) { addBedToRoutingQueue(bedBO); } log.info("[setStopReady] roomId={} bedNo={} stopReady={}", roomId, bedNo, stop); return GlobalErrorCodeConstants.SUCCESS; } /** 查询当前「停止候诊」状态(工位不存在返回 null) */ public Boolean getStopReady(Long roomId, String bedNo) { BedBO bedBO = mapOpeningBed.get( Utils.formatRoomBed(roomId, bedNo) ); return null == bedBO ? null : Boolean.TRUE.equals(bedBO.getStopReady()); } /** * 入座(把工位主人设为我)。 * * @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; } // 可以"成为主人"的状态:已开通 / 有医生 / 暂停(**排除"已关闭"**)。 // 【本次放宽】原来"无主人"时只认"已开通":一旦数据出现"doc_id 为空但状态仍是有医生/暂停" // 这种不一致,医生站在工位前却始终坐不上,只报"状态异常",现场无从处理。 List 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 ) { // 仍失败 → 把库里**真实状态**摊开,并区分原因(原来一律"状态异常",现场看不出所以然) 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; } /** * 手工把某位「排队中」患者拉入指定工位(工作台"本诊室排队中(未分配)"分区的「拉入」按钮)。 * *

与自动分流({@code hurryupOneCheckType})走**同一套**动作: * 条件更新「排队中(10) → 准备中(20)」并把 room/bed 写成本工位,然后处理亲和项。 * 号不变(号在签到时已定)。 * *

三重校验:工位存在 → 该行仍是「排队中」→ 检查项目在本工位开展范围内。 * 条件更新自带并发保护:两个工位同时点「拉入」,只有一个能成功。 */ 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) { BedBO bedBO = mapOpeningBed.get( Utils.formatRoomBed( roomId, bedNo ) ); if (null == bedBO) { log.error("bedDoctorOff mapBedVsQueue DONOT existed. " + roomId + " " + bedNo); return QUEUE_BED_NOT_EXIST; } // DB update List statusList = new ArrayList(); statusList.add(BedStatusEnum.DOCTOR_ON); statusList.add(BedStatusEnum.DOCTOR_PAUSE); Integer updateNum = roomMapper.setBedDoctorOff(roomId, bedNo, docId, BedStatusEnum.OPENING, statusList); if ( null==updateNum || 0 == updateNum ) { log.error("bedDoctorOff DB invalid status. " + roomId + " " + bedNo); return ROOM_INVALID_STATUS; } removeBedFromRoutingQueue(bedBO); bedBO.setStatus(BedStatusEnum.OPENING.getStatus()); return GlobalErrorCodeConstants.SUCCESS; } /** * 【本次需求】管理员强制清空工位(不要求是本人)。 *

* 与 {@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(); 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 清空)。 *

* 与 {@link #closeBiz()} 配合:先清库,随后 {@code startBedReload()} 会按库重新装载内存, * 保证内存(mapOpeningBed / 分流队列)与库一致。 * * @return 清空的工位数 */ public int closeBizClearBeds() { List statusList = new ArrayList(); 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) { // 从 DB 把该工位.序号最小的 [候诊中中] 的人 设置为 [就诊中] (或领用中) Integer updateNum = queueMapper.updateBedQueueStatus(roomId, bedNo, QueueStatusEnum.READY.getStatus(), QueueStatusEnum.ONSTAGE.getStatus()); // 该工位 没有 [候诊中中] 人员 if (null == updateNum || 0 == updateNum) { return QUEUE_NOT_READY_PATIENT; } // 分流队列中 该工位 候诊中中人的数量 减一 BedQueueBO bo = mapBedVsQueue.get( Utils.formatRoomBed( roomId, bedNo ) ); bo.queueNum.getAndDecrement(); // 可能已经【并发的】在 hurry-up 中改变了值 refreshRoutingQueue(bo); return GlobalErrorCodeConstants.SUCCESS; } */ // 常规 或者 领用 时 医生 取下一位患者 public ErrorCode nextPatient(Long roomId, String bedNo) { // ⓪ 【并发加固·本需求】本工位**已有「就诊中(30)」患者时不再叫下一位**。 // // 原先这个判断只在 `QueueServiceImpl#finishNextPatient` 里做过,而它位于 // singleThreadExecutor 串行区**之外**:两个并发请求(双击 / 多端)都可能读到 0, // 随后各自提交一次 nextPatient,串行执行时会各推进一条 READY ⇒ // 同一工位出现 2 个「就诊中」,患者被"卡"在就诊中、工位也不再补人。 // 这里在串行区内再判一次(本方法只由该串行线程池调用)。 List 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; } // ③ 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()); // 分流队列中 该工位 【候诊中】患者数量 减一 // 注意:召回的这位本来就不在候诊计数里,不能减;另外 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; } // 装机时 医生 取下一位患者 public ErrorCode nextInstallPatient(Long roomId, String bedNo) { // 从 DB 把 序号最小的 [已领用] [已召回-安装] 的人 设置为 [安装中] QueueDO firstItem = queueMapper.getRoomFirstQueueItemByStatus(roomId, List.of(QueueStatusEnum.RECEIVED.getStatus(), QueueStatusEnum.RECALLED_INSTALL.getStatus())); // 该工位 没有 [已领用] | [已召回-安装] 人员 if (null == firstItem) { return QUEUE_NOT_READY_PATIENT; } firstItem.setStatus( QueueStatusEnum.INSTALLING.getStatus() ); firstItem.setBedNo( bedNo ); // 转到 安装工位 queueMapper.updateById( firstItem ); // 检查项目.亲和性 处理逻辑: 变为【安装中】后 拉 其他检查项来亲和 CheckTypeDO checkTypeDO = getCheckTypeItem( firstItem.getBookCheckType() ); if (checkTypeDO.getAffinityCheckTypes().length > 0) { procAffinityWhenSomeOneEntryInstalling(firstItem.getPatId(), roomId, bedNo, checkTypeDO.getAffinityCheckTypes() ); } // 装机工位 不参与分流 return GlobalErrorCodeConstants.SUCCESS; } /** * 1. 每天开诊前 从DB同步工位的患者队列数据到 工位分流队列 * 2. 服务运维重启时 */ public ErrorCode bedReload() { mapCheckTypeRoutingQueue.clear(); mapOpeningBed.clear(); // 清除非当天的排队人员 queueMapper.clearQueue(); // 清除非当天的语音叫号记录 callMapper.clearCall(); // 从DB 获取 工位列表 List bedStatusEnumList = new ArrayList(); bedStatusEnumList.add(BedStatusEnum.OPENING); bedStatusEnumList.add(BedStatusEnum.DOCTOR_ON); bedStatusEnumList.add(BedStatusEnum.DOCTOR_PAUSE); List roomDOList = roomMapper.simpleRoomList(bedStatusEnumList); List bedBOList = roomDOList.stream().map(item -> BeanUtils.toBean(item, BedBO.class)).toList(); bedBOList.forEach(item -> { mapOpeningBed.put( Utils.formatRoomBed(item.roomId, item.bedNo), item); // 【需求3】「停止候诊」的工位不加入分流队列 —— 与医生在座(status=DOCTOR_ON)同样判定, // 否则每次 bedReload(开诊/重启/诊室重置)后开关会失效、又开始收病人。 // 注意:stopReady 仅阻止**新增**候诊患者,已有候诊患者仍可正常叫号 // (nextPatient 不检查该开关)。 if (BedStatusEnum.DOCTOR_ON.getStatus() == item.getStatus() && !Boolean.TRUE.equals(item.getStopReady())) { addBedToRoutingQueue(item); } }); // 【号统一(本需求)】不再初始化内存计数器 curSeqNum: // 排队号来自签到时的 queue.seq_num,分流不再分配号。 return GlobalErrorCodeConstants.SUCCESS; } public void resetRoom(Boolean needCloseBed) { if (needCloseBed) { // 踢出在座的医生 List bedStatusEnumList = new ArrayList(); bedStatusEnumList.add(BedStatusEnum.DOCTOR_ON); bedStatusEnumList.add(BedStatusEnum.DOCTOR_PAUSE); List roomDOList = roomMapper.simpleRoomList(bedStatusEnumList); List userIdList = roomDOList.stream().map(roomDO -> roomDO.getDocId()).toList(); oAuth2TokenApi.tick(userIdList); // 所有工位状态 => 关闭 Integer ret = roomMapper.resetRoom(BedStatusEnum.CLOSED.getStatus()); } } public void hurryupAllBed() { if (0 == openingFlag.get()) return; mapOpeningBed.values().forEach(bedQueueBO -> hurryupOneBed(bedQueueBO.roomId, bedQueueBO.bedNo)); } /** * 【周期兜底专用】只给**有医生在座(DOCTOR_ON)**的工位补人。 * *

为什么不能直接用 {@link #hurryupAllBed()}:它遍历的是 `mapOpeningBed` * (含「已开通但没人」的工位),会把排队患者推进**没有医生**的工位候诊区 —— * 同一检查项目有多个工位时(如常规心电图 116/117/118),患者可能被"分配"到 * 一个没人看的工位,真正在座的医生反而看不到。该方法原本只在开诊/重启时跑一次, * 若改成每分钟跑一遍,这个副作用会被放大。因此周期兜底只补在座工位, * 与自动分流队列({@code addBedToRoutingQueue} 只收 DOCTOR_ON)的口径保持一致。 */ public void hurryupAllSeatedBeds() { if (0 == openingFlag.get()) return; mapOpeningBed.values().forEach(bedQueueBO -> { // 只补「有医生在座」的工位 if (!Objects.equals(BedStatusEnum.DOCTOR_ON.getStatus(), bedQueueBO.getStatus())) { return; } // 「停止候诊」的工位直接跳过:hurryupOneBed 内部本来也会跳过, // 这里提前判断是为了避免每分钟刷一条 "[补人] 工位处于【停止候诊】" 的 WARN。 if (Boolean.TRUE.equals(bedQueueBO.getStopReady())) { return; } hurryupOneBed(bedQueueBO.roomId, bedQueueBO.bedNo); }); } /** * 把医生候诊的队列塞满 */ public void hurryupOneBed(Long roomId, String bedNo) { 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 不检查本开关), // 也不影响医生对具体患者的"召回"操作本身(只影响其自动进入候诊的补位)。 boolean stopReady = Boolean.TRUE.equals(bedBO.getStopReady()); // 处理 过号-回来 的人 while (!stopReady && bedBO.getReadyPatNum() < bedBO.getMaxReadyPatNum()) { // 查看 当前工位 是否有「已召回(15)」的患者(排队号最小的那一位) QueueDO recalledQueueItem = queueMapper.getBedFirstQueueItemByStatus( bedBO.getRoomId(), bedBO.getBedNo(), QueueStatusEnum.RECALLED.getStatus()); if (null == recalledQueueItem) break; // 【号统一(本需求)】过号召回**不再重排队号**:号是签到时定的, // 患者按原号回到候诊序列(需求:「病人过号了,也排着」)。 // 旧实现(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()); } // 可能已经在 nextPatient 中改变了值 bedBO.readyPatNum++; // 可能已经【并发的】在 nextPatient 中改变了分流队列顺序 refreshBedPriorityLevelInRoutingQueue(bedBO); } // 处理 排队中 患者 hurryupOneBedCheckTypeList(bedBO); } /** * 一个工位可以支持多个检查项目,所以需要保证多个检查项目的就诊时间先后顺序 * 按照一个检查项目来处理,就不能保证同一个工位的多个检查项目的就诊时间的先后顺序 * 所以实现逻辑是 让支持该检查项目的所有工位去抢占 【排队中】【工位支持的检查项目】的患者 * 实际抢占的未必是 参数中的检查项目的患者,可能是其他检查项目的患者 * @param checkType */ public void hurryupOneCheckType(Integer checkType) { // 【修复既有缺陷】补上开诊门闸。原实现**唯独这里漏了** openingFlag 检查 // (hurryupAllBed 与 hurryupOneBed 都有),导致手动"闭诊"后, // 只要有人预约确认 / 加急,患者仍会被推进候诊区。 if (0 == openingFlag.get()) return; PriorityBlockingQueue routingQueue = mapCheckTypeRoutingQueue.get(checkType); if (null == routingQueue) return; while (true) { BedBO bedBO = routingQueue.peek(); if (null == bedBO) return; // 【需求3】跳过处于「停止候诊」的工位。 // 不能直接 return —— 该队列按 readyPatNum 升序 peek, // 直接 return 会让其余工位也收不到患者(饿死)。 // 这里 poll 出来后若命中停止态,仅将其从队列移除(不再 offer 回去), // 然后 continue 处理下一个工位。 if (Boolean.TRUE.equals(bedBO.getStopReady())) { routingQueue.poll(); log.info("[hurryupOneCheckType] 工位[停止候诊],移出分流队列. roomId={} bedNo={}", bedBO.getRoomId(), bedBO.getBedNo()); continue; } int curQueueNum = bedBO.getReadyPatNum(); if (curQueueNum > bedBO.getMaxReadyPatNum()) throw new RuntimeException("hurryupOneCheckType: exceed max queue number!"); if (curQueueNum == bedBO.getMaxReadyPatNum()) return; // 查看 是否有排队中的患者 // // 【号统一(本需求)】分流只做「排队中(10) → 候诊中(20)」的搬迁,**不再分配排队号**: // 号在签到时已写入 seq_num;加急只影响这里的取人顺序(SQL 内 jump_flag 优先)。 // 旧实现是「UPDATE ... 顺带 seq_num=当前号+1,再按号查回该行」—— // 号不再唯一后"按号查回"会取错人,故改为:先取行 → 按 id 条件更新。 QueueDO preemptQueueItem = queueMapper.getFirstWaitingByCheckTypes( QueueStatusEnum.WAITING.getStatus(), bedBO.checkTypes); //关键点 关键点 关键点 checkType // 没有排队中的患者 if (null == preemptQueueItem) { return; } // 条件更新: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()); } // 可能已经在 nextPatient 中改变了值 bedBO.readyPatNum++; // 可能已经【并发的】在 nextPatient 中改变了分流队列顺序 refreshBedPriorityLevelInRoutingQueue(bedBO); } } public Integer getOpeningFlag() { return openingFlag.get(); } public void setOpeningFlag(Integer flag) { openingFlag.set(flag); } public void initCheckType() { List checkTypeDOList = checkTypeMapper.simpleCheckTypeList(); mapCheckTypeVsReadyMax = checkTypeDOList.stream().collect(Collectors.toMap(CheckTypeDO::getValue, checkType -> checkType)); } public CheckTypeDO getCheckTypeItem(Integer checkType) { return mapCheckTypeVsReadyMax.get( checkType ); } public BedBO getBedQueueBO(Long roomId, String bedNo) { return mapOpeningBed.get( Utils.formatRoomBed(roomId, bedNo) ); } public MonitorInfoVO getMonitorInfo() { MonitorInfoVO monitorInfoVO = new MonitorInfoVO(); monitorInfoVO.setOpeningFlag( openingFlag.get() ); roomBedStatistic(monitorInfoVO); return monitorInfoVO; } public void monitorInfo() { MonitorInfoVO monitorInfoVO = new MonitorInfoVO(); roomBedStatistic(monitorInfoVO); log.info(" opening " + openingFlag.get() + " " + monitorInfoVO.getOpeningBedNum() + " " + monitorInfoVO.getDocBedNum() + " " + monitorInfoVO.getCheckTypeBedInfo().toString() ); } private void hurryupOneBedCheckTypeList(BedBO bedBO) { // 【需求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()) { // 抢占 是否有排队中的患者 // // 【号统一(本需求)】与 hurryupOneCheckType 相同:只搬迁状态,**不再分配排队号**。 QueueDO preemptQueueItem = queueMapper.getFirstWaitingByCheckTypes( QueueStatusEnum.WAITING.getStatus(), bedBO.checkTypes); // 没有排队中的患者 if (null == preemptQueueItem) { return; } // 条件更新: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()); } 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 readyStatus = new ArrayList<>(); readyStatus.add(QueueStatusEnum.READY.getStatus()); List readyList = queueMapper.getBedQueueByStatus(roomId, bedNo, readyStatus); return null == readyList ? 0 : readyList.size(); } /** * 立即补人(现场自救 + 自助诊断):手动触发一次该工位的自动分流,并把"为什么没补/补了几人"一起返回。 * *

用于工作台候诊区为空时的「立即补人」按钮:不必等定时任务、也不用重启去猜是哪道门闸挡住的。 */ public Map hurryUpNow(Long roomId, String bedNo) { Map 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; } /** * 有检查项分流到诊室后,也就是进入[候诊中]后,拉 [排队中] + 具有亲和性的 检查项 来依附该检查项。 * *

【亲和项与排队号】亲和项目(同一患者、可在一个工位一起做的多个检查项)在候诊区 * 跟随主项显示:这里把亲和项的排队号改成**主项的号**、并搬到主项所在诊室/工位, * 界面(工作台/诊间屏/大屏)才能把两者排在一起。 *

注意这**不是**"重新赋号":号仍是签到时定的那个,只是亲和项沿用主项的号, * 与"分流/叫号/召回不再改号"并不冲突。 */ private void procAffinityWhenPreempt(QueueDO preemptQueueItem, Integer[] affinityCheckTypes) { List affinityItems = queueMapper.getCurPatGivenCheckTypesAndStatus( preemptQueueItem.getPatId(), affinityCheckTypes, QueueStatusEnum.WAITING.getStatus()); for (int i = 0; i < affinityItems.size(); i++) { QueueDO queueItem = affinityItems.get(i); queueItem.setStatus(QueueStatusEnum.AFFINITY.getStatus()); //改变 排队状态 queueItem.setAffinityItem( 1 ); queueItem.setRoomId(preemptQueueItem.getRoomId()); queueItem.setRoomName(preemptQueueItem.getRoomName()); queueItem.setBedNo(preemptQueueItem.getBedNo()); queueItem.setSeqNum( preemptQueueItem.getSeqNum()); // 亲和项沿用主项的排队号(跟随显示) queueMapper.updateById(queueItem); } } /** * [过号] 患者 => [候诊中] ,处理 检查项目.亲和性 逻辑。 * *

召回时主项的号不变,因此只需让已经跟随的亲和项保持一致(保持同号 → 界面仍排在一起)。 */ private void procAffinityWhenRecalled(QueueDO recalledQueueItem, Integer[] affinityCheckTypes) { List 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() ); // 亲和项与主项保持同一个号 queueMapper.updateById(queueItem); } } /** * 工位「候诊区容量上限」= 该工位开展的**各检查项目** {@code check_type.ready_num} * (页面字段:「检查类型管理」→「候诊准备人数」)的**最大值**。 * *

返回 0 表示"不自动分流":此时排队中(10) 的患者**不会**被主动搬进候诊区(20), * 需要医生在候诊区用「拉入」手动补人(现场确认的口径)。 * *

【本次加固】 *

    *
  • 实时读库:原来这份配置只在服务启动时加载一次 * ({@code MySpringEventListener} → {@code initCheckType()}), * 在页面上改了「候诊准备人数」必须**重启服务**才生效;
  • *
  • 容错:检查项目在配置里查不到、或 {@code ready_num} 为空 → **跳过并记警告**, * 不再抛 NPE。原先这个 NPE 发生在 {@code addBedToRoutingQueue}(入座/开诊/重载都会走), * 会连带把入座弄失败。
  • *
*/ private Integer getBedReadyMax(BedBO bedBO) { Integer[] checkTypes = bedBO.getCheckTypes(); if (null == checkTypes || 0 == checkTypes.length) { return 0; } Map fresh = readCheckTypeMapFresh(); int ret = 0; for (Integer checkType : checkTypes) { 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(); if (null == tmp) { log.warn("[getBedReadyMax] 检查项目[候诊准备人数]为空,跳过. checkType={} name={}", checkType, checkTypeDO.getName()); continue; } ret = Math.max(ret, tmp); //【候诊中】数量 取大值 } return ret; } /** * 实时读取「检查项目值 → 检查项目配置」映射(每次装配工位时读一次全表,检查项目只有十几个,量级很小)。 * 这样管理员在「检查类型管理」里改了「候诊准备人数」**立刻生效,不用重启服务**。 * 读库失败时回落到启动时加载的缓存({@link #initCheckType()})。 */ private Map readCheckTypeMapFresh() { try { List list = checkTypeMapper.simpleCheckTypeList(); Map 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) { // 装机工位 不进 分流队列,不接收 【排队中】患者分流 if (Objects.equals(BedOpTypeEnum.OP_INSTALL.getOpType(), bedBO.getOpType())) return; // 【需求3】「停止候诊」的工位不进分流队列。 // 这是**唯一入口**的统一门闸:只要工位被标记为停止候诊, // 无论从哪条路径(开诊 addBedToRoutingQueue、医生恢复、bedReload、 // 或手动恢复开关)试图加入,都会被拒绝,从而保证: // · 已有候诊患者仍可正常叫号(nextPatient 不检查该开关) // · 但不会再被分配【排队中】的新患者 if (Boolean.TRUE.equals(bedBO.getStopReady())) { log.info("[addBedToRoutingQueue] 工位处于[停止候诊],跳过加入分流队列. roomId={} bedNo={}", bedBO.getRoomId(), bedBO.getBedNo()); return; } // 同步DB, 设置工位的分流控制信息 List queueStatusList = new ArrayList<>(); queueStatusList.add(QueueStatusEnum.READY.getStatus()); //[候诊中] List 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 -> { CheckTypeDO checkTypeDO = mapCheckTypeVsReadyMax.get(checkType); PriorityBlockingQueue routingQueue = mapCheckTypeRoutingQueue.get(checkType); if (null == routingQueue) { routingQueue = new PriorityBlockingQueue(); mapCheckTypeRoutingQueue.put( checkType, routingQueue ); } if (!routingQueue.contains(bedBO)) { routingQueue.offer(bedBO); } else { log.error("bedDoctorResume routingQueue still exist!. " + bedBO.getRoomId() + " " + bedBO.getBedNo()); } }); } private void removeBedFromRoutingQueue(BedBO bedBO) { // 装机工位 不进 分流队列,不接收 【排队中】患者分流 if (Objects.equals(BedOpTypeEnum.OP_INSTALL.getOpType(), bedBO.getOpType())) return; Integer[] checkTypes = bedBO.getCheckTypes(); Arrays.stream(checkTypes).forEach(checkType -> { PriorityBlockingQueue routingQueue = mapCheckTypeRoutingQueue.get(checkType); if (null != routingQueue) routingQueue.remove(bedBO); }); } private void refreshBedPriorityLevelInRoutingQueue(BedBO bedBO) { // 装机工位 不进 分流队列,不接收 【排队中】患者分流 if (Objects.equals(BedOpTypeEnum.OP_INSTALL.getOpType(), bedBO.getOpType())) return; Integer[] checkTypes = bedBO.getCheckTypes(); Arrays.stream(checkTypes).forEach(checkType -> { PriorityBlockingQueue routingQueue = mapCheckTypeRoutingQueue.get(checkType); if (null != routingQueue) { routingQueue.remove(bedBO); routingQueue.offer(bedBO); } }); } /** * 检查项进入到 [安装中] 时,拉 [已领用] + 具有亲和性的 检查项 来依附该检查项 */ private void procAffinityWhenSomeOneEntryInstalling(String patId, Long roomId_operator, String bedNo_operator, Integer[] affinityCheckTypes) { List affinityItems = queueMapper.getCurPatGivenCheckTypesAndStatus( patId, affinityCheckTypes, QueueStatusEnum.RECEIVED.getStatus()); for (int i = 0; i < affinityItems.size(); i++) { QueueDO queueItem = affinityItems.get(i); queueItem.setStatus(QueueStatusEnum.AFFINITY_INSTALL.getStatus()); //改变 排队状态 queueItem.setAffinityItem(1); queueItem.setRoomId(roomId_operator); queueItem.setBedNo(bedNo_operator); // 跟随到 装机 工位 queueMapper.updateById(queueItem); } } private void roomBedStatistic(MonitorInfoVO monitorInfoVO) { Map mapOpeningCheckBedStatInfo = new HashMap<>(); Map mapOpeningInstallBedStatInfo = new HashMap<>(); Map mapDocCheckBedStatInfo = new HashMap<>(); Map mapDocInstallBedStatInfo = new HashMap<>(); mapCheckTypeVsReadyMax.keySet().forEach( checkType -> { mapOpeningCheckBedStatInfo.put(checkType, 0); mapOpeningInstallBedStatInfo.put(checkType, 0); mapDocCheckBedStatInfo.put(checkType, 0); mapDocInstallBedStatInfo.put(checkType, 0); }); //开通的 和 运营中 的工位统计 mapOpeningBed.values().forEach(bedQueueBO -> { Integer[] checkTypes = bedQueueBO.getCheckTypes(); Arrays.stream(checkTypes).forEach(checkType -> { if (bedQueueBO.opType == 0 || bedQueueBO.opType == 1) mapOpeningCheckBedStatInfo.put(checkType, mapOpeningCheckBedStatInfo.get(checkType) + 1 ); else mapOpeningInstallBedStatInfo.put(checkType, mapOpeningInstallBedStatInfo.get(checkType) + 1 ); if (bedQueueBO.getStatus() == BedStatusEnum.DOCTOR_ON.getStatus() || bedQueueBO.getStatus() == BedStatusEnum.DOCTOR_PAUSE.getStatus()) { if (bedQueueBO.opType == 0 || bedQueueBO.opType == 1) mapDocCheckBedStatInfo.put(checkType, mapDocCheckBedStatInfo.get(checkType) + 1); else mapDocInstallBedStatInfo.put(checkType, mapDocInstallBedStatInfo.get(checkType) + 1); } }); }); // 接收..排队中患者..的分流队列 HashSet hashSetOperatingBedQueue = new HashSet<>(); mapCheckTypeRoutingQueue.values().forEach(routingQueue->{ routingQueue.stream().forEach(bedQueueBO -> hashSetOperatingBedQueue.add(bedQueueBO)); }); Map mapCheckTypeBedInfo = new HashMap<>(); mapCheckTypeVsReadyMax.keySet().forEach( checkType -> { String str = mapOpeningCheckBedStatInfo.get(checkType) + " " + mapOpeningInstallBedStatInfo.get(checkType) + " " + mapDocCheckBedStatInfo.get(checkType) + " " + mapDocInstallBedStatInfo.get(checkType) + " "; mapCheckTypeBedInfo.put( checkType, str); }); monitorInfoVO.setCheckTypeBedInfo(mapCheckTypeBedInfo); monitorInfoVO.setOpeningBedNum(mapOpeningBed.size()); monitorInfoVO.setDocBedNum((int) mapOpeningBed.values().stream().filter(item -> item.getStatus() == BedStatusEnum.DOCTOR_ON.getStatus() || item.getStatus() == BedStatusEnum.DOCTOR_PAUSE.getStatus()) .count()); // 【本需求·统计按人合并】在座**医生数**:一人多工位时上面的 docBedNum 会按工位累计, // 这里按 doc_id 去重,给监控页一个"到底几个医生在岗"的指标。 List 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); } }