yxh
6 天以前 68ff33beff11310e830b0615b5a81f83fad0f683
ai优化
已修改49个文件
已添加20个文件
7775 ■■■■■ 文件已修改
jh-framework/jh-spring-boot-starter-excel/src/main/java/cn/lihu/jh/framework/excel/core/convert/DictConvert.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-framework/jh-spring-boot-starter-web/src/main/java/cn/lihu/jh/framework/web/core/handler/GlobalExceptionHandler.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-api/src/main/java/cn/lihu/jh/module/ecg/enums/ErrorCodeConstants.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-api/src/main/java/cn/lihu/jh/module/ecg/enums/QueueStatusEnum.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-api/src/main/java/cn/lihu/jh/module/ecg/enums/SeqRuleEnum.java 150 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/Constants.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/Utils.java 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/annotation/RequireBedOwner.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/aspect/BedOwnerAspect.java 108 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/appointment/AppointmentController.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/appointment/vo/AppointmentConfirmReqVO.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/appointment/vo/AppointmentPageReqVO.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/appointment/vo/AppointmentRespVO.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/callingscreen/CallingScreenController.java 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/callingscreen/ScreenQueueRespVO.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/checktype/CheckTypeController.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/devrent/DevRentController.java 388 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/devrent/vo/RoutineFinishReqVO.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/doctor/DoctorController.java 103 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/jobrecord/JobRecordController.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/jobrecord/vo/JobRecordRespVO.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/queue/vo/QueuePageReqVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/queue/vo/QueueRespVO.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/queue/vo/QueueSaveReqVO.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/room/RoomController.java 287 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/room/vo/MonitorInfoVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/room/vo/RoomRespVO.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/room/vo/RoomSeatRespVO.java 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/room/vo/RoomSelectBedRespVO.java 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/dataobject/jobrecord/JobRecordDO.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/dataobject/queue/QueueDO.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/dataobject/queuesequence/SeqCounterDO.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/mysql/appointment/AppointmentMapper.java 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/mysql/call/CallMapper.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/mysql/devrent/DevRentMapper.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/mysql/queue/QueueMapper.java 221 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/mysql/queuesequence/QueueSequenceMapper.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/mysql/room/RoomMapper.java 207 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/enums/HisCheckNameEnum.java 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/enums/HisCheckStatusEnum.java 110 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/appointment/AppointmentService.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/appointment/AppointmentServiceImpl.java 674 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/call/CallServiceImpl.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/callingscreen/CallingScreenServiceImpl.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/config/EcgConfigServiceImpl.java 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/devrent/DevRentService.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/devrent/DevRentServiceImpl.java 235 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/his/HisEcgFilter.java 454 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/jobrecord/JobRecordServiceImpl.java 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/queue/BedDoctorOnCallable.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/queue/QueueService.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/queue/QueueServiceImpl.java 176 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/queue/QueueServiceTxFunctions.java 576 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/queuesequence/QueueSequenceService.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/queuesequence/QueueSequenceServiceImpl.java 281 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/room/RoomService.java 133 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/room/RoomServiceImpl.java 403 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/utils/IpUtils.java 258 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/test/java/cn/lihu/jh/module/ecg/dal/mysql/queuesequence/SeqCounterInterceptorIgnoreTest.java 76 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/test/java/cn/lihu/jh/module/ecg/enums/HisCheckStatusEnumTest.java 108 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/test/java/cn/lihu/jh/module/ecg/enums/SeqRuleEnumTest.java 232 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/test/java/cn/lihu/jh/module/ecg/service/appointment/HisCheckAppointmentCheckTypeTest.java 110 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/test/java/cn/lihu/jh/module/ecg/service/appointment/HisDoctorTimeParseTest.java 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/test/java/cn/lihu/jh/module/ecg/service/appointment/HisParseSafetyTest.java 388 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/test/java/cn/lihu/jh/module/ecg/service/appointment/RegisterDateTruncationTest.java 139 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/test/java/cn/lihu/jh/module/ecg/service/his/HisEcgFilterTest.java 328 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-server/src/main/java/cn/lihu/jh/server/ecg/FrontendAuthorizeRequestsCustomizer.java 106 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-server/src/main/java/cn/lihu/jh/server/ecg/FrontendResourceConfig.java 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-server/src/main/java/cn/lihu/jh/server/ecg/SpaForwardController.java 143 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-framework/jh-spring-boot-starter-excel/src/main/java/cn/lihu/jh/framework/excel/core/convert/DictConvert.java
@@ -37,7 +37,8 @@
        String label = readCellData.getStringValue();
        String value = DictFrameworkUtils.parseDictDataValue(type, label);
        if (value == null) {
            log.error("[convertToJavaData][type({}) è§£æžä¸æŽ‰ label({})]", type, label);
            // ã€æœ¬éœ€æ±‚】与导出方向一致:字典缺项属数据问题,用 WARN è€Œä¸æ˜¯ ERROR(避免导入时刷红日志)
            log.warn("[convertToJavaData][type({}) æœªæ‰¾åˆ°å­—典项 label({}),按 null å¤„理]", type, label);
            return null;
        }
        // å°† String çš„ value è½¬æ¢æˆå¯¹åº”的属性
@@ -58,7 +59,11 @@
        String value = String.valueOf(object);
        String label = DictFrameworkUtils.getDictDataLabel(type, value);
        if (label == null) {
            log.error("[convertToExcelData][type({}) è½¬æ¢ä¸äº† label({})]", type, value);
            // ã€æœ¬éœ€æ±‚】字典里没有这个值时,属于"数据没配字典",不是系统错误:
            // åŽŸæ¥ç”¨ log.error,导出 N è¡Œå°±ä¼šåˆ· N æ¡ ERROR(例如"患者性别=0"在字典里没有 0),
            // çŽ°åœºçœ‹ç€åƒç³»ç»Ÿå‡ºæ•…éšœã€‚è¿™é‡Œé™ä¸º WARN,仍保留可追溯信息。
            // è¿”回值保持空单元格(比直接输出原始值 "0" æ›´åˆç†ï¼‰ã€‚
            log.warn("[convertToExcelData][type({}) æœªæ‰¾åˆ°å­—典项 value({}),导出为空]", type, value);
            return new WriteCellData<>("");
        }
        // ç”Ÿæˆ Excel å°è¡¨æ ¼
jh-framework/jh-spring-boot-starter-web/src/main/java/cn/lihu/jh/framework/web/core/handler/GlobalExceptionHandler.java
@@ -4,6 +4,8 @@
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.databind.exc.InvalidFormatException;
import cn.lihu.jh.framework.apilog.core.service.ApiErrorLogFrameworkService;
import cn.lihu.jh.framework.common.exception.ServiceException;
import cn.lihu.jh.framework.common.exception.util.ServiceExceptionUtil;
@@ -17,6 +19,7 @@
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.util.Assert;
import org.springframework.validation.BindException;
@@ -124,6 +127,46 @@
    }
    /**
     * å¤„理请求体(@RequestBody)反序列化失败。
     *
     * <h3>为什么需要单独处理</h3>
     * ä¹‹å‰æ²¡æœ‰è¿™ä¸ªå¤„理器,JSON é‡Œçš„字段类型不对(例如 {@code "bookCheckType": "常规心电图"},
     * è€Œå®žä½“字段是 {@code Integer})时会抛 {@link HttpMessageNotReadableException},
     * ä¸€è·¯è½åˆ° {@link #defaultExceptionHandler} å…œåº• â€”— å‰ç«¯åªçœ‹åˆ° <b>500「系统异常」</b>,
     * å®Œå…¨çœ‹ä¸å‡ºæ˜¯å‚数格式问题(实测 2026-10-28 æŽ’查 {@code /ecg/appointment/create} å³ä¸ºæ­¤ï¼‰ã€‚
     *
     * <p>现在改为返回 <b>400</b> å¹¶æ˜Žç¡®æŒ‡å‡ºã€Œå“ªä¸ªå­—段、收到什么值、期望什么类型」。
     */
    @ExceptionHandler(HttpMessageNotReadableException.class)
    public CommonResult<?> httpMessageNotReadableExceptionHandler(HttpMessageNotReadableException ex) {
        log.warn("[httpMessageNotReadableExceptionHandler]", ex);
        return CommonResult.error(BAD_REQUEST.getCode(), "请求参数格式错误:" + buildMessageNotReadableDetail(ex));
    }
    /**
     * æŠŠååºåˆ—化异常翻译成人能看懂的说明:字段名 + æ”¶åˆ°çš„值 + æœŸæœ›çš„类型。
     */
    private String buildMessageNotReadableDetail(HttpMessageNotReadableException ex) {
        Throwable cause = ex.getMostSpecificCause();
        // â‘  å­—段类型不匹配(最常见):拿到了字段名 / å®žé™…值 / æœŸæœ›ç±»åž‹
        if (cause instanceof InvalidFormatException) {
            InvalidFormatException ife = (InvalidFormatException) cause;
            String field = ife.getPath().isEmpty()
                    ? "" : ife.getPath().get(ife.getPath().size() - 1).getFieldName();
            Object value = ife.getValue();
            Class<?> target = ife.getTargetType();
            return String.format("字段「%s」类型不正确(收到值:%s,期望类型:%s)",
                    StrUtil.blankToDefault(field, "未知字段"), value, null == target ? "未知" : target.getSimpleName());
        }
        // â‘¡ è¯·æ±‚体缺失 / JSON è¯­æ³•错误等:用最具体的原因
        if (cause instanceof JsonParseException) {
            return "JSON æ ¼å¼ä¸æ­£ç¡®:" + ((JsonParseException) cause).getOriginalMessage();
        }
        String message = null == cause ? ex.getMessage() : cause.getMessage();
        return StrUtil.blankToDefault(message, "请求体无法解析");
    }
    /**
     * å¤„理 SpringMVC å‚数校验不正确
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
jh-module-ecg/jh-module-ecg-api/src/main/java/cn/lihu/jh/module/ecg/enums/ErrorCodeConstants.java
@@ -35,6 +35,24 @@
    ErrorCode ROOM_NOT_EXISTS = new ErrorCode(1_010_002_000, "诊室和诊疗床不存在");
    ErrorCode ROOM_NOT_SIT = new ErrorCode(1_010_002_001, "请重新就座");
    ErrorCode ROOM_INVALID_STATUS = new ErrorCode(1_010_002_001, "诊室和诊疗床状态异常");
    /**
     * å…¥åº§è¢«æ‹’:该工位已被**其他医生**占用(本次新增)。
     *
     * <p>背景:原来这种情况也返回 {@link #ROOM_INVALID_STATUS}("诊室和诊疗床状态异常"),
     * çŽ°åœºçœ‹åˆ°è¿™å¥è¯æ— æ³•åˆ¤æ–­æ˜¯"工位被人占了"还是"状态不对",排障成本高。
     * ç‹¬ç«‹æˆç åŽï¼Œå‰ç«¯å¯ç›´æŽ¥æç¤º"该工位已由其他医生操作中",并刷新列表。
     */
    ErrorCode ROOM_OCCUPIED_BY_OTHER = new ErrorCode(1_010_002_002,
            "该工位已由其他医生操作中,不能入座;请刷新后选择空闲工位");
    /**
     * å›´è§‚模式:当前登录人不是该工位的主人,不能执行**写**操作(本次新增)。
     *
     * <p>背景:引入"围观"后任何登录用户都能查看任意已开放工位的数据(只读),
     * ä½†å«å·ã€è¿‡å·ã€å®Œæˆæ£€æŸ¥ã€ç¡®è´¹ã€é¢†ç”¨ã€è£…机等写操作只能由该工位主人执行。
     * å‰ç«¯ç¦ç”¨æŒ‰é’®åªæ˜¯ç•Œé¢çº¦å®šï¼ŒæœåŠ¡ç«¯ç”± {@code @RequireBedOwner} åˆ‡é¢ç»Ÿä¸€æ‹¦æˆªã€‚
     */
    ErrorCode ROOM_NOT_OWNER = new ErrorCode(1_010_002_003,
            "当前工位由其他医生操作中,您处于围观模式,不能执行该操作");
    ErrorCode QUEUE_NOT_EXISTS = new ErrorCode(1_010_003_000, "排队不存在");
    ErrorCode QUEUE_BED_NOT_EXIST = new ErrorCode(1_010_003_001, "工位队列不存在");
@@ -50,6 +68,12 @@
     */
    ErrorCode QUEUE_STOP_READY_NOT_CONFIGURED = new ErrorCode(1_010_003_006,
            "停止候诊功能未配置,请先执行数据库升级脚本(clinic_room.stop_ready)");
    /** æ‰‹å·¥"拉入排队患者"时:该患者已不在「排队中」(被别的工位拉走/已叫号) */
    ErrorCode QUEUE_NOT_WAITING = new ErrorCode(1_010_003_007,
            "该患者已不在排队中(可能已被其他工位拉入),请刷新后重试");
    /** æ‰‹å·¥"拉入排队患者"时:检查项目不在本工位开展范围内 */
    ErrorCode QUEUE_CHECK_TYPE_NOT_MATCH = new ErrorCode(1_010_003_008,
            "该患者的检查项目不在本工位开展范围内,不能拉入");
    ErrorCode PATIENT_NOT_EXISTS = new ErrorCode(1_010_004_000, "患者不存在");
jh-module-ecg/jh-module-ecg-api/src/main/java/cn/lihu/jh/module/ecg/enums/QueueStatusEnum.java
@@ -22,7 +22,7 @@
    AFFINITY(12, "亲和"),
    AFFINITY_INSTALL(13, "亲和-安装"),
    RECALLED(15, "已召回"),
    READY(20, "候诊中"),  // å€™è¯Šä¸­ å·²åˆ†æµåˆ°å·¥ä½
    READY(20, "准备中"),  // å‡†å¤‡ä¸­ï¼šå·²åˆ†æµåˆ°å·¥ä½ï¼ˆåŽŸç§°"候诊中",2026-10-16 ç»Ÿä¸€æ”¹åä¸º"准备中")
    ONSTAGE(30, "就诊中"),  // æˆ– é¢†ç”¨ä¸­
    RECEIVED(33, "已领用"),
    RECALLED_INSTALL(34, "已召回-安装"),
jh-module-ecg/jh-module-ecg-api/src/main/java/cn/lihu/jh/module/ecg/enums/SeqRuleEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,150 @@
package cn.lihu.jh.module.ecg.enums;
import cn.lihu.jh.framework.common.core.IntArrayValuable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
 * æŽ’队序号规则枚举 â€”— åœ¨ã€Œæ£€æŸ¥ç±»åž‹ç®¡ç†ã€(system/check-type) çš„ {@code check_type.seq_rule} ä¸Šé…ç½®ã€‚
 *
 * <h3>背景</h3>
 * ç­¾åˆ°æ—¶éœ€è¦ç»™æ‚£è€…一个「排队序号」,不同检查项目用不同规则。原实现**只支持一种**
 * ï¼ˆæŒ‰æ—¶æ®µå–号,见 {@link #TIMESLOT}),本枚举把三种规则显式化,便于按项目配置。
 *
 * <h3>三种规则</h3>
 * <ul>
 *   <li>{@link #APPOINT} â€”— **直接用预约系统下发的预约号**,签到时不再分配新序号;</li>
 *   <li>{@link #CHECKIN} â€”— **签到时给签到序号**:同一天、同一检查类型内,第 1 ä¸ªç­¾åˆ°æ˜¯ 1 å·ï¼Œ
 *       ç¬¬ 2 ä¸ªæ˜¯ 2 å·â€¦â€¦ï¼›å¹¶æŒ‰ã€Œæ¯ 9 ä¸ªå·é¢„ç•™ 1 ä¸ªã€è·³è¿‡é¢„留号,留给加急患者;</li>
 *   <li>{@link #TIMESLOT} â€”— **按预约时间段给号**(现状规则):号池按
 *       {@code (check_type, time_slot)} ç»´æŠ¤ï¼Œå®¹é‡å—时段配置限制。</li>
 * </ul>
 *
 * <h3>容量语义差异(重要)</h3>
 * {@link #APPOINT} ä¸Ž {@link #CHECKIN} **不设当日上限**(号连续递增,发完即止不存在),
 * åªæœ‰ {@link #TIMESLOT} ä¿ç•™åŽŸæœ‰çš„æ—¶æ®µå®¹é‡æŽ§åˆ¶ã€‚è§ {@link #isCapacityLimited()}。
 *
 * <h3>与现有 {@code timeslot_book_num} / {@code timeslot_reserved_num} çš„关系</h3>
 * é‚£ä¸¤ä¸ªå­—段是**时段号池**的参数,仅在 {@link #TIMESLOT} ä¸‹ç”Ÿæ•ˆï¼›
 * {@link #CHECKIN} çš„预留规则固定为「每 9 ä¸ªè·³ 1 ä¸ªã€ï¼ˆ9 ä¸ªæ™®é€šå· + 1 ä¸ªé¢„留号 = ä¸€è½® 10 ä¸ªï¼‰ã€‚
 *
 * @author å¿ƒç”µæŽ’队装机系统
 */
@Getter
@AllArgsConstructor
public enum SeqRuleEnum implements IntArrayValuable {
    /** â‘  ç”¨é¢„约系统的预约号(不再分配新号) */
    APPOINT(0, "预约号"),
    /** â‘¡ ç­¾åˆ°æ—¶ç»™ç­¾åˆ°åºå·ï¼ˆå½“天同检查类型内连续递增,每 9 ä¸ªé¢„ç•™ 1 ä¸ªï¼‰ */
    CHECKIN(1, "签到序号"),
    /** â‘¢ æŒ‰é¢„约时间段给号(现状规则,号池按时段维护) */
    TIMESLOT(2, "时段号");
    public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(SeqRuleEnum::getRule).toArray();
    /** é»˜è®¤è§„则:保持与改造前一致的行为,避免存量数据未配置时行为变化 */
    public static final SeqRuleEnum DEFAULT = TIMESLOT;
    /**
     * ä¸€è½®å·çš„æ•°é‡ï¼š9 ä¸ªæ™®é€šå· + 1 ä¸ªé¢„留号。
     * é¢„留号用于加急患者({@code jjbz=J})。
     */
    public static final int ROUND_SIZE = 10;
    /** ä¸€è½®ä¸­è¢«é¢„留的号个数(每 {@link #ROUND_SIZE} ä¸ªç•™ 1 ä¸ªï¼‰ */
    public static final int RESERVED_PER_ROUND = 1;
    /** è§„则值 */
    private final Integer rule;
    /** è§„则名 */
    private final String name;
    @Override
    public int[] array() {
        return ARRAYS;
    }
    /**
     * è¯¥è§„则下是否需要检查「时段号池容量」。
     * <p>
     * ä»… {@link #TIMESLOT} éœ€è¦ â€”— å®ƒå— {@code queue_sequence.queue_full} é™åˆ¶ï¼›
     * å¦ä¸¤ç§è§„则号值是连续递增的,不设上限。
     */
    public boolean isCapacityLimited() {
        return this == TIMESLOT;
    }
    /**
     * æŒ‰è§„则值解析枚举;无法识别或为 null æ—¶è¿”回 {@link #DEFAULT}。
     * <p>
     * ä¹‹æ‰€ä»¥ä¸æŠ›å¼‚常:该值来自数据库配置,存量行可能为 null;
     * å–默认值可保证升级后**行为与改造前一致**,不因未配置而阻断签到。
     */
    public static SeqRuleEnum parse(Integer rule) {
        if (rule == null) {
            return DEFAULT;
        }
        for (SeqRuleEnum item : values()) {
            if (item.rule.equals(rule)) {
                return item;
            }
        }
        return DEFAULT;
    }
    /**
     * åˆ¤æ–­ç»™å®šå·æ˜¯å¦ä¸ºã€Œé¢„留号」(留给加急患者)。
     * <p>
     * è§„则:每 {@link #ROUND_SIZE} ä¸ªå·ç•™ {@link #RESERVED_PER_ROUND} ä¸ªã€‚
     * ä»¥ 1 ä¸ºèµ·ç‚¹æ—¶ï¼Œæ¯è½®çš„第 10 ä¸ªï¼ˆ10、20、30…)为预留号。
     *
     * @param seq å€™é€‰å·ï¼ˆä»Ž 1 å¼€å§‹ï¼‰
     * @return true è¡¨ç¤ºè¯¥å·éœ€è·³è¿‡ï¼ˆé™¤éžæ˜¯åŠ æ€¥æ‚£è€…ï¼‰
     */
    public static boolean isReservedSeq(int seq) {
        return seq > 0 && seq % ROUND_SIZE == 0;
    }
    /**
     * å– {@code from} åŠä¹‹åŽç¬¬ä¸€ä¸ªã€Œéžé¢„留号」。
     *
     * <p>用于普通患者:若计数器算出的号恰好是预留号(10 çš„倍数),
     * åˆ™æ”¹ç”¨ä¸‹ä¸€ä¸ªå·ï¼ŒæŠŠé¢„留号留给加急患者。
     *
     * <p>注意与调用方 {@code advanceNormalSeq} çš„配合:计数器在同一事务内
     * <b>也要</b>跳过该预留号(多 +1),否则下一次会把同一个号再发一次。
     * è¯¦è§ {@code QueueSequenceMapper#advanceNormalSeq} çš„说明。
     *
     * @param from èµ·å§‹å·ï¼ˆå«ï¼‰
     * @return ç¬¬ä¸€ä¸ªéžé¢„留号
     */
    public static int nextNormalSeq(int from) {
        int seq = Math.max(1, from);
        while (isReservedSeq(seq)) {
            seq++;
        }
        return seq;
    }
    /**
     * å–下一个预留号(给加急患者)。
     * <p>
     * è‹¥ {@code from} æœ¬èº«å·²æ˜¯é¢„留号则直接返回;否则跳到本轮的预留号。
     * ä¾‹ï¼šfrom=1 â†’ 10;from=10 â†’ 10;from=11 â†’ 20。
     *
     * @param from èµ·å§‹å·ï¼ˆå«ï¼‰
     * @return ä¸å°äºŽ {@code from} çš„第一个预留号
     */
    public static int nextReservedSeq(int from) {
        int seq = Math.max(1, from);
        int rem = seq % ROUND_SIZE;
        if (rem == 0) {
            return seq;
        }
        return seq + (ROUND_SIZE - rem);
    }
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/Constants.java
@@ -12,6 +12,28 @@
    static final String ECG_SCREEN_PANE_PASSED_KEY = "ecg.screen.pane.passed";
    static final String DEV_CODE_CHOICE = "dev.code.choice";
    /**
     * é€‰æ‹©å·¥ä½æ¨¡å¼ï¼ˆéœ€æ±‚:工位选择模式)。
     * <p>取值:
     * <ul>
     *   <li>{@code 0} æŒ‰éœ€è¦å¼ºåˆ¶é€‰æ‹©å·¥ä½ï¼ˆ=原来行为:登录后未入座就强制去选工位)</li>
     *   <li>{@code 1} ç™»å½•IP匹配工位(按来访IP自动匹配工位并自动入座;匹配不到则按 0 æ‰§è¡Œï¼‰</li>
     *   <li>{@code 2} ç™»å½•时手动选择工位(强制选择,并把本机IP写入所选工位的 clinic_room.ip)</li>
     *   <li>{@code 3} æŒ‰ç³»ç»Ÿé»˜è®¤å¤„理(该IP未绑定过工位→按 2 é€‰ä¸€æ¬¡å¹¶è®°ä½IP;已绑定→按 1 è‡ªåŠ¨åŒ¹é…ï¼›åŒ¹é…ä¸åˆ°â†’æŒ‰ 0)</li>
     * </ul>
     * å‚数缺失或非法时,一律按 {@code 0} å¤„理,保证不影响存量部署。</p>
     */
    static final String ECG_ROOM_SELECT_MODE_KEY = "ecg.room.select.mode";
    /** é€‰æ‹©å·¥ä½æ¨¡å¼ï¼šæŒ‰éœ€è¦å¼ºåˆ¶é€‰æ‹©å·¥ä½ï¼ˆé»˜è®¤ï¼Œç­‰ä»·äºŽåŽŸè¡Œä¸ºï¼‰ */
    static final int ROOM_SELECT_MODE_FORCE = 0;
    /** é€‰æ‹©å·¥ä½æ¨¡å¼ï¼šç™»å½•IP匹配工位 */
    static final int ROOM_SELECT_MODE_BY_IP = 1;
    /** é€‰æ‹©å·¥ä½æ¨¡å¼ï¼šç™»å½•时手动选择工位,并写入本机IP */
    static final int ROOM_SELECT_MODE_MANUAL_BIND_IP = 2;
    /** é€‰æ‹©å·¥ä½æ¨¡å¼ï¼šæŒ‰ç³»ç»Ÿé»˜è®¤å¤„理(首次=手动选并记IP,之后=IP匹配) */
    static final int ROOM_SELECT_MODE_SYSTEM_DEFAULT = 3;
    // æ—¶é—´æ®µ.间隔时长
    static final String BOOK_TIMESLOT_LENGTH = "book.timeslot.length";
    //static final String BOOK_TIMESLOT_LIST = "book.timeslot.list";
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/Utils.java
@@ -1,9 +1,12 @@
package cn.lihu.jh.module.ecg;
import cn.lihu.jh.module.ecg.dal.dataobject.queue.QueueDO;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -91,5 +94,52 @@
        return localTimeList;
    }
    /**
     * æŠŠã€Œäº²å’Œé¡¹ã€æ’回到它的「主项」后面,使两者在界面上紧挨着显示。
     *
     * <p>亲和项 = åŒä¸€æ‚£è€…的另一个检查项目:主项进候诊时它一起进,主项做完时它一起做完
     * ï¼ˆè§ {@code QueueServiceTxFunctions#procAffinityWhenPreempt})。
     *
     * <p>之所以抽成一个方法:这段逻辑在工作台列表、诊间屏、大屏共 <b>5 å¤„</b>重复,
     * è€ŒåŽŸå®žçŽ°æœ‰ä¸¤ä¸ªå‘ï¼ˆéšã€ŒæŽ’é˜Ÿå·ç»Ÿä¸€ã€æ”¹é€ ä¸€å¹¶ä¿®æ­£ï¼‰ï¼š
     * <ol>
     *   <li>只用 {@code seqNum} ç›¸ç­‰æ¥å®šä½ä¸»é¡¹ã€‚排队号改为「按检查类型各自编号」后号会跨检查类型重复,
     *       å¯èƒ½æŠŠäº²å’Œé¡¹æ’到<b>别的检查类型</b>的患者后面;故同时要求 {@code patId} ç›¸åŒã€‚</li>
     *   <li>用 {@code ==} æ¯”较 {@code Integer}:号大于 127 æ—¶æ˜¯å¼•用比较、永远不相等,
     *       äº²å’Œé¡¹è¢« {@code remove} ä¹‹åŽæ’不回去 â€”— è¡¨çŽ°ä¸º<b>亲和项在列表里消失</b>
     *       ï¼ˆæ—¶æ®µå·è§„则下号很容易超过 127)。改为按值比较,并加"找不到主项就追加到末尾"的兜底。</li>
     * </ol>
     *
     * @param list          å·²æŽ’好序的列表(调用方需先把亲和项从中移除)
     * @param affinityItems è¢«ç§»é™¤å‡ºæ¥çš„亲和项
     */
    public static void insertAffinityItemsAfterMain(List<QueueDO> list, List<QueueDO> affinityItems) {
        if (list == null || affinityItems == null || affinityItems.isEmpty()) {
            return;
        }
        for (QueueDO affinityItem : affinityItems) {
            boolean inserted = false;
            for (int i = list.size() - 1; i >= 0; i--) {
                QueueDO mainItem = list.get(i);
                if (isSameSeqNum(affinityItem.getSeqNum(), mainItem.getSeqNum())
                        && Objects.equals(affinityItem.getPatId(), mainItem.getPatId())) {
                    list.add(i + 1, affinityItem);
                    inserted = true;
                    break;
                }
            }
            if (!inserted) {
                // ä¸»é¡¹ä¸åœ¨æœ¬åˆ—表(例如已就诊完成被状态过滤掉):兜底追加到末尾,
                // å®å¯ä½ç½®ä¸å®Œç¾Žï¼Œä¹Ÿä¸èƒ½è®©å€™è¯Šä¸­çš„æ‚£è€…从列表里"消失"。
                list.add(affinityItem);
            }
        }
    }
    /** æŽ’队号按值比较({@code Integer} ç”¨ {@code ==} åœ¨å¤§äºŽ 127 æ—¶æ˜¯å¼•用比较,会误判为不等) */
    private static boolean isSameSeqNum(Integer a, Integer b) {
        return a != null && b != null && a.intValue() == b.intValue();
    }
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/annotation/RequireBedOwner.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,33 @@
package cn.lihu.jh.module.ecg.annotation;
import java.lang.annotation.*;
/**
 * æ ‡è®°ã€Œå¿…须是该工位的主人才能调用」的写接口(主/围观模型的核心鉴权)。
 *
 * <h3>为什么需要它</h3>
 * å¼•å…¥"围观"后,任何登录用户都能**查看**任意已开放工位的数据(只读),
 * ä½†**写**操作(叫号、过号、完成检查、确费、领用、装机…)只能由该工位的主人执行。
 *
 * <p>前端虽然会在围观模式下禁用按钮,但那只是界面约定 â€”— ä»»ä½•人绕过界面直接调接口照样能改数据。
 * å› æ­¤æœåŠ¡ç«¯å¿…é¡»ç»Ÿä¸€æ ¡éªŒï¼š<b>当前登录人 == clinic_room è¯¥ roomId+bedNo çš„ doc_id</b>。
 *
 * <h3>用法</h3>
 * åŠ åœ¨ Controller çš„写方法上即可,切面会自动从参数里解析 roomId / bedNo:
 * <ul>
 *   <li>{@code @RequestParam("roomId") Long roomId} / {@code @RequestParam("bedNo") String bedNo};</li>
 *   <li>或 {@code @RequestBody XxxVO vo},VO é‡Œæœ‰ {@code getRoomId()} / {@code getBedNo()}。</li>
 * </ul>
 * è§£æžä¸åˆ° roomId/bedNo æ—¶**放行并记警告**(避免误伤),此类接口需在业务里自行校验
 * ï¼ˆä¾‹å¦‚只带 rentId çš„ {@code fee-confirm})。
 *
 * @author å¿ƒç”µæŽ’队装机系统
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RequireBedOwner {
    /** ä¾¿äºŽæ—¥å¿—排查:写清这个接口是做什么的(可选) */
    String value() default "";
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/aspect/BedOwnerAspect.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,108 @@
package cn.lihu.jh.module.ecg.aspect;
import cn.lihu.jh.module.ecg.annotation.RequireBedOwner;
import cn.lihu.jh.module.ecg.service.room.RoomService;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.RequestParam;
import javax.annotation.Resource;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
/**
 * ã€Œå¿…须是工位主人」的写操作鉴权切面(主/围观模型的核心)。
 *
 * <p>处理被 {@link RequireBedOwner} æ ‡æ³¨çš„ Controller æ–¹æ³•:解析出 roomId / bedNo,
 * äº¤ç»™ {@link RoomService#assertBedOwner(Long, String)} æ ¡éªŒï¼›
 * å›´è§‚人或无座者调用写接口时直接抛 {@code ROOM_NOT_OWNER}("您处于围观模式,不能执行该操作")。
 *
 * <p><b>为什么解析不到就放行</b>:只带 rentId ä¹‹ç±»çš„æŽ¥å£æ‹¿ä¸åˆ° roomId/bedNo,
 * è‹¥ä¸€å¾‹æ‹’绝会把正常业务也挡掉(例如确费)。这类接口记警告日志,并由业务自行校验。
 */
@Aspect
@Component
@Slf4j
public class BedOwnerAspect {
    @Resource
    private RoomService roomService;
    @Before("@annotation(requireBedOwner)")
    public void checkBedOwner(JoinPoint joinPoint, RequireBedOwner requireBedOwner) {
        Long roomId = null;
        String bedNo = null;
        Object[] args = joinPoint.getArgs();
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        Annotation[][] paramAnnotations = method.getParameterAnnotations();
        for (int i = 0; i < args.length; i++) {
            Object arg = args[i];
            if (null == arg) {
                continue;
            }
            // â‘  @RequestParam("roomId"/"bedNo") ç›´æŽ¥å‚æ•°
            Annotation[] annotations = (i < paramAnnotations.length) ? paramAnnotations[i] : null;
            if (null != annotations) {
                for (Annotation annotation : annotations) {
                    if (!(annotation instanceof RequestParam)) {
                        continue;
                    }
                    String name = ((RequestParam) annotation).value();
                    if (name.isEmpty()) {
                        name = ((RequestParam) annotation).name();
                    }
                    if ("roomId".equals(name) && arg instanceof Number) {
                        roomId = ((Number) arg).longValue();
                    } else if ("bedNo".equals(name)) {
                        bedNo = String.valueOf(arg);
                    }
                }
            }
            // â‘¡ è¯·æ±‚体 VO:反射读 getRoomId() / getBedNo()
            if (isSimpleValue(arg)) {
                continue;
            }
            if (null == roomId) {
                Object v = readGetter(arg, "getRoomId");
                if (v instanceof Number) {
                    roomId = ((Number) v).longValue();
                }
            }
            if (null == bedNo) {
                Object v = readGetter(arg, "getBedNo");
                if (null != v) {
                    bedNo = String.valueOf(v);
                }
            }
        }
        if (null == roomId || null == bedNo || bedNo.isEmpty() || "null".equals(bedNo)) {
            log.warn("[RequireBedOwner] æœªèƒ½ä»Žå‚数解析 roomId/bedNo,跳过主人校验(该接口需自行校验). æŽ¥å£={} æ ‡è®°={}",
                    signature.getName(), requireBedOwner.value());
            return;
        }
        roomService.assertBedOwner(roomId, bedNo);
    }
    /** ç®€å•类型(数字/字符串/集合等)不需要做 getter åå°„ */
    private boolean isSimpleValue(Object arg) {
        return arg instanceof Number || arg instanceof CharSequence || arg instanceof Boolean
                || arg instanceof java.util.Collection || arg instanceof java.util.Map;
    }
    private Object readGetter(Object target, String getterName) {
        try {
            Method getter = target.getClass().getMethod(getterName);
            return getter.invoke(target);
        } catch (Exception e) {
            return null;
        }
    }
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/appointment/AppointmentController.java
@@ -126,10 +126,29 @@
        return success(BeanUtils.toBean(appointmentList.get(0), AppointmentRespVO.class));
    }
    /**
     * ã€æœ¬éœ€æ±‚:紧急患者不排队直接检查】按「申请单号 / å°±è¯Šå· / æ‚£è€…编号」查近一个月的预约记录。
     *
     * <p>工作台在三个输入框里按回车时调用;结果按**预约时间倒序**(最近的在前),
     * å¤šæ¡æ—¶ç”±å‰ç«¯å¼¹é€‰æ‹©æ¡†ã€‚窗口为 [今天-30 å¤©, ä»Šå¤©+30 å¤©],并按本窗口开展的检查项目过滤。
     *
     * @param code       ç”³è¯·å•号 / å°±è¯Šæµæ°´å· / æ‚£è€…编号(三者之一)
     * @param checkTypes æœ¬çª—口(诊室)开展的检查项目,逗号分隔;为空表示不限制
     */
    @GetMapping("/emergency-candidates")
    @Operation(summary = "紧急加做:按申请单号/就诊号/患者编号查近一个月的预约记录")
    @Parameter(name = "code", description = "申请单号 / å°±è¯Šæµæ°´å· / æ‚£è€…编号", required = true)
    @Parameter(name = "checkTypes", description = "本窗口开展的检查项目,逗号分隔;为空不限制")
    @PreAuthorize("@ss.hasPermission('ecg:appointment:query')")
    public CommonResult<List<AppointmentRespVO>> getEmergencyCandidates(@RequestParam("code") String code,
                                                                       @RequestParam(value = "checkTypes", required = false) String checkTypes) {
        List<AppointmentDO> appointmentList = appointmentService.getEmergencyCandidates(code, checkTypes);
        return success(BeanUtils.toBean(appointmentList, AppointmentRespVO.class));
    }
    @GetMapping("/page")
    @Operation(summary = "获得预约分页")
    @PreAuthorize("@ss.hasPermission('ecg:appointment:query')")
    public CommonResult<PageResult<AppointmentRespVO>> getAppointmentPage(AppointmentPageReqVO pageReqVO) {
    @PreAuthorize("@ss.hasPermission('ecg:appointment:query')")    public CommonResult<PageResult<AppointmentRespVO>> getAppointmentPage(AppointmentPageReqVO pageReqVO) {
        pageReqVO.setOrderCreateTime("1");
        PageResult<AppointmentDO> pageResult = appointmentService.getAppointmentPage(pageReqVO);
        return success(BeanUtils.toBean(pageResult, AppointmentRespVO.class));
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/appointment/vo/AppointmentConfirmReqVO.java
@@ -48,4 +48,17 @@
    private Integer isVip;
    /**
     * ã€æœ¬éœ€æ±‚】是否"已确认签入"非当天预约。
     *
     * <p>预约日期不是当天时:
     * <ul>
     *   <li>{@code forceCheckIn != true} â€”— ç›´æŽ¥è¿”回 {@code APPOINTMENT_NOT_TODAY},
     *       å‰ç«¯æ®æ­¤å¼¹ã€Œéžå½“天预约,是否继续签到」;</li>
     *   <li>{@code forceCheckIn == true} â€”— å…è®¸ç­¾å…¥ï¼Œå¹¶æŒ‰**预留号**取号
     *       ï¼ˆä¸å ç”¨å½“天同时段的正常预约号)。</li>
     * </ul>
     */
    private Boolean forceCheckIn;
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/appointment/vo/AppointmentPageReqVO.java
@@ -72,6 +72,19 @@
    @Schema(description = "预约日期")
    private LocalDate bookDate;
    /**
     * ã€æœ¬éœ€æ±‚】预约日期**时间段**查询:起始(含当天 00:00:00)。
     * <p>前端「预约日期」用日期范围选择器,查询时拆成 start(00:00:00) / end(23:59:59) ä¸¤ä¸ªå‚数,
     * ä¸Žæ—¢æœ‰çš„ {@code registerDateStart/End}、{@code createTimeStart/End} å†™æ³•保持一致。
     */
    @Schema(description = "预约日期-开始(含)", example = "2026-11-01 00:00:00")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime bookDateStart;
    @Schema(description = "预约日期-结束(含)", example = "2026-11-30 23:59:59")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime bookDateEnd;
    @Schema(description = "预约时间段")
    private Integer bookTimeslot;
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/appointment/vo/AppointmentRespVO.java
@@ -9,11 +9,11 @@
import java.time.LocalDate;
import java.util.*;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import com.alibaba.excel.annotation.*;
import com.alibaba.excel.annotation.format.DateTimeFormat;
import com.alibaba.excel.annotation.write.style.ColumnWidth;
import cn.lihu.jh.framework.excel.core.annotations.DictFormat;
import cn.lihu.jh.framework.excel.core.convert.DictConvert;
@@ -51,6 +51,8 @@
    @Schema(description = "患者生日")
    @ExcelProperty("患者生日")
    // ã€æœ¬éœ€æ±‚】导出只显示日期(原来 LocalDateTime é»˜è®¤å¸¦æ—¶åˆ†ç§’ â†’ "1990-01-01 00:00:00")
    @DateTimeFormat("yyyy-MM-dd")
    private LocalDateTime patBirthday;
    @Schema(description = "患者手机")
@@ -99,6 +101,9 @@
    @Schema(description = "预约发生时间", requiredMode = Schema.RequiredMode.REQUIRED)
    @ExcelProperty("预约发生时间")
    // ã€æœ¬éœ€æ±‚】列宽固定 20:原先靠 LongestMatchColumnWidth è‡ªåŠ¨ç®—ï¼ˆçº¦ 18),
    // ä¸Žç™»è®°æ—¶é—´/开单时间(Date åž‹å•元格只算到 12)不一致,这里三列统一。
    @ColumnWidth(20)
    private LocalDateTime bookTime;
    @Schema(description = "预约检查类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
@@ -106,8 +111,15 @@
    @ExcelProperty(value = "预约检查类型", converter = DictConvert.class)
    private Integer bookCheckType;
    @Schema(description = "内部预约编号", requiredMode = Schema.RequiredMode.REQUIRED)
    @ExcelProperty("内部预约编号")
    /**
     * æŽ’队号(对外字段名保持 bookSeqNum ä¸å˜ï¼‰ã€‚
     *
     * <p>【号统一(本需求)】值来源已改为 {@code queue.seq_num} â€”—
     * å·åœ¨ç­¾åˆ°æ—¶ä¸€æ¬¡æ€§åˆ†é…ã€ä¹‹åŽä¸å˜ï¼›æœ¬å­—段只在"该预约已排队"时用于回显。
     * æ˜¾ç¤ºæ—¶è¯·æ‹¼ä¸Š {@code check_type.seq_prefix}(号按检查项目各自编号,会跨项目重复)。
     */
    @Schema(description = "排队号(来源 queue.seq_num;已无 book å·ï¼‰", requiredMode = Schema.RequiredMode.REQUIRED)
    @ExcelProperty("排队号")
    private Integer bookSeqNum;
    @Schema(description = "预约来源:X系统、护士手动预约", requiredMode = Schema.RequiredMode.REQUIRED)
@@ -131,6 +143,9 @@
     */
    @Schema(description = "登记时间", requiredMode = Schema.RequiredMode.REQUIRED)
    @ExcelProperty("登记时间")
    // ã€æœ¬éœ€æ±‚】列宽固定 20(与「预约发生时间」一致):
    // Date åž‹å•元格在 LongestMatchColumnWidth ä¸‹åªä¼šç®—到 12,时间被截断。
    @ColumnWidth(20)
    private Date registerDate;
    /**
@@ -173,6 +188,8 @@
     * å¼€å•æ—¶é—´
     */
    @ExcelProperty("开单时间")
    // ã€æœ¬éœ€æ±‚】列宽固定 20(与「预约发生时间」一致)
    @ColumnWidth(20)
    private Date doctorTime;
    // ==================== HIS æŽ¥å…¥è¡¥å……字段(2026-09-27 éšè¡¨æ–°å¢žåˆ—一起加入) ====================
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/callingscreen/CallingScreenController.java
@@ -3,7 +3,9 @@
import cn.hutool.extra.servlet.ServletUtil;
import cn.lihu.jh.framework.common.pojo.CommonResult;
import cn.lihu.jh.framework.common.util.object.BeanUtils;
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.mysql.checktype.CheckTypeMapper;
import cn.lihu.jh.module.ecg.service.callingscreen.CallingScreenService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -17,6 +19,7 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static cn.lihu.jh.framework.common.pojo.CommonResult.error;
import static cn.lihu.jh.framework.common.pojo.CommonResult.success;
@@ -30,6 +33,14 @@
    @Resource
    private CallingScreenService callingScreenService;
    /** ç”¨äºŽç»™å±å¹•数据补「排队号前缀」(check_type.seq_prefix) */
    @Resource
    private CheckTypeMapper checkTypeMapper;
    /** ã€é‡å«ã€‘用于给屏幕数据补"最近一次叫号记录id"(call_patient) */
    @Resource
    private cn.lihu.jh.module.ecg.dal.mysql.call.CallMapper callMapper;
    @GetMapping("/display-col-info")
    @Operation(summary = "列上显示的检查类型名称")
@@ -48,10 +59,16 @@
        Map<Integer, List<QueueDO>> map = callingScreenService.getBigScreenPatient();
        Map<Integer, List<ScreenQueueRespVO>> mapVO = new HashMap<>();
        // å±å¹•要显示「前缀 + æŽ’队号」,设备未登录拿不到检查项目字典,故由后端一并下发(每次请求读一次)
        Map<Integer, String> seqPrefixMap = getSeqPrefixMap();
        // ã€é‡å«ã€‘一并下发"最近一次叫号记录id",屏幕据此重新播报
        Map<String, Long> callIdMap = getLatestCallIdMap();
        map.keySet().forEach( displayCol -> {
            //log.info("dispayCol: " + displayCol);
            List<QueueDO> columnQueueDOList = map.get(displayCol);
            List<ScreenQueueRespVO> screenQueueRespVOList = BeanUtils.toBean(columnQueueDOList, ScreenQueueRespVO.class);
            fillSeqPrefix(screenQueueRespVOList, seqPrefixMap);
            fillCallId(screenQueueRespVOList, callIdMap);
            mapVO.put(displayCol, screenQueueRespVOList);
        });
@@ -69,9 +86,88 @@
        List<QueueDO> queueDOList2 = callingScreenService.getRoomInstallRelatedPatient(reqIp, roomId);
        Map<Integer, List<ScreenQueueRespVO>> mapVO = new HashMap<>();
        mapVO.put(1, BeanUtils.toBean(queueDOList1, ScreenQueueRespVO.class));
        mapVO.put(2, BeanUtils.toBean(queueDOList2, ScreenQueueRespVO.class));
        List<ScreenQueueRespVO> waitingVOList = BeanUtils.toBean(queueDOList1, ScreenQueueRespVO.class);
        List<ScreenQueueRespVO> installVOList = BeanUtils.toBean(queueDOList2, ScreenQueueRespVO.class);
        // å±å¹•要显示「前缀 + æŽ’队号」,设备未登录拿不到检查项目字典,故由后端一并下发(每次请求读一次)
        Map<Integer, String> seqPrefixMap = getSeqPrefixMap();
        Map<String, Long> callIdMap = getLatestCallIdMap();
        fillSeqPrefix(waitingVOList, seqPrefixMap);
        fillSeqPrefix(installVOList, seqPrefixMap);
        fillCallId(waitingVOList, callIdMap);
        fillCallId(installVOList, callIdMap);
        mapVO.put(1, waitingVOList);
        mapVO.put(2, installVOList);
        return success( mapVO );
    }
    /**
     * ç»™å±å¹•数据补上「排队号显示前缀」= {@code check_type.seq_prefix}。
     *
     * <p>【号统一(本需求)】排队号按检查项目各自编号,不同检查项目会出现相同数字,
     * å±å¹•上必须带前缀才分得清(例:A5 / B5)。屏幕设备不登录、无法调用检查项目接口,
     * å› æ­¤åœ¨è¿™é‡Œè¡¥é½ã€‚
     *
     * <p>每次请求查一次全表建映射(检查项目只有十几个,量级很小):
     * ä¸åšé•¿æœŸç¼“存,避免管理员改了前缀后屏幕一直显示旧值。
     * æŸ¥ä¸åˆ°é…ç½®æ—¶ç•™ç©ºä¸²â€”—宁可只显示数字,也不要让屏幕报错或空白。
     */
    private void fillSeqPrefix(List<ScreenQueueRespVO> voList, Map<Integer, String> seqPrefixMap) {
        if (voList == null || voList.isEmpty() || seqPrefixMap == null) {
            return;
        }
        voList.forEach(vo -> vo.setSeqPrefix(seqPrefixMap.getOrDefault(vo.getBookCheckType(), "")));
    }
    /* ==================== é‡å«ï¼šä¸‹å‘"最近一次叫号记录id" ==================== */
    /**
     * ç»™å±å¹•数据补上 {@code callId}(该患者最近一次叫号记录的 id)。
     *
     * <p>为什么需要:屏幕播报靠"数据变化"触发 â€”— å¤§å±åˆ¤æ–­"某患者新出现在叫号列"、
     * è¯Šé—´å±åˆ¤æ–­"诊中第一人变化"。而医生点「重叫」时**患者还是那个人、诊室还是那个诊室**,
     * é˜Ÿåˆ—数据一点没变,两块屏自然不会再播。重叫会新增一条叫号记录(call_patient),
     * æŠŠå®ƒçš„ id ä¸‹å‘后,屏幕只要发现 callId å˜äº†å°±é‡æ–°æ’­æŠ¥ã€‚
     */
    private void fillCallId(List<ScreenQueueRespVO> voList, Map<String, Long> callIdMap) {
        if (voList == null || voList.isEmpty() || callIdMap == null || callIdMap.isEmpty()) {
            return;
        }
        voList.forEach(vo -> {
            if (null != vo.getPatId()) {
                vo.setCallId(callIdMap.get(vo.getPatId()));
            }
        });
    }
    /** ä¸€æ¬¡æŸ¥è¯¢å–到「患者编号 â†’ æœ€è¿‘一次叫号记录 id」;失败时返回空映射(屏幕按老逻辑播报) */
    private Map<String, Long> getLatestCallIdMap() {
        try {
            Map<String, Long> map = new HashMap<>();
            for (Map<String, Object> row : callMapper.selectLatestCallIds()) {
                Object patId = row.get("patId");
                Object callId = row.get("callId");
                if (null != patId && callId instanceof Number) {
                    map.put(String.valueOf(patId), ((Number) callId).longValue());
                }
            }
            return map;
        } catch (Exception e) {
            log.error("[getLatestCallIdMap] è¯»å–最近叫号记录失败,重叫将不会触发屏幕重播", e);
            return new HashMap<>();
        }
    }
    /** è¯»ä¸€æ¬¡æ£€æŸ¥é¡¹ç›®é…ç½®ï¼Œå¾—到「检查项目值 â†’ æŽ’队号前缀」映射;失败时返回空映射(屏幕只显示号) */
    private Map<Integer, String> getSeqPrefixMap() {        try {
            return checkTypeMapper.simpleCheckTypeList().stream()
                    .filter(checkType -> null != checkType.getValue())
                    .collect(Collectors.toMap(CheckTypeDO::getValue,
                            checkType -> null == checkType.getSeqPrefix() ? "" : checkType.getSeqPrefix(),
                            (a, b) -> a));
        } catch (Exception e) {
            log.error("[getSeqPrefixMap] è¯»å–检查项目前缀失败,屏幕将只显示号", e);
            return new HashMap<>();
        }
    }
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/callingscreen/ScreenQueueRespVO.java
@@ -34,11 +34,33 @@
    @ExcelProperty("排队序号")
    private Integer seqNum;
    @Schema(description = "预约序号", requiredMode = Schema.RequiredMode.REQUIRED)
    /**
     * æŽ’队号显示前缀({@code check_type.seq_prefix})。
     *
     * <p>【号统一(本需求)】排队号改为**按检查项目各自编号**,不同检查项目之间会出现相同数字
     * ï¼ˆä¾‹ï¼šåŠ¨æ€å¿ƒç”µ 5 å·ã€åŠ¨æ€è¡€åŽ‹ 5 å·ï¼‰ï¼Œå±å¹•必须靠前缀区分,故由后端随队列数据一并下发。
     * <p>屏幕设备未登录、拿不到检查项目字典接口,因此不能由前端自行查表。
     */
    @Schema(description = "排队号显示前缀(检查项目配置的 seq_prefix)", example = "A")
    @ExcelProperty("排队号前缀")
    private String seqPrefix;
    /** ã€å·²åœç”¨ã€‘屏幕请显示 seqPrefix + seqNum;本字段仅保留兼容老屏幕页面,后端已不再写入 */
    @Schema(description = "预约序号(已停用,请用 seqPrefix + seqNum)", requiredMode = Schema.RequiredMode.REQUIRED)
    @ExcelProperty("预约序号")
    private Integer bookSeqNum;
    /**
     * ã€é‡å«ï¼ˆæœ¬éœ€æ±‚)】该患者**最近一次叫号记录**的 id。
     *
     * <p>屏幕靠"数据变化"触发播报:医生点「重叫」只会新增一条叫号记录、不改变队列数据,
     * å› æ­¤æŠŠ callId ä¸€å¹¶ä¸‹å‘ â€”— å®ƒä¸€å˜ï¼Œå±å¹•就重新播报(大屏绕过"同一患者只播一次"的锁,
     * è¯Šé—´å±å¯¹æ¯”上次的 callId)。为空表示今天还没有叫号记录,屏幕按老逻辑处理。
     */
    @Schema(description = "最近一次叫号记录id(用于重叫时重新播报)", example = "1234")
    private Long callId;
    /**
     * 5:过号 10:排队中 15:已召回 20:候诊准备 30:就诊中 40:就诊完成
     */
    @Schema(description = "排队状态", example = "1")
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/checktype/CheckTypeController.java
@@ -39,11 +39,17 @@
    @Resource
    private CheckTypeService checkTypeService;
    /** ã€æœ¬æ¬¡åŠ å›ºã€‘æ£€æŸ¥é¡¹ç›®æ”¹åŠ¨åŽåˆ·æ–°æŽ’é˜Ÿæ¨¡å—çš„æ£€æŸ¥é¡¹ç›®ç¼“å­˜ï¼ˆäº²å’Œé¡¹ã€å€™è¯Šä¸Šé™ç­‰ï¼‰ï¼Œæ”¹å®Œä¸ç”¨é‡å¯æœåŠ¡ */
    @Resource
    private cn.lihu.jh.module.ecg.service.queue.QueueService queueService;
    @PostMapping("/create")
    @Operation(summary = "创建检查类型")
    @PreAuthorize("@ss.hasPermission('ecg:check-type:create')")
    public CommonResult<Integer> createCheckType(@Valid @RequestBody CheckTypeSaveReqVO createReqVO) {
        return success(checkTypeService.createCheckType(createReqVO));
        Integer id = checkTypeService.createCheckType(createReqVO);
        queueService.initCheckType();
        return success(id);
    }
    @PutMapping("/update")
@@ -51,6 +57,7 @@
    @PreAuthorize("@ss.hasPermission('ecg:check-type:update')")
    public CommonResult<Boolean> updateCheckType(@Valid @RequestBody CheckTypeSaveReqVO updateReqVO) {
        checkTypeService.updateCheckType(updateReqVO);
        queueService.initCheckType();
        return success(true);
    }
@@ -60,6 +67,7 @@
    @PreAuthorize("@ss.hasPermission('ecg:check-type:delete')")
    public CommonResult<Boolean> deleteCheckType(@RequestParam("id") Integer id) {
        checkTypeService.deleteCheckType(id);
        queueService.initCheckType();
        return success(true);
    }
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/devrent/DevRentController.java
@@ -1,319 +1,705 @@
package cn.lihu.jh.module.ecg.controller.admin.devrent;
import cn.lihu.jh.framework.security.core.util.SecurityFrameworkUtils;
import cn.lihu.jh.module.ecg.dal.dataobject.appointment.AppointmentDO;
import cn.lihu.jh.module.ecg.dal.dataobject.devmanage.DeviceDO;
import cn.lihu.jh.module.ecg.enums.DevStateEnum;
import cn.lihu.jh.module.ecg.annotation.RequireBedOwner;
import cn.lihu.jh.module.ecg.service.appointment.AppointmentService;
import cn.lihu.jh.module.ecg.service.devmanage.DeviceService;
import cn.lihu.jh.module.ecg.service.queue.QueueService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.validation.annotation.Validated;
import org.springframework.security.access.prepost.PreAuthorize;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Operation;
import java.util.*;
import java.io.IOException;
import cn.lihu.jh.framework.common.pojo.PageParam;
import cn.lihu.jh.framework.common.pojo.PageResult;
import cn.lihu.jh.framework.common.pojo.CommonResult;
import cn.lihu.jh.framework.common.util.object.BeanUtils;
import cn.lihu.jh.framework.excel.core.util.ExcelUtils;
import cn.lihu.jh.framework.apilog.core.annotation.ApiAccessLog;
import static cn.lihu.jh.framework.apilog.core.enums.OperateTypeEnum.*;
import cn.lihu.jh.module.ecg.controller.admin.devrent.vo.*;
import cn.lihu.jh.module.ecg.dal.dataobject.devrent.DevRentDO;
import cn.lihu.jh.module.ecg.service.devrent.DevRentService;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
@Tag(name = "装机拆机")
@RestController
@RequestMapping("/ecg/dev-rent")
@Validated
@Slf4j
public class DevRentController {
    @Resource
    private DevRentService devRentService;
    @Resource
    private DeviceService deviceService;
    @Resource
    private QueueService queueService;
    @Resource
    private AppointmentService appointmentService;
    /** ã€ä¸»/围观】写操作的工位主人校验(只带患者信息的接口在方法里显式调用) */
    @Resource
    private cn.lihu.jh.module.ecg.service.room.RoomService roomService;
    // å¸¸è§„检查 å®Œæˆ
    @RequireBedOwner("检查完成")
    @PostMapping("/routine-finish")
    @Operation(summary = "常规检查完成")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<Long> routineFinish(@Valid @RequestBody RoutineFinishReqVO routineFinishReqVO) {
        CommonResult<Long> result = devRentService.routineFinishOperation(routineFinishReqVO);
        try {
            String userNickname = SecurityFrameworkUtils.getLoginUserNickname();
            String userHisId = SecurityFrameworkUtils.getLoginUserHisId();
            Long rentId = routineFinishReqVO.getId();
            // ã€ç¼ºé™·ä¿®å¤ã€‘优先用**服务返回的 rentId**(= æœ¬æ¬¡å®Œæˆå®žé™…写入的检查单 id):
            //   Â· æ™®é€šæ‚£è€…:请求里带了 id,两者相同;
            //   Â· ç´§æ€¥åŠ åšæ‚£è€…ï¼šè¯·æ±‚é‡Œæ²¡æœ‰ id(没签到过),服务会新建检查单 â€”—
            //     åŽŸå®žçŽ°ç”¨ routineFinishReqVO.getId()(null)去确费,必然失败(且被 catch åžæŽ‰ï¼‰ï¼Œ
            //     çŽ°åœºè¡¨çŽ°ä¸º"加急患者检查完成后没有确费"。
            Long rentId = null != result && null != result.getData()
                    ? result.getData() : routineFinishReqVO.getId();
            devRentService.feeConfirm(rentId, userHisId, userNickname, true);  // æ•…意 ä¸æ£€æŸ¥è¿”回值
        } catch (Exception e) {
            log.error("常规检查完成, è‡ªåŠ¨ç¡®è´¹å¤±è´¥", e);
        }
        return result;
    }
    @RequireBedOwner("设备领用完成")
    @PostMapping("/dev-ready")
    @Operation(summary = "设备领用完成")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<Long> devReady(@Valid @RequestBody DevRentSaveReqVO createReqVO) {
        String userHisId = SecurityFrameworkUtils.getLoginUserHisId();
        String userNickname = SecurityFrameworkUtils.getLoginUserNickname();
        createReqVO.setOperater(userNickname);
        CommonResult<Long> result = devRentService.readyOperation(createReqVO);
        try {
            Long rentId = createReqVO.getId();
            devRentService.feeConfirm(rentId, userHisId, userNickname, true);  // æ•…意 ä¸æ£€æŸ¥è¿”回值
        } catch (Exception e) {
            log.error("设备领用完成, è‡ªåŠ¨ç¡®è´¹å¤±è´¥", e);
        }
        return result;
    }
    @RequireBedOwner("装机完成")
    @PostMapping("/dev-install")
    @Operation(summary = "装机")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<Long> devInstall(@Valid @RequestBody DevRentSaveReqVO createReqVO) {
        return devRentService.installOperation(createReqVO);
    }
    @RequireBedOwner("拆机")
    @PostMapping("/dev-dismantle")
    @Operation(summary = "拆机")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<Long> devDismantle(@Valid @RequestBody DevRentSaveReqVO createReqVO) {
        devRentService.dismantleOperation(createReqVO);
        return CommonResult.success(0L);
    }
    @RequireBedOwner("数据录入")
    @PostMapping("/dev-data-entry")
    @Operation(summary = "数据录入")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<Long> devDataEntry(@Valid @RequestBody DevRentSaveReqVO createReqVO) {
        devRentService.dataEntryOperation(createReqVO);
        return CommonResult.success(0L);
    }
    @PostMapping("/routine-check-cancel")
    @Operation(summary = "取消常规检查")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<Long> routineCheckCancel(@Valid @RequestBody DevCancelReqVO cancelReqVO) {
        // ã€ä¸»/围观】该接口没有 roomId/bedNo,按「患者 + æ£€æŸ¥é¡¹ç›®ã€åæŸ¥å…¶æ‰€åœ¨å·¥ä½å†æ ¡éªŒä¸»äººèº«ä»½
        roomService.assertBedOwnerByPatient(cancelReqVO.getPatId(), cancelReqVO.getCheckType());
        return devRentService.cancelRoutineOperation(cancelReqVO);
    }
    @PostMapping("/dev-ready-cancel")
    @Operation(summary = "取消领用")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<Long> devReadyCancel(@Valid @RequestBody DevCancelReqVO cancelReqVO) {
        // ã€ä¸»/围观】按「患者 + æ£€æŸ¥é¡¹ç›®ã€åæŸ¥å·¥ä½å†æ ¡éªŒä¸»äººèº«ä»½
        roomService.assertBedOwnerByPatient(cancelReqVO.getPatId(), cancelReqVO.getCheckType());
        return devRentService.cancelReadyOperation(cancelReqVO);
    }
    @PostMapping("/dev-install-cancel")
    @Operation(summary = "取消装机")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<Long> devInstallCancel(@Valid @RequestBody DevCancelReqVO cancelReqVO) {
        // ã€ä¸»/围观】按「患者 + æ£€æŸ¥é¡¹ç›®ã€åæŸ¥å·¥ä½å†æ ¡éªŒä¸»äººèº«ä»½
        roomService.assertBedOwnerByPatient(cancelReqVO.getPatId(), cancelReqVO.getCheckType());
        return devRentService.cancelInstallOperation(cancelReqVO);
    }
    @RequireBedOwner("设备遗失")
    @PostMapping("/dev-lose")
    @Operation(summary = "遗失")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<Long> devLose(@Valid @RequestBody DevRentSaveReqVO loseReqVO) {
        loseReqVO.setState(DevStateEnum.LOST.getState());
        return devRentService.dismantleExceptionOperation(loseReqVO);
    }
    @RequireBedOwner("设备损坏")
    @PostMapping("/dev-damage")
    @Operation(summary = "损坏")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<Long> devDamage(@Valid @RequestBody DevRentSaveReqVO damageReqVO) {
        damageReqVO.setState(DevStateEnum.DAMAGED.getState());
        return devRentService.dismantleExceptionOperation(damageReqVO);
    }
    @PostMapping("/create")
    @Operation(summary = "创建装机拆机")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<Long> createDevRent(@Valid @RequestBody DevRentSaveReqVO createReqVO) {
        return CommonResult.success(devRentService.createDevRent(createReqVO));
    }
    @PutMapping("/update")
    @Operation(summary = "更新装机拆机")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<Boolean> updateDevRent(@Valid @RequestBody DevRentSaveReqVO updateReqVO) {
        devRentService.updateDevRent(updateReqVO);
        return CommonResult.success(true);
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除装机拆机")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<Boolean> deleteDevRent(@RequestParam("id") Long id) {
        devRentService.deleteDevRent(id);
        return CommonResult.success(true);
    }
    @GetMapping("/get")
    @Operation(summary = "获得装机拆机")
    @Parameter(name = "id", description = "编号", required = true, example = "1024")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<DevRentRespVO> getDevRent(@RequestParam("id") Long id) {
        DevRentDO devRent = devRentService.getDevRent(id);
        DevRentRespVO devRentRespVO = BeanUtils.toBean(devRent, DevRentRespVO.class);
        DeviceDO deviceDO = deviceService.getDevice(devRent.getDevId());
        if (null != deviceDO) {
            devRentRespVO.setCategory(deviceDO.getCategory());
            devRentRespVO.setBrand(deviceDO.getBrand());
            devRentRespVO.setModel(deviceDO.getModel());
            devRentRespVO.setDevState(deviceDO.getState());
        }
        return CommonResult.success(devRentRespVO);
    }
    @GetMapping("/page")
    @Operation(summary = "获得装机拆机分页")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<PageResult<DevRentRespVO>> getDevRentPage(@Valid DevRentPageReqVO pageReqVO) {
        PageResult<DevRentDO> pageResult = devRentService.getDevRentPage(pageReqVO);
        return CommonResult.success(BeanUtils.toBean(pageResult, DevRentRespVO.class));
    }
    @GetMapping("/export-excel")
    @Operation(summary = "导出装机拆机 Excel")
    @PreAuthorize("@ss.hasPermission('ecg:dev-rent:export')")
    @ApiAccessLog(operateType = EXPORT)
    public void exportDevRentExcel(@Valid DevRentPageReqVO pageReqVO, HttpServletResponse response) throws IOException {
        pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE);
        List<DevRentDO> list = devRentService.getDevRentPage(pageReqVO).getList();
        // å¯¼å‡º Excel
        ExcelUtils.write(response, "装机拆机.xls", "数据", DevRentRespVO.class, BeanUtils.toBean(list, DevRentRespVO.class));
    }
    @GetMapping("/get-free-rent")
    @Operation(summary = "根据患者或设备, èŽ·å–å·²é¢†å–ç§Ÿèµæƒ…å†µ")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<DevRentRespVO> getFreeRent(@Valid DevRentSearchReqVO searchReqVO) {
        DevRentDO devRent = devRentService.getFreeRent(searchReqVO);
        if (null == devRent) {
            return CommonResult.success(null);
        }
        DevRentRespVO devRentRespVO = BeanUtils.toBean(devRent, DevRentRespVO.class);
        return CommonResult.success(devRentRespVO);
    }
    @GetMapping("/get-ready-rent")
    @Operation(summary = "获取已领取租赁情况")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<DevRentRespVO> getReadyRent(@Valid DevRentSearchReqVO searchReqVO) {
        DevRentDO devRent = devRentService.getReadyOrCancelledRent(searchReqVO);
        if (null == devRent) {
            return CommonResult.success(null);
        }
        DevRentRespVO devRentRespVO = BeanUtils.toBean(devRent, DevRentRespVO.class);
        DeviceDO deviceDO = deviceService.getDevice(devRent.getDevId());
        if (null != deviceDO) {
            devRentRespVO.setCategory(deviceDO.getCategory());
            devRentRespVO.setBrand(deviceDO.getBrand());
            devRentRespVO.setModel(deviceDO.getModel());
            devRentRespVO.setDevState(deviceDO.getState());
        }
        return CommonResult.success(devRentRespVO);
    }
    @GetMapping("/get-existing-rent")
    @Operation(summary = "获取已安装、已领用的租赁情况")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<DevRentRespVO> getInstalledRent(@Valid DevRentSearchReqVO searchReqVO) {
        DevRentDO devRent = devRentService.getExistingRent(searchReqVO);
        if (null == devRent) {
            return CommonResult.success(null);
        }
        DeviceDO deviceDO = deviceService.getDevice(devRent.getDevId());
        DevRentRespVO devRentRespVO = BeanUtils.toBean(devRent, DevRentRespVO.class);
        devRentRespVO.setCategory(deviceDO.getCategory());
        devRentRespVO.setBrand(deviceDO.getBrand());
        devRentRespVO.setModel(deviceDO.getModel());
        devRentRespVO.setDevState(devRent.getState());
        return CommonResult.success(devRentRespVO);
    }
    @GetMapping("/get-dismantled-rent")
    @Operation(summary = "获取已拆卸租赁情况")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<DevRentRespVO> getDismantledRent(@Valid DevRentSearchReqVO searchReqVO) {
        DevRentDO devRent = devRentService.getDismantledRent(searchReqVO);
        if (null == devRent) {
            return CommonResult.success(null);
        }
        DeviceDO deviceDO = deviceService.getDevice(devRent.getDevId());
        DevRentRespVO devRentRespVO = BeanUtils.toBean(devRent, DevRentRespVO.class);
        devRentRespVO.setCategory(deviceDO.getCategory());
        devRentRespVO.setBrand(deviceDO.getBrand());
        devRentRespVO.setModel(deviceDO.getModel());
        return CommonResult.success(devRentRespVO);
    }
    // ç¡®è´¹
    @GetMapping("/fee-confirm")
    @Operation(summary = "确费")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<Integer> feeConfirm(@Valid Long rentId) {
        Long userId = SecurityFrameworkUtils.getLoginUserId();
        String userNickname = SecurityFrameworkUtils.getLoginUserNickname();
        String userHisId = SecurityFrameworkUtils.getLoginUserHisId();
        // ã€ä¸»/围观】确费也要拦围观人:按该租赁单的患者反查其当前工位并校验主人身份。
        // ç”¨ Soft ç‰ˆæœ¬ï¼šç¡®è´¹å¸¸åœ¨æµç¨‹æœ«ç«¯è°ƒç”¨ï¼Œæ‚£è€…可能已不在队列上,查不到工位时只记警告放行。
        assertRentBedOwnerSoft(rentId);
        Integer result = devRentService.feeConfirm(rentId, userHisId, userNickname, true);
        if (1 == result) return CommonResult.error(result, "确费失败");
        return CommonResult.success(result);
    }
    // å–消确费
    @GetMapping("/fee-cancel")
    @Operation(summary = "取消确费")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<Integer> feeCancel(@Valid Long rentId) {
        Long userId = SecurityFrameworkUtils.getLoginUserId();
        String userNickname = SecurityFrameworkUtils.getLoginUserNickname();
        String userHisId = SecurityFrameworkUtils.getLoginUserHisId();
        // ã€ä¸»/围观】取消确费同样要拦围观人(Soft:查不到工位只记警告)
        assertRentBedOwnerSoft(rentId);
        Integer result = devRentService.feeConfirm(rentId, userHisId, userNickname, false);
        if (1 == result) return CommonResult.error(result, "取消确费失败");
        return CommonResult.success(result);
    }
    /**
     * ã€ä¸»/围观】确费 / å–消确费的主人校验。
     *
     * <p>接口只带 rentId,拿不到 roomId/bedNo,因此先按租赁单找到患者,
     * å†ç”± {@link cn.lihu.jh.module.ecg.service.room.RoomService#assertBedOwnerByPatientSoft}
     * æŒ‰æ‚£è€…当前所在工位校验主人身份(查不到工位时只记警告放行,避免误伤末端流程)。
     *
     * <p>注意:只包住"读租赁单"这一步,**不能**把主人校验的异常也吞掉,否则等于没有校验。
     */
    private void assertRentBedOwnerSoft(Long rentId) {
        cn.lihu.jh.module.ecg.dal.dataobject.devrent.DevRentDO rent;
        try {
            rent = devRentService.getDevRent(rentId);
        } catch (Exception e) {
            log.error("[assertRentBedOwnerSoft] è¯»å–租赁单失败,跳过主人校验. rentId={}", rentId, e);
            return;
        }
        roomService.assertBedOwnerByPatientSoft(null == rent ? null : rent.getPatId(),
                null == rent ? null : rent.getCheckType());
    }
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/devrent/vo/RoutineFinishReqVO.java
@@ -66,4 +66,16 @@
    @Schema(description = "诊床编号")
    String bedNo;
    /**
     * ã€æœ¬éœ€æ±‚:紧急患者不排队直接检查】是否「紧急加做」。
     *
     * <p>为 {@code true} æ—¶ï¼ŒåŽç«¯åœ¨è¯¥æ‚£è€… + æ£€æŸ¥é¡¹ç›®**今天没有「待检查(0)」检查单**的情况下,
     * ä¼šæŒ‰é¢„约单自动补建一条(因为这些患者没走签到/排队,正常签到才会建这条),
     * ä»Žè€Œå¯ä»¥åƒæŽ’队患者一样完成检查并写入「已完成列表」。
     *
     * <p>为 {@code false}/{@code null} æ—¶ï¼Œè¡Œä¸ºä¸Žæ”¹é€ å‰**完全一致**。
     */
    @Schema(description = "是否紧急加做(不排队直接检查)", example = "false")
    private Boolean emergency;
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/doctor/DoctorController.java
@@ -12,6 +12,7 @@
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.enums.QueueStatusEnum;
import cn.lihu.jh.module.ecg.annotation.RequireBedOwner;
import cn.lihu.jh.module.ecg.service.call.CallService;
import cn.lihu.jh.module.ecg.service.queue.QueueService;
import io.swagger.v3.oas.annotations.Operation;
@@ -25,6 +26,7 @@
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import static cn.lihu.jh.framework.common.exception.enums.GlobalErrorCodeConstants.SUCCESS;
@@ -42,9 +44,14 @@
    @Resource
    private QueueService queueService;
    /** ã€æœ¬æ¬¡ä¿®å¤ã€‘撤销"检查完成"时回滚检查单状态并取消确费 */
    @Resource
    private cn.lihu.jh.module.ecg.service.devrent.DevRentService devRentService;
    @Resource
    private CallService callService;
    @RequireBedOwner("暂停接诊")
    @GetMapping("/bed-doctor-pause")
    @Operation(summary = "医生暂停")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:state')")
@@ -64,6 +71,7 @@
        return error(result);
    }
    @RequireBedOwner("恢复接诊")
    @GetMapping("/bed-doctor-resume")
    @Operation(summary = "医生恢复")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:state')")
@@ -86,12 +94,15 @@
    @PreAuthorize("@ss.hasPermission('ecg:doctor:state')")
    public CommonResult<Integer> bedDoctorOn(
            @RequestParam("roomId") Long roomId,
            @RequestParam("bedNo") String bedNo)
            @RequestParam("bedNo") String bedNo,
            // å·¥ä½ä¸»é”®ï¼ˆå‰ç«¯è¯·æ±‚里本来就带):传入时优先按它定位,避免 bed_no ä¸Žè¯·æ±‚值
            // å­˜åœ¨è‚‰çœ¼éš¾è¾¨çš„差异(结尾空格/大小写/全角)时"明明空闲却更新 0 è¡Œ"
            @RequestParam(value = "id", required = false) Integer bedId)
    {
        Long userId = SecurityFrameworkUtils.getLoginUserId();
        String userNickname = SecurityFrameworkUtils.getLoginUserNickname();
        ErrorCode result = queueService.startBedDoctorOn(roomId, bedNo, userId, userNickname);
        ErrorCode result = queueService.startBedDoctorOn(roomId, bedNo, userId, userNickname, bedId);
        if (result.equals(SUCCESS))
            return success(0);
@@ -167,6 +178,7 @@
        return result;
    }
    @RequireBedOwner("叫号:取下一位患者")
    @GetMapping("/finish-next-patient")
    @Operation(summary = "常规叫号、下一位患者")
    @Parameter(name = "roomId", description = "诊室编号", required = true, example = "116")
@@ -193,6 +205,7 @@
        return success(BeanUtils.toBean(queueDOList, QueueRespVO.class));
    }
    @RequireBedOwner("领用:取下一位患者")
    @GetMapping("/finish-receive-next-patient")
    @Operation(summary = "领用叫号、下一位患者")
    @Parameter(name = "roomId", description = "诊室编号", required = true, example = "116")
@@ -219,6 +232,7 @@
        return success(BeanUtils.toBean(queueDOList, QueueRespVO.class));
    }
    @RequireBedOwner("装机:取下一位患者")
    @GetMapping("/finish-install-next-patient")
    @Operation(summary = "装机叫号、下一位患者")
    @Parameter(name = "roomId", description = "诊室编号", required = true, example = "116")
@@ -262,6 +276,7 @@
    }
    // å¸¸è§„检查过号、领用过号
    @RequireBedOwner("过号")
    @GetMapping("/pass-next-patient")
    @Operation(summary = "过号、下一位患者")
    @Parameter(name = "roomId", description = "诊室编号", required = true, example = "116")
@@ -286,6 +301,7 @@
        return success(BeanUtils.toBean(queueDOList, QueueRespVO.class));
    }
    @RequireBedOwner("装机过号")
    @GetMapping("/pass-install-next-patient")
    @Operation(summary = "安装过号、下一位患者")
    @Parameter(name = "roomId", description = "诊室编号", required = true, example = "116")
@@ -312,6 +328,7 @@
        return success(BeanUtils.toBean(queueDOList, QueueRespVO.class));
    }
    @RequireBedOwner("重叫")
    @GetMapping("/call-again")
    @Operation(summary = "[常规检查]或[领用] é‡å«ã€æ‚£è€…")
    @Parameter(name = "roomId", description = "诊室编号", required = true, example = "116")
@@ -342,6 +359,7 @@
    }
    @RequireBedOwner("重叫(安装)")
    @GetMapping("/call-install-again")
    @Operation(summary = "重叫、患者")
    @Parameter(name = "roomId", description = "诊室编号", required = true, example = "116")
@@ -382,7 +400,64 @@
            @RequestParam("bedNo") String bedNo)
    {
        List<QueueDO> queueDOList = queueService.getCheckRelatedPatient(roomId, bedNo);
        // ã€è‡ªåŠ©è¯Šæ–­ã€‘çŽ°åœºå¤šæ¬¡å‡ºçŽ°"列表是空的"却看不出原因:这里把**前端问的工位 + åŽç«¯è¿”回几条**
        // æ‰“出来,配合接口一起看就能立刻区分"前端问错工位 / åŽç«¯ç¡®å®žæ²¡æ•°æ® / é¡µé¢æ²¡åˆ·æ–°"。
        log.info("[getToBeCheckedPatientList] roomId={} bedNo=[{}] ç™»å½•人={} â†’ è¿”回 {} æ¡",
                roomId, bedNo, SecurityFrameworkUtils.getLoginUserId(),
                null == queueDOList ? 0 : queueDOList.size());
        return success(BeanUtils.toBean(queueDOList, QueueRespVO.class));
    }
    @GetMapping("/get-room-waiting-list")
    @Operation(summary = "取本诊室「排队中(未分配工位)」患者列表")
    @Parameter(name = "roomId", description = "诊室编号", required = true, example = "121")
    @Parameter(name = "bedNo", description = "工位编号", required = true, example = "F1")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<List<QueueRespVO>> getRoomWaitingList(
            @RequestParam("roomId") Long roomId,
            @RequestParam("bedNo") String bedNo)
    {
        // ã€æœ¬è¯Šå®¤æœªåˆ†é…ã€‘候诊列表只含"已分流到本工位"的患者;这里把"本工位检查类型、
        // è¿˜æ²¡åˆ†é…å·¥ä½"的排队患者(大屏能看到、工作台原先看不到)单独返回,供医生手工拉入。
        List<QueueDO> queueDOList = queueService.getUnassignedWaitingPatient(roomId, bedNo);
        log.info("[getRoomWaitingList] roomId={} bedNo=[{}] ç™»å½•人={} â†’ æœªåˆ†é…æŽ’队中 {} æ¡",
                roomId, bedNo, SecurityFrameworkUtils.getLoginUserId(),
                null == queueDOList ? 0 : queueDOList.size());
        return success(BeanUtils.toBean(queueDOList, QueueRespVO.class));
    }
    @RequireBedOwner("拉入排队患者")
    @GetMapping("/pull-waiting-patient")
    @Operation(summary = "把「排队中」的患者手工拉入本工位")
    @Parameter(name = "roomId", description = "诊室编号", required = true, example = "121")
    @Parameter(name = "bedNo", description = "工位编号", required = true, example = "F1")
    @Parameter(name = "id", description = "排队行id", required = true, example = "123")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<String> pullWaitingPatient(
            @RequestParam("roomId") Long roomId,
            @RequestParam("bedNo") String bedNo,
            @RequestParam("id") Integer id)
    {
        ErrorCode result = queueService.pullWaitingPatient(roomId, bedNo, id);
        if (result.equals(SUCCESS))
            return success("操作成功");
        return error(result);
    }
    @RequireBedOwner("立即补人")
    @GetMapping("/hurry-up-now")
    @Operation(summary = "立即补人(手动触发本工位自动分流,并返回诊断信息)")
    @Parameter(name = "roomId", description = "诊室编号", required = true, example = "121")
    @Parameter(name = "bedNo", description = "工位编号", required = true, example = "F1")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<Map<String, Object>> hurryUpNow(
            @RequestParam("roomId") Long roomId,
            @RequestParam("bedNo") String bedNo)
    {
        // çŽ°åœºè‡ªæ•‘ + è‡ªåŠ©è¯Šæ–­ï¼šå€™è¯ŠåŒºä¸ºç©ºæ—¶ç‚¹ä¸€ä¸‹ï¼Œç«‹åˆ»å¾—åˆ°
        // ã€Œç³»ç»Ÿå¼€è¯ŠçŠ¶æ€ / æ˜¯å¦åœæ­¢å€™è¯Š / å€™è¯Šä¸Šé™ / è¡¥äººå‰åŽäººæ•° / ç»“论」。
        return success(queueService.hurryUpNow(roomId, bedNo));
    }
    @GetMapping("/get-to-be-installed-list")
@@ -438,6 +513,9 @@
        return success(patientStatisticVO);
    }
    // ã€æœ¬éœ€æ±‚·排队队列供服务台分诊调度使用】不再做「工位主人」校验:
    // åˆ†è¯Šå°ä¸å åº§ï¼ˆclinic_room.doc_id æ˜¯åè¯ŠåŒ»ç”Ÿï¼‰ï¼Œå¦åˆ™å¿…报 1010002003「围观模式,不能执行该操作」。
    // ä»ä¿ç•™ @PreAuthorize('ecg:doctor:task') æƒé™ç‚¹ã€‚
    @GetMapping("/recall-pass-waiting-patient")
    @Operation(summary = "过号-排队中 æ‚£è€…召回")
    @Parameter(name = "patId", description = "患者编号", required = true, example = "B2")
@@ -454,6 +532,7 @@
        return success("操作成功");
    }
    // ã€æœ¬éœ€æ±‚·排队队列供服务台分诊调度使用】不再做「工位主人」校验(原因同上)。
    @GetMapping("/recall-patient")
    @Operation(summary = "过号病人召回")
    @Parameter(name = "roomId", description = "诊室编号", required = true, example = "116")
@@ -475,6 +554,7 @@
        return success("操作成功");
    }
    // ã€æœ¬éœ€æ±‚·排队队列供服务台分诊调度使用】不再做「工位主人」校验(原因同上)。
    @GetMapping("/recall-install-patient")
    @Operation(summary = "过号安装病人召回")
    @Parameter(name = "roomId", description = "诊室编号", required = true, example = "116")
@@ -490,10 +570,15 @@
            @RequestParam("roomId_operator") Long roomId_operator,
            @RequestParam("bedNo_operator") String bedNo_operator )
    {
        if (roomId != roomId_operator)
            return error(QUEUE_RECALL_INSTALL_NOT_CUR_ROOM);
        // ã€æœ¬éœ€æ±‚·排队队列供服务台分诊调度使用】去掉「操作者工位必须等于患者工位」的校验:
        //   Â· åˆ†è¯Šå°ä¸å åº§ï¼ŒroomId_operator / bedNo_operator å¯èƒ½æ˜¯ç©ºæˆ–与服务台当前工位不一致;
        //   Â· æŽ’队队列页传的是**队列行内的工位**,本来也只用于亲和项重新定位。
        // åŽŸæ ¡éªŒï¼šif (roomId != roomId_operator) return error(QUEUE_RECALL_INSTALL_NOT_CUR_ROOM);
        // çŽ°åœ¨ï¼šæ“ä½œè€…å·¥ä½ç¼ºå¤±æ—¶ï¼Œé€€åŒ–ä¸ºç”¨æ‚£è€…é˜Ÿåˆ—è¡Œä¸Šçš„å·¥ä½ï¼ˆroomId/bedNo)。
        Long operatorRoomId = (null != roomId_operator) ? roomId_operator : roomId;
        String operatorBedNo = (null != bedNo_operator && !bedNo_operator.isEmpty()) ? bedNo_operator : bedNo;
        Integer result = queueService.recallInstallPatient(roomId, bedNo, patId, checkType, roomId_operator, bedNo_operator);
        Integer result = queueService.recallInstallPatient(roomId, bedNo, patId, checkType, operatorRoomId, operatorBedNo);
        if (null == result || 0 == result)
            return error(PATIENT_NOT_EXISTS);
@@ -502,6 +587,7 @@
    // ==================== éœ€æ±‚2:撤销「叫号 / æ£€æŸ¥å®Œæˆã€ ====================
    @RequireBedOwner("撤销检查完成")
    @GetMapping("/undo-finish-patient")
    @Operation(summary = "撤销检查完成:已就诊(40) â†’ å€™è¯Šä¸­(20)")
    @Parameter(name = "roomId", description = "诊室编号", required = true, example = "116")
@@ -519,9 +605,14 @@
        if (null == result || 0 == result) {
            return error(APPOINTMENT_NOT_EXISTS);
        }
        // ã€æœ¬æ¬¡ä¿®å¤ã€‘只回滚队列状态是不够的:检查单(dev_rent)仍停在「常规检查完成(60)」,
        // å‰ç«¯å†æ¬¡å®Œæˆæ—¶å–不到「待检查」单(rentId ä¸ºç©ºï¼‰â†’ åŽç«¯æŠ¥ã€Œè¯·å…ˆé¢„约确认」。
        // è¿™é‡ŒæŠŠæ£€æŸ¥å•回滚成「待检查」并取消确费(内部已 try/catch,失败不影响撤销结果)。
        devRentService.rollbackRoutineFinish(patId, checkType);
        return success("已撤销检查完成,患者已返回候诊区");
    }
    @RequireBedOwner("撤销叫号")
    @GetMapping("/undo-call-patient")
    @Operation(summary = "撤销叫号:就诊中(30) â†’ å€™è¯Šä¸­(20)")
    @Parameter(name = "roomId", description = "诊室编号", required = true, example = "116")
@@ -551,6 +642,7 @@
    // ==================== éœ€æ±‚3:停止候诊开关 ====================
    @RequireBedOwner("停止候诊")
    @GetMapping("/stop-ready")
    @Operation(summary = "停止候诊:不再新增候诊患者(已有候诊仍可叫号)")
    @Parameter(name = "roomId", description = "诊室编号", required = true, example = "116")
@@ -566,6 +658,7 @@
        return success(true);
    }
    @RequireBedOwner("恢复候诊")
    @GetMapping("/resume-ready")
    @Operation(summary = "恢复候诊:重新开始接收候诊患者")
    @Parameter(name = "roomId", description = "诊室编号", required = true, example = "116")
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/jobrecord/JobRecordController.java
@@ -26,6 +26,7 @@
import cn.lihu.jh.module.ecg.controller.admin.jobrecord.vo.*;
import cn.lihu.jh.module.ecg.dal.dataobject.jobrecord.JobRecordDO;
import cn.lihu.jh.module.ecg.service.jobrecord.JobRecordService;
import cn.lihu.jh.module.ecg.service.room.RoomService;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;
@@ -39,6 +40,10 @@
    @Resource
    private JobRecordService jobRecordService;
    /** ã€ä¸»/围观】围观人查"工位主人的记录"时需要校验该工位当前主人 */
    @Resource
    private RoomService roomService;
    @PostMapping("/create")
    @Operation(summary = "创建工作记录")
@@ -82,13 +87,24 @@
    }
    @GetMapping("/doctor-page")
    @Operation(summary = "获得用户自己的工作记录分页")
    @Operation(summary = "获得工作记录分页(默认查自己;围观时按工位主人查)")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<PageResult<JobRecordRespVO>> getJobRecordDoctorPage(@Valid JobRecordPageReqVO pageReqVO) {
    public CommonResult<PageResult<JobRecordRespVO>> getJobRecordDoctorPage(
            @Valid JobRecordPageReqVO pageReqVO,
            @RequestParam(value = "roomId", required = false) Long roomId,
            @RequestParam(value = "bedNo", required = false) String bedNo) {
        Long userId = SecurityFrameworkUtils.getLoginUserId();
        String userNickname = SecurityFrameworkUtils.getLoginUserNickname();
        pageReqVO.setDocId( userId );
        // ã€ä¸»/围观】默认查自己的记录(保持原行为);
        // ä¼ äº†åˆ«äººçš„ docId(围观时前端传"该工位主人")→ å¿…须校验该工位当前主人确实是这个 docId,
        // å¦åˆ™å›´è§‚人看到的"已完成检查记录"永远是空的(原实现无条件覆盖成登录人)。
        Long ownerDocId = pageReqVO.getDocId();
        if (null == ownerDocId || ownerDocId.equals(userId)) {
            pageReqVO.setDocId(userId);
        } else {
            roomService.assertBedOwnerIs(roomId, bedNo, ownerDocId);
        }
        PageResult<JobRecordDO> pageResult = jobRecordService.getJobRecordPage(pageReqVO);
        return success(BeanUtils.toBean(pageResult, JobRecordRespVO.class));
    }
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/jobrecord/vo/JobRecordRespVO.java
@@ -63,4 +63,11 @@
    @Schema(description = "已缴费", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @ExcelProperty("已缴费")
    private Integer paid;
    /**
     * ã€æ’¤é”€æ ‡è¯†ï½œéžåº“字段】该条完成记录对应的患者"当日队列状态已不是已就诊(40)",
     * å³**检查完成被撤销过、还没重新完成**。撤销后自动变 true,再次完成后自动变 false。
     */
    @Schema(description = "是否已撤销(完成被撤销、尚未重新完成)", example = "false")
    private Boolean undone;
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/queue/vo/QueuePageReqVO.java
@@ -46,7 +46,8 @@
    @Schema(description = "预约检查类型", example = "1")
    private Integer bookCheckType;
    @Schema(description = "预约序号")
    /** ã€å·²åœç”¨ã€‘排队号统一为 seqNum;本字段仅保留兼容,查询请用 seqNum */
    @Schema(description = "预约序号(已停用,请用 seqNum)")
    private Integer bookSeqNum;
    @Schema(description = "排队序号")
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/queue/vo/QueueRespVO.java
@@ -57,11 +57,12 @@
    @ExcelProperty("预约检查类型")
    private Integer bookCheckType;
    @Schema(description = "预约序号")
    /** ã€å·²åœç”¨ã€‘排队号统一为 seqNum(见下);本字段仅保留以兼容老前端/老数据,不再写入与显示 */
    @Schema(description = "预约序号(已停用,请用 seqNum)")
    @ExcelProperty("预约序号")
    private Integer bookSeqNum;
    @Schema(description = "排队序号", requiredMode = Schema.RequiredMode.REQUIRED)
    @Schema(description = "排队序号(= check_type.seq_prefix + æœ¬å€¼ï¼‰", requiredMode = Schema.RequiredMode.REQUIRED)
    @ExcelProperty("排队序号")
    private Integer seqNum;
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/queue/vo/QueueSaveReqVO.java
@@ -55,7 +55,8 @@
    @Schema(description = "预约检查类型", example = "1")
    private Integer bookCheckType;
    @Schema(description = "预约序号", requiredMode = Schema.RequiredMode.REQUIRED)
    /** ã€å·²åœç”¨ã€‘排队号统一为 seqNum(见下);本字段仅保留以兼容老调用方,新代码请勿赋值 */
    @Schema(description = "预约序号(已停用,请用 seqNum)", requiredMode = Schema.RequiredMode.REQUIRED)
    //@NotNull(message = "预约序号不能为空")
    private Integer bookSeqNum;
@@ -63,6 +64,7 @@
    //@NotNull(message = "是否VIP")
    private Integer isVip;
    /** æŽ’队号:签到时按 check_type.seq_rule åˆ†é…ä¸€æ¬¡ï¼Œä¹‹åŽä¸å†æ”¹åЍ */
    @Schema(description = "排队序号", requiredMode = Schema.RequiredMode.REQUIRED)
    @NotNull(message = "排队序号不能为空")
    private Integer seqNum;
@@ -95,9 +97,11 @@
     * <p>
     * æ¥æºï¼šHIS æŠ¥æ–‡ {@code observationRequest.zdy.jjbz}(N=常规,J=加急)
     * â†’ é¢„约记录 {@code appointment.jjbz} â†’ é¢„约确认时带入排队。
     * ä¸Žäººå·¥æ’队({@code QueueServiceImpl#patientJump})共用该字段:
     * {@code queue} è¡¨æŒ‰ {@code order by jump_flag desc, book_timeslot, create_time} å–号,
     * å› æ­¤ç½® 1 å³å®žçް"加急优先"。
     * ä¸Žäººå·¥æ’队({@code QueueServiceImpl#patientJump})共用该字段。
     * <p>
     * ã€å·ç»Ÿä¸€åŽã€‘排队号在签到时一次性定死、之后不再改动,加急**不再靠改号/插队重编号**,
     * è€Œæ˜¯é æŽ’序:分流取人、叫号取人以及工作台/诊间屏/大屏列表均为
     * {@code jump_flag desc ä¼˜å…ˆ â†’ seq_num å‡åº},因此置 1 å³å®žçް"加急优先"。
     */
    @Schema(description = "加急标记 1=加急")
    private Byte jumpFlag;
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/room/RoomController.java
@@ -10,6 +10,7 @@
import javax.validation.Valid;
import cn.hutool.extra.servlet.ServletUtil;
import cn.lihu.jh.module.ecg.utils.IpUtils;
import cn.lihu.jh.module.ecg.controller.admin.room.vo.*;
import cn.lihu.jh.module.ecg.dal.dataobject.queue.QueueDO;
import cn.lihu.jh.module.ecg.dal.dataobject.room.RoomDO;
@@ -29,11 +30,16 @@
import cn.lihu.jh.framework.common.pojo.PageParam;
import cn.lihu.jh.framework.common.pojo.PageResult;
import cn.lihu.jh.framework.common.pojo.CommonResult;
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.framework.excel.core.util.ExcelUtils;
import cn.lihu.jh.framework.apilog.core.annotation.ApiAccessLog;
import cn.lihu.jh.framework.security.core.util.SecurityFrameworkUtils;
import lombok.extern.slf4j.Slf4j;
import static cn.lihu.jh.framework.common.pojo.CommonResult.success;
import static cn.lihu.jh.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.lihu.jh.framework.apilog.core.enums.OperateTypeEnum.*;
@@ -41,6 +47,7 @@
@RestController
@RequestMapping("/clinic/room")
@Validated
@Slf4j
public class RoomController {
    @Resource
@@ -92,6 +99,258 @@
        String reqIp = ServletUtil.getClientIP(request);
        RoomProfile roomProfile = roomService.getRoomByIP(reqIp, roomId);
        return success(BeanUtils.toBean(roomProfile, RoomProfileRespVO.class));
    }
    /* ==================== é€‰æ‹©å·¥ä½æ¨¡å¼ï¼ˆéœ€æ±‚:诊室工位选择模式 0/1/2/3) ==================== */
    /**
     * æŒ‰æœ¬æœºIP匹配工位(模式 1/3 ç”¨ï¼‰ã€‚
     *
     * <p>只读接口:
     * <ul>
     *   <li>把后端实际识别到的来访IP一起返回,便于排查"前置代理未透传 X-Forwarded-For";</li>
     *   <li>候选工位已按「本人已占用 â†’ ç©ºé—² â†’ è¢«ä»–人占用」排好序,前端取第一个可用的自动入座;</li>
     *   <li>本接口不改任何状态,入座仍走原有的 bedDoctorOn æµç¨‹ã€‚</li>
     * </ul>
     * éœ€è¦ç™»å½•(未登录时返回空结果),但不做菜单权限校验:能登录就能查自己的工位。</p>
     */
    @GetMapping("/get-ip-bed")
    @Operation(summary = "按本机IP匹配工位(选择工位模式 1/3)")
    public CommonResult<RoomSelectBedRespVO> getIpBed(HttpServletRequest request) {
        // è§„范化:去括号/区域号、统一小写、IPv4-mapped IPv6 è¿˜åŽŸæˆ IPv4(支持 IPv4 ä¸Ž IPv6)
        String clientIp = IpUtils.normalize(ServletUtil.getClientIP(request));
        Long docId = SecurityFrameworkUtils.getLoginUserId();
        RoomSelectBedRespVO resp = new RoomSelectBedRespVO();
        resp.setClientIp(clientIp);
        resp.setIpValid(IpUtils.isBindableIp(clientIp));
        resp.setIpLoopback(IpUtils.isLoopback(clientIp));
        resp.setIpv6(IpUtils.isIpv6Address(clientIp));
        List<RoomSelectBedRespVO.RoomSelectBedItem> beds = roomService.matchBedsByIp(clientIp, docId);
        resp.setBeds(beds);
        resp.setIpBound(!beds.isEmpty());
        // æœ‰"本人已占用"或"空闲"的工位,就能自动入座
        resp.setMatched(beds.stream()
                .anyMatch(b -> Boolean.TRUE.equals(b.getMine()) || !Boolean.TRUE.equals(b.getOccupied())));
        log.info("[getIpBed][按IP匹配工位] clientIp={} docId={} ipValid={} loopback={} å€™é€‰æ•°={} å¯è‡ªåŠ¨å…¥åº§={}",
                clientIp, docId, resp.getIpValid(), resp.getIpLoopback(), beds.size(), resp.getMatched());
        return success(resp);
    }
    /**
     * æŠŠæœ¬æœºIP写入指定工位(模式 2/3 ç”¨ï¼‰ã€‚
     *
     * <p>只更新 {@code clinic_room.ip} ä¸€ä¸ªå­—段,不走 {@code clinic:room:update}(否则为了选工位还得给医生开诊室管理权限),
     * ä¹Ÿä¸ä¼šç¢° status/doc_id/check_types ç­‰å…¶å®ƒå­—段。
     * è‹¥è¯¥IP已绑在别的工位上,默认返回冲突工位让前端确认;确认后带 {@code force=true} å†è°ƒä¸€æ¬¡å³è¦†ç›–。</p>
     */
    @PostMapping("/bind-ip")
    @Operation(summary = "把本机IP绑定到工位(选择工位模式 2/3)")
    public CommonResult<Map<String, Object>> bindIp(HttpServletRequest request,
                                                    @RequestParam("id") Integer id,
                                                    @RequestParam(value = "force", required = false, defaultValue = "false") Boolean force) {
        Map<String, Object> data = new HashMap<>();
        String rawIp = ServletUtil.getClientIP(request);
        String clientIp = IpUtils.normalize(rawIp);
        data.put("clientIp", clientIp);
        if (!IpUtils.isBindableIp(clientIp)) {
            data.put("code", 400);
            if (IpUtils.isLoopback(clientIp)) {
                // æµè§ˆå™¨å’ŒæœåŠ¡åœ¨åŒä¸€å°æœºå™¨ä¸Šè®¿é—®æ—¶å¿…ç„¶å‡ºçŽ°ï¼šIPv4 æ˜¯ 127.0.0.1,IPv6 æ˜¯ ::1
                data.put("msg", "无法绑定工位:检测到当前访问来自本机回环地址(" + clientIp
                        + ",即本机访问),请在医生实际使用的电脑上访问后再绑定");
            } else if (IpUtils.isUnspecified(clientIp)) {
                data.put("msg", "无法绑定工位:当前识别到的地址是未指定地址(" + clientIp + ")");
            } else if (IpUtils.isLocalAddress(clientIp)) {
                // æœ¬æ¬¡æ–°å¢žï¼šçŽ°åœºæœ€å¸¸è§çš„"IP ä¸å¯¹"就是这种 â€”— åœ¨æœåŠ¡å™¨ä¸Šï¼ˆæˆ–è¿œç¨‹æ¡Œé¢é‡Œï¼‰è®¿é—®ç³»ç»Ÿï¼Œ
                // å®¢æˆ·ç«¯æœ¬æ¥å°±æ˜¯æœåŠ¡å™¨è‡ªå·±ï¼›è‹¥æŠŠå®ƒå†™è¿›å·¥ä½ï¼Œè¯¥å·¥ä½ä»¥åŽæ°¸è¿œåŒ¹é…ä¸åˆ°åŒ»ç”Ÿç”µè„‘ã€‚
                data.put("msg", "无法绑定工位:识别到的是**服务器自身地址**(" + clientIp
                        + ")—— è¯´æ˜Žå½“前是在服务器上访问,或经远程桌面/本机代理访问,这不是医生电脑的 IP。"
                        + "请在医生实际使用的电脑上、用服务器的 IPv4 åœ°å€è®¿é—®åŽå†ç»‘定"
                        + "(可用「IP è¯Šæ–­ã€æŸ¥çœ‹åŽç«¯åˆ°åº•把谁当成了客户端)");
                log.warn("[bindIp][识别到服务器自身地址,拒绝绑定] clientIp={} bedId={} æœåŠ¡å™¨ç½‘å¡IPv4={}",
                        clientIp, id, IpUtils.localIpv4Addresses());
            } else if (IpUtils.isIpv6Address(clientIp)) {
                // æœ¬ç³»ç»Ÿå·¥ä½ç»‘定统一采用 IPv4;客户端走 IPv6 æ—¶æ— æ³•从代码"退化成"IPv4
                data.put("msg", "无法绑定工位:当前访问使用的是 IPv6 åœ°å€ï¼ˆ" + clientIp
                        + "),本系统工位绑定仅采用 IPv4,请改用服务器的 IPv4 åœ°å€è®¿é—®"
                        + "(或让服务只监听 IPv4:JVM å‚æ•° -Djava.net.preferIPv4Stack=true)");
            } else {
                data.put("msg", "无法绑定工位:未能识别到合法的 IPv4 æ¥è®¿åœ°å€ï¼ˆ" + clientIp
                        + "),请检查是否通过代理访问、以及代理是否透传客户端地址(X-Forwarded-For)");
            }
            log.warn("[bindIp][IP不可用,拒绝绑定] raw={} normalized={} ipv6={} bedId={}",
                    rawIp, clientIp, IpUtils.isIpv6Address(clientIp), id);
            return success(data);
        }
        // å†²çªæ£€æµ‹ï¼šè¯¥IP已绑在别的工位 â†’ å…ˆè®©å‰ç«¯ç¡®è®¤
        List<RoomDO> conflicts = roomService.listIpBindings(clientIp, id);
        if (!conflicts.isEmpty() && !Boolean.TRUE.equals(force)) {
            RoomDO c = conflicts.get(0);
            data.put("code", 409);
            data.put("conflict", true);
            data.put("conflictBedId", c.getId());
            data.put("conflictRoomName", c.getRoomName());
            data.put("conflictBedNo", c.getBedNo());
            data.put("msg", "该IP已绑定「" + c.getRoomName() + " / " + c.getBedNo() + "」");
            return success(data);
        }
        Integer rows = roomService.bindIpToBed(id, clientIp);
        data.put("code", rows != null && rows > 0 ? 200 : 500);
        data.put("msg", rows != null && rows > 0 ? "已绑定本机IP到该工位" : "绑定失败(工位不存在?)");
        data.put("rows", rows);
        log.info("[bindIp][绑定结果] raw={} clientIp={} bedId={} force={} rows={}", rawIp, clientIp, id, force, rows);
        return success(data);
    }
    /* ==================== è‡ªåŠ¨å…¥åº§ / æˆ‘的工位(本次需求) ==================== */
    /**
     * ä¸€é”®è‡ªåŠ¨å…¥åº§ï¼ˆé€‰æ‹©å·¥ä½æ¨¡å¼ 1/3 ç”¨ï¼‰ã€‚
     *
     * <p>为什么合并成一个接口:前端原来把「匹配 â†’ ç»‘ IP â†’ å…¥åº§ã€æ‹†æˆä¸‰ä¸ªè¯·æ±‚自己拼,
     * ä»»ä¸€æ­¥å¤±è´¥éƒ½åªèƒ½å›žè½"手选工位",现场表现就是"登录后没有自动入座"且看不出原因。
     * çŽ°åœ¨ä¸€æ¬¡è°ƒç”¨å®Œæˆï¼Œå¹¶è¿”å›ž**识别到的 IP** ä¸Ž**未入座原因**
     * ï¼ˆ{@code ip_invalid / nomatch / multi / occupied / error}),排查一眼可辨。
     *
     * <p>需要登录,但不做菜单权限校验:能登录就能坐自己的工位。
     */
    @PostMapping("/auto-seat")
    @Operation(summary = "按本机IP自动匹配工位并自动入座(选择工位模式 1/3)")
    public CommonResult<RoomSeatRespVO> autoSeat(HttpServletRequest request) {
        String clientIp = IpUtils.normalize(ServletUtil.getClientIP(request));
        Long docId = SecurityFrameworkUtils.getLoginUserId();
        String docName = SecurityFrameworkUtils.getLoginUserNickname();
        RoomSeatRespVO resp = roomService.autoSeatByIp(clientIp, docId, docName);
        log.info("[autoSeat][自动入座] clientIp={} docId={} seated={} reason={} å·¥ä½={}/{}",
                clientIp, docId, resp.getSeated(), resp.getReason(), resp.getRoomName(), resp.getBedNo());
        return success(resp);
    }
    /**
     * æŸ¥è¯¢å½“前登录人**已入座**的工位(系统顶栏"当前工位"标签用)。
     *
     * <p>未入座时返回 {@code seated=false},前端据此显示灰色「未入座」并可点击去选工位。
     */
    @GetMapping("/my-seat")
    @Operation(summary = "查询当前登录人已入座的工位(系统顶栏展示)")
    public CommonResult<RoomSeatRespVO> mySeat() {
        Long docId = SecurityFrameworkUtils.getLoginUserId();
        RoomSeatRespVO resp = roomService.getMySeat(docId);
        log.info("[mySeat][查询我的工位] docId={} seated={} å·¥ä½={}/{}",
                docId, resp.getSeated(), resp.getRoomName(), resp.getBedNo());
        return success(resp);
    }
    /**
     * ã€æœ¬éœ€æ±‚·一人多工位】查询当前登录人占据的**全部**工位(节假日值班多岗位)。
     *
     * <p>返回「医生在座 / åŒ»ç”Ÿæš‚停」的工位列表,按 roomId + bedNo å‡åºï¼›æœªå å·¥ä½è¿”回空数组。
     * å‰ç«¯ç”¨å®ƒæ¸²æŸ“顶栏「我的工位(N ä¸ªï¼‰ã€å¹¶åœ¨å·¥ä½ä¹‹é—´åˆ‡æ¢ã€‚
     */
    @GetMapping("/my-seats")
    @Operation(summary = "查询当前登录人已入座的全部工位(一人多岗位)")
    public CommonResult<List<RoomSeatRespVO>> mySeats() {
        Long docId = SecurityFrameworkUtils.getLoginUserId();
        List<RoomSeatRespVO> seats = roomService.getMySeats(docId);
        log.info("[mySeats][查询我的全部工位] docId={} æ•°é‡={}", docId, null == seats ? 0 : seats.size());
        return success(seats);
    }
    /**
     * ã€æœ¬éœ€æ±‚·接班自助接管】把工位从"别人占用"改为"当前登录人占用"。
     *
     * <p>场景:上一班医生下班未点「离座」,下一班医生在同一台电脑/同一工位接班。
     * åŽŸå ç”¨äººä¼šè¢«å¼ºåˆ¶ç¦»åº§ï¼ˆå…¶é¡µé¢ 5s è½®è¯¢ä¼šæç¤ºã€Œè¯·é‡æ–°å°±åã€å¹¶è½¬åªè¯»ï¼‰ã€‚
     * æƒé™æ²¿ç”¨åŒ»ç”Ÿå·¥ä½œå°å·²æœ‰çš„ {@code ecg:doctor:task},无需新增权限点。
     */
    @PostMapping("/take-over-bed")
    @Operation(summary = "接管工位(接班:把他人占用的工位转给我)")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<Boolean> takeOverBed(@RequestParam("roomId") Long roomId,
                                             @RequestParam("bedNo") String bedNo) {
        roomService.takeOverBed(roomId, bedNo);
        return success(true);
    }
    /**
     * å®¢æˆ·ç«¯ IP è¯†åˆ«è¯Šæ–­ï¼ˆçŽ°åœºæŽ’æŸ¥"工位绑定的 IP ä¸å¯¹"用)。
     *
     * <p>一次把所有相关线索摊开:各个代理头、TCP ç›´è¿žåœ°å€ã€è§„范化结果、是否回环/IPv6、
     * **是否是服务器自身地址**、以及服务器所有 IPv4 ç½‘卡地址。界面上的「IP è¯Šæ–­ã€æŒ‰é’®å°±è°ƒå®ƒã€‚
     *
     * <p>典型结论:
     * <ul>
     *   <li>取到的是服务器自身地址 â†’ å½“前是在服务器上/远程桌面里访问,或代理把客户端地址覆盖成了服务器地址;</li>
     *   <li>取到 127.x / ::1 â†’ æµè§ˆå™¨ä¸ŽæœåŠ¡åœ¨åŒä¸€å°æœºå™¨ï¼›</li>
     *   <li>取到 IPv6 â†’ å®¢æˆ·ç«¯èµ° IPv6(本系统工位绑定只用 IPv4);</li>
     *   <li>{@code X-Forwarded-For} ä¸ºç©ºä¸”直连地址=网关 â†’ å‰é¢çš„代理没透传客户端地址。</li>
     * </ul>
     */
    @GetMapping("/ip-debug")
    @Operation(summary = "客户端IP识别诊断(排查工位绑定IP不对)")
    public CommonResult<Map<String, Object>> ipDebug(HttpServletRequest request) {
        Map<String, Object> data = new LinkedHashMap<>();
        String xff = request.getHeader("X-Forwarded-For");
        String realIp = request.getHeader("X-Real-IP");
        String remoteAddr = request.getRemoteAddr();
        String raw = ServletUtil.getClientIP(request);
        String clientIp = IpUtils.normalize(raw);
        data.put("访问Host", request.getHeader("Host"));
        data.put("X-Forwarded-For", xff);
        data.put("X-Real-IP", realIp);
        data.put("Proxy-Client-IP", request.getHeader("Proxy-Client-IP"));
        data.put("WL-Proxy-Client-IP", request.getHeader("WL-Proxy-Client-IP"));
        data.put("HTTP_CLIENT_IP", request.getHeader("HTTP_CLIENT_IP"));
        data.put("直连地址(remoteAddr)", remoteAddr);
        data.put("取到的原始值", raw);
        data.put("规范化后(工位按此匹配)", clientIp);
        data.put("可用于工位绑定", IpUtils.isBindableIp(clientIp));
        data.put("是回环(浏览器与服务同机)", IpUtils.isLoopback(clientIp));
        data.put("是IPv6", IpUtils.isIpv6Address(clientIp));
        data.put("是服务器自身地址", IpUtils.isLocalAddress(clientIp));
        data.put("服务器网卡IPv4", IpUtils.localIpv4Addresses());
        String verdict;
        if (IpUtils.isLoopback(clientIp)) {
            verdict = "客户端就是服务器本机(回环 " + clientIp + "):属【浏览器与服务在同一台机器】的正常现象,"
                    + "不能作为工位标识,请在医生电脑上访问。";
        } else if (IpUtils.isLocalAddress(clientIp)) {
            verdict = "⚠ è¯†åˆ«åˆ°çš„æ˜¯ã€æœåŠ¡å™¨è‡ªèº«åœ°å€ã€‘ï¼ˆ" + clientIp + "):当前是在服务器上/远程桌面里访问,"
                    + "或前置代理把客户端地址覆盖成了服务器地址 â†’ ä¸èƒ½ä½œä¸ºå·¥ä½æ ‡è¯†ã€‚"
                    + "请在医生电脑上、用服务器的 IPv4 åœ°å€è®¿é—®ï¼›è‹¥ç¡®è®¤æ˜¯åœ¨åŒ»ç”Ÿç”µè„‘上访问,请检查代理是否透传 "
                    + "X-Forwarded-For(nginx: proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;)。";
        } else if (IpUtils.isIpv6Address(clientIp)) {
            verdict = "客户端走的是 IPv6(" + clientIp + "):本系统工位绑定只采用 IPv4,"
                    + "请让服务只监听 IPv4(JVM: -Djava.net.preferIPv4Stack=true)或改用 IPv4 åœ°å€è®¿é—®ã€‚";
        } else if (IpUtils.isBindableIp(clientIp)) {
            verdict = "识别正常,可用于工位绑定:" + clientIp;
        } else {
            verdict = "未能识别到可用的 IPv4 å®¢æˆ·ç«¯åœ°å€ï¼ˆåŽŸå§‹å€¼ï¼š" + raw + ",直连地址:" + remoteAddr
                    + "):请检查代理是否透传客户端地址(X-Forwarded-For)。";
        }
        data.put("诊断结论", verdict);
        log.info("[ipDebug][客户端IP诊断] Host={} XFF={} X-Real-IP={} remoteAddr={} â†’ clientIp={} æœåŠ¡å™¨åœ°å€={}",
                request.getHeader("Host"), xff, realIp, remoteAddr, clientIp, IpUtils.isLocalAddress(clientIp));
        return success(data);
    }
    /**
     * å·¥ä½çŠ¶æ€è¯Šæ–­ï¼ˆæŽ’æŸ¥"入座报状态异常 / æ˜Žæ˜Žç©ºé—²å´åä¸ä¸Š")。
     *
     * <p>把库里**原始行**摊开:`id / room_id / room_name / bed_no(含长度与 HEX,用于发现结尾空格、全角) /
     * ip / status / doc_id / doc_name / op_type / deleted / update_time`,并给出一句结论。
     *
     * <p>用法:`GET /clinic/room/bed-state?id=3` æˆ– `?roomId=118&bedNo=C1`。
     */
    @GetMapping("/bed-state")
    @Operation(summary = "工位状态诊断(排查入座报状态异常)")
    public CommonResult<Map<String, Object>> bedState(
            @RequestParam(value = "id", required = false) Integer id,
            @RequestParam(value = "roomId", required = false) Long roomId,
            @RequestParam(value = "bedNo", required = false) String bedNo) {
        return success(roomService.diagnoseBed(id, roomId, bedNo));
    }
    @GetMapping("/page")
@@ -166,6 +425,10 @@
        List<RoomDO> list = roomService.simpleRoomList();
        List<RoomRespVO> list2 = BeanUtils.toBean(list, RoomRespVO.class);
        // æ˜¾å¼æ ‡æ³¨ã€Œæ˜¯å¦å·²è¢«åŒ»ç”Ÿå ç”¨ã€ï¼šé€‰å·¥ä½é¡µæ®æ­¤æ˜¾ç¤ºã€Œå·²å…¥åº§ï¼šå¼ ä¸‰ã€å¹¶**禁用入座按钮**,
        // ä¸ä¾èµ– BeanUtils æ˜¯å¦æŠŠ docId å¤åˆ¶è¿‡æ¥ï¼ˆåŒä¿é™©ï¼›æœ¬æ¬¡çŽ°åœºå°±æœ‰"看不出来谁占了工位"的反馈)。
        list2.forEach(roomRespVO -> roomRespVO.setOccupied(null != roomRespVO.getDocId()));
        Map<Long, List<RoomRespVO>> map = list2.stream().collect(Collectors.groupingBy(RoomRespVO::getRoomId));
        return success(map);
    }
@@ -200,5 +463,29 @@
        return success(0);
    }
    /**
     * ã€æœ¬æ¬¡éœ€æ±‚】管理员强制清空某个工位(不要求是占用者本人)。
     * <p>
     * ä½¿ç”¨åœºæ™¯ï¼šåŒ»ç”Ÿä¸‹ç­å¿˜è®°ã€Œç¦»åº§ã€ï¼Œæˆ–换人使用同一工位。
     * <p>
     * ä¸ŽåŒ»ç”Ÿè‡ªå·±çš„「离座」接口({@code /ecg/doctor/bed-doctor-off})的区别:
     * Â· è¯¥æŽ¥å£åªèƒ½æ¸…**自己**占用的工位(SQL å¸¦ {@code doc_id = å½“前用户});
     * Â· æœ¬æŽ¥å£é¢å‘管理端,可以清**任何人**占用的工位(仅要求该工位当前是「医生在岗 / æš‚停」)。
     * <p>
     * å·¥ä½ç©ºé—²æ—¶ä¼šè¿”回「当前工位状态不允许该操作」——这是预期行为。
     */
    @PostMapping("/force-clear-bed")
    @Operation(summary = "管理员强制清空工位", description = "医生下班忘离座 / æ¢äººä½¿ç”¨åŒä¸€å·¥ä½æ—¶ä½¿ç”¨")
    @PreAuthorize("@ss.hasPermission('clinic:room:update')")
    public CommonResult<Boolean> forceClearBed(@RequestParam("roomId") Long roomId,
                                               @RequestParam("bedNo") String bedNo) {
        ErrorCode ec = queueService.startBedDoctorOffByAdmin(roomId, bedNo);
        if (!GlobalErrorCodeConstants.SUCCESS.getCode().equals(ec.getCode())) {
            log.warn("[forceClearBed] æ¸…空工位失败 roomId={} bedNo={} code={} msg={}",
                    roomId, bedNo, ec.getCode(), ec.getMsg());
            throw exception(ec);
        }
        return success(true);
    }
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/room/vo/MonitorInfoVO.java
@@ -9,7 +9,13 @@
@Data
public class MonitorInfoVO {
    private Integer openingBedNum;
    /** åœ¨åº§**工位数**(医生在座 / åŒ»ç”Ÿæš‚停 çš„工位数量;一人多工位时可能大于医生数) */
    private Integer docBedNum;
    /**
     * ã€æœ¬éœ€æ±‚·统计按人合并】在座**医生数**(按 doc_id åŽ»é‡ï¼‰ã€‚
     * <p>一人多工位后 {@link #docBedNum} ä¼šæŒ‰å·¥ä½ç´¯è®¡ï¼Œç›‘控页要表达"到底几个医生在岗"就用本字段。
     */
    private Integer docNum;
    private Integer routingBedNum;
    private Integer openingFlag;
    private Map<Integer, String> checkTypeBedInfo;
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/room/vo/RoomRespVO.java
@@ -52,6 +52,13 @@
    @ExcelProperty("医生名")
    private String docName;
    /**
     * è¯¥å·¥ä½æ˜¯å¦å·²è¢«åŒ»ç”Ÿå ç”¨ï¼ˆdocId éžç©ºå³ä¸ºå ç”¨ï¼‰ã€‚
     * <p>用于"按IP自动匹配工位"时判断候选工位是否可用;仅查询返回,不落库。</p>
     */
    @Schema(description = "是否已被医生占用")
    private Boolean occupied;
    @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
    @ExcelProperty("创建时间")
    private LocalDateTime createTime;
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/room/vo/RoomSeatRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,72 @@
package cn.lihu.jh.module.ecg.controller.admin.room.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
 * ã€Œå·¥ä½å…¥åº§ã€ç»“æžœ â€”— è‡ªåŠ¨å…¥åº§ï¼ˆ{@code POST /clinic/room/auto-seat})
 * ä¸Žã€ŒæŸ¥è¯¢æˆ‘的工位」({@code GET /clinic/room/my-seat})共用。
 *
 * <p>背景:前端原来把"按 IP åŒ¹é… â†’ ç»‘ IP â†’ å…¥åº§"拆成三个请求自己拼,
 * ä»»ä¸€æ­¥å¤±è´¥å°±åªèƒ½å›žè½åˆ°"手选工位",现场表现为"登录后没有自动入座"且无从判断原因。
 * çŽ°åœ¨æ”¶æ•›æˆä¸€ä¸ªæŽ¥å£ï¼Œå¹¶æŠŠ**识别到的 IP** ä¸Ž**未入座原因**一起返回,便于排查。
 *
 * @author å¿ƒç”µæŽ’队装机系统
 */
@Data
public class RoomSeatRespVO {
    @Schema(description = "是否已入座(true æ—¶ä¸‹é¢çš„工位信息有效)", example = "true")
    private Boolean seated = false;
    /**
     * æœªå…¥åº§åŽŸå› ï¼ˆseated=true æ—¶ä¸ºç©ºï¼‰ï¼š
     * <ul>
     *   <li>{@code ip_invalid} â€”— æœ¬æœº IP ä¸å¯ç”¨ï¼ˆå›žçޝ / IPv6 / æœªæŒ‡å®š / éžæ³•),不能作为工位标识;</li>
     *   <li>{@code nomatch}    â€”— è¯¥ IP æ²¡æœ‰åŒ¹é…åˆ°ä»»ä½•工位(工位没录 IP,或工位已关闭);</li>
     *   <li>{@code multi}      â€”— è¯¥ IP å¯¹åº”**多个空闲工位**,无法确定坐哪一个,需人工点选;</li>
     *   <li>{@code occupied}   â€”— åŒ¹é…åˆ°çš„工位已被**他人**占用(不挤掉他,转手选/旁观);</li>
     *   <li>{@code error}      â€”— å…¥åº§å†™åº“未生效(并发被别人抢先、状态不允许等)。</li>
     * </ul>
     */
    @Schema(description = "未入座原因:ip_invalid/nomatch/multi/occupied/error", example = "nomatch")
    private String reason;
    @Schema(description = "后端实际识别到的客户端IP(排查代理未透传 X-Forwarded-For ç”¨ï¼‰", example = "32.88.65.208")
    private String clientIp;
    @Schema(description = "被占用时的占用人姓名", example = "张三")
    private String ownerName;
    /* ==================== å·¥ä½ä¿¡æ¯ï¼ˆseated=true æ—¶æœ‰æ•ˆï¼‰ ==================== */
    @Schema(description = "工位主键", example = "1")
    private Integer id;
    @Schema(description = "诊室编号", example = "1")
    private Long roomId;
    @Schema(description = "诊室名称", example = "1号门诊")
    private String roomName;
    @Schema(description = "工位号", example = "A1")
    private String bedNo;
    @Schema(description = "工位绑定的IP", example = "32.88.65.208")
    private String ip;
    @Schema(description = "工位状态:0已关闭 10已开通 20有医生 30暂停", example = "20")
    private Integer status;
    @Schema(description = "当前主人(医生)编号", example = "1")
    private Long docId;
    @Schema(description = "当前主人(医生)姓名", example = "张三")
    private String docName;
    @Schema(description = "该工位开展的检查项目")
    private Integer[] checkTypes;
    @Schema(description = "作业类型:0检查 1领用 2装机", example = "0")
    private Integer opType;
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/room/vo/RoomSelectBedRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,84 @@
package cn.lihu.jh.module.ecg.controller.admin.room.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
/**
 * ã€Œé€‰æ‹©å·¥ä½æ¨¡å¼ã€- æŒ‰æœ¬æœºIP匹配工位的返回结果。
 *
 * <p>用于医生端登录后自动匹配工位:
 * <ul>
 *   <li>{@code clientIp}:后端实际识别到的来访IP(排查"代理未透传 X-Forwarded-For"时看这个)</li>
 *   <li>{@code ipValid}:该IP是否可用于绑定(排除空、0.0.0.0、127.0.0.1、非法格式)</li>
 *   <li>{@code matched}:是否有可直接自动入座的工位</li>
 *   <li>{@code beds}:该IP下匹配到的候选工位,已按「本人已占用 â†’ ç©ºé—² â†’ è¢«ä»–人占用」排好序</li>
 * </ul>
 */
@Data
@Schema(description = "管理后台 - æŒ‰IP匹配工位 Response VO")
public class RoomSelectBedRespVO {
    @Schema(description = "后端识别到的来访IP")
    private String clientIp;
    @Schema(description = "该IP是否可用于绑定(空/0.0.0.0/127.0.0.1/::1/非法格式为 false)")
    private Boolean ipValid;
    @Schema(description = "该IP是否是本机回环地址(127.x / ::1),用于给出「本机访问」的准确提示")
    private Boolean ipLoopback;
    @Schema(description = "该IP是否是 IPv6 åœ°å€ï¼ˆæœ¬ç³»ç»Ÿå·¥ä½ç»‘定仅采用 IPv4,IPv6 ä¸å‚与匹配与绑定)")
    private Boolean ipv6;
    @Schema(description = "该IP是否已经绑定了工位")
    private Boolean ipBound;
    @Schema(description = "是否有可直接入座的工位(本人已占用 æˆ– ç©ºé—²ï¼‰")
    private Boolean matched;
    @Schema(description = "候选工位列表(按 æœ¬äººå·²å ç”¨ â†’ ç©ºé—² â†’ è¢«ä»–人占用 æŽ’序)")
    private List<RoomSelectBedItem> beds;
    @Data
    @Schema(description = "候选工位")
    public static class RoomSelectBedItem {
        @Schema(description = "工位主键 clinic_room.id")
        private Integer id;
        @Schema(description = "诊室编号")
        private Long roomId;
        @Schema(description = "诊室名称")
        private String roomName;
        @Schema(description = "诊疗床/工位编号")
        private String bedNo;
        @Schema(description = "该工位绑定的IP")
        private String ip;
        @Schema(description = "当前占用的医生ID,空表示空闲")
        private Long docId;
        @Schema(description = "当前占用的医生姓名")
        private String docName;
        @Schema(description = "操作类型 0检查 1领用 2装机")
        private Integer opType;
        @Schema(description = "工位接诊的检查类型")
        private Integer[] checkTypes;
        @Schema(description = "开诊状态")
        private Integer status;
        @Schema(description = "是否已被占用(docId éžç©ºï¼‰")
        private Boolean occupied;
        @Schema(description = "是否被「当前登录人」占用(刷新/重进时可直接恢复)")
        private Boolean mine;
    }
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/dataobject/jobrecord/JobRecordDO.java
@@ -139,4 +139,17 @@
    /** å¤šè€è¯ï¼š0无 1多耐 2多重耐药 */
    @TableField(exist = false)
    private String tolerance;
    /**
     * ã€æ’¤é”€æ ‡è¯†ï½œéžåº“字段】该条完成记录对应的患者**当日队列状态是否已不是"已就诊(40)"**,
     * å³"检查完成被撤销过、还没重新完成"。
     *
     * <p>计算方式:`JobRecordServiceImpl#getJobRecordPage` é‡ŒæŒ‰ (patId, checkType) å…³è”当日
     * `queue.status`;状态 â‰  40 â†’ true。撤销后自动变 true,患者再次完成(回 40)自动变 false,
     * ä¸éœ€è¦å†™åº“、也不会漏清标记。
     *
     * <p>必须标 {@code @TableField(exist = false)}:本 DO ä¼šç”¨äºŽ insert/updateById。
     */
    @TableField(exist = false)
    private Boolean undone;
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/dataobject/queue/QueueDO.java
@@ -73,7 +73,11 @@
     */
    private Integer bookCheckType;
    /**
     * é¢„约序号
     * é¢„约序号。
     *
     * <p><b>【已停用】</b>排队号统一为 {@link #seqNum}(签到时一次性分配、之后不变),
     * æœ¬å­—段不再写入、不再显示,仅为兼容历史数据与便于回滚而保留列。
     * æ–°ä»£ç è¯·å‹¿è¯»å–或赋值。
     */
    private Integer bookSeqNum;
    /**
@@ -81,7 +85,12 @@
     */
    private Integer isVip;
    /**
     * æŽ’队序号
     * æŽ’队序号(排队号)。
     *
     * <p>【号统一(本需求)】<b>签到时</b>按 {@code check_type.seq_rule} è§„则分配,
     * ä¹‹åŽä»»ä½•环节都不再改动(分流/叫号/过号/召回/加急/装机只改状态与诊室工位)。
     * <p>号按**检查项目各自编号**,跨检查项目允许重复 â†’ æ˜¾ç¤ºä¸€å¾‹ä¸º
     * {@code check_type.seq_prefix + seqNum}。
     */
    private Integer seqNum;
    /**
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/dataobject/queuesequence/SeqCounterDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,40 @@
package cn.lihu.jh.module.ecg.dal.dataobject.queuesequence;
import lombok.Data;
/**
 * ç­¾åˆ°åºå·è®¡æ•°å™¨ï¼ˆå¯¹åº” {@code seq_counter} è¡¨ï¼‰ã€‚
 *
 * <p>仅用于「排队序号规则 = ç­¾åˆ°åºå·ã€æ—¶è¯»å–两个计数器:
 * <ul>
 *   <li>{@code normalNext} â€”— ä¸‹ä¸€ä¸ª**普通号**(1..9、11..19…,跳过 10 çš„倍数);</li>
 *   <li>{@code reservedNext} â€”— ä¸‹ä¸€ä¸ª**预留号**(10、20、30…,留给加急患者)。</li>
 * </ul>
 *
 * <p>之所以分成两个计数器:单个计数器在"跳过预留号"时必然与后续号撞车
 * ï¼ˆcandidate=10 è·³è¿‡å¾— 11,下一个 candidate=11 ä¹Ÿå¾— 11)。详见
 * {@code QueueSequenceMapper} ä¸­çš„踩坑记录。
 *
 * <p>该表按 {@code (check_type, seq_date)} å”¯ä¸€ï¼Œæ•…**每天自动重置**
 * ï¼ˆç¬¬ä¸€æ¬¡å–号时以 {@code normal_next=1, reserved_next=10} æ’入)。
 *
 * @author å¿ƒç”µæŽ’队装机系统
 */
@Data
public class SeqCounterDO {
    private Integer id;
    /** æ£€æŸ¥ç±»åž‹å€¼ï¼ˆcheck_type.value) */
    private Integer checkType;
    /** æ—¥æœŸï¼ˆæŒ‰å¤©é‡ç½®ï¼‰ */
    private java.time.LocalDate seqDate;
    /** ä¸‹ä¸€ä¸ªæ™®é€šå· */
    private Integer normalNext;
    /** ä¸‹ä¸€ä¸ªé¢„留号(加急用) */
    private Integer reservedNext;
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/mysql/appointment/AppointmentMapper.java
@@ -25,7 +25,9 @@
    default PageResult<AppointmentDO> selectPage(AppointmentPageReqVO reqVO) {
        LambdaQueryWrapperX<AppointmentDO> wrapper = (LambdaQueryWrapperX<AppointmentDO>) new LambdaQueryWrapperX<AppointmentDO>().eqIfPresent(AppointmentDO::getPatId, reqVO.getPatId()).likeIfPresent(AppointmentDO::getPatName, reqVO.getPatName()).eqIfPresent(AppointmentDO::getPatGender, reqVO.getPatGender()).eqIfPresent(AppointmentDO::getPatBirthday, reqVO.getPatBirthday()).eqIfPresent(AppointmentDO::getPatMobile, reqVO.getPatMobile()).eqIfPresent(AppointmentDO::getPatPhone, reqVO.getPatPhone()).eqIfPresent(AppointmentDO::getPatIdentityId, reqVO.getPatIdentityId()).eqIfPresent(AppointmentDO::getPatAddr, reqVO.getPatAddr()).eqIfPresent(AppointmentDO::getPatDeptCode, reqVO.getPatDeptCode()).eqIfPresent(AppointmentDO::getPatDeptDesc, reqVO.getPatDeptDesc()).eqIfPresent(AppointmentDO::getPatWardCode, reqVO.getPatWardCode()).eqIfPresent(AppointmentDO::getPatWardDesc, reqVO.getPatWardDesc()).eqIfPresent(AppointmentDO::getBookDate, reqVO.getBookDate()).eqIfPresent(AppointmentDO::getBookTimeslot, reqVO.getBookTimeslot()).eqIfPresent(AppointmentDO::getBookCheckType, reqVO.getBookCheckType()).eqIfPresent(AppointmentDO::getBookSrc, reqVO.getBookSrc()).eqIfPresent(AppointmentDO::getPaid, reqVO.getPaid()).eqIfPresent(AppointmentDO::getApplyNo, reqVO.getApplyNo()).eqIfPresent(AppointmentDO::getEpisodeId, reqVO.getEpisodeId()).inIfPresent(AppointmentDO::getStatus, reqVO.getStatus())
                // âœ… æ–°å¢žï¼šcreateTime åŒºé—´æŸ¥è¯¢
                .betweenIfPresent(AppointmentDO::getCreateTime, reqVO.getCreateTimeStart(), reqVO.getCreateTimeEnd()).betweenIfPresent(AppointmentDO::getRegisterDate, reqVO.getRegisterDateStart(), reqVO.getRegisterDateEnd());
                .betweenIfPresent(AppointmentDO::getCreateTime, reqVO.getCreateTimeStart(), reqVO.getCreateTimeEnd()).betweenIfPresent(AppointmentDO::getRegisterDate, reqVO.getRegisterDateStart(), reqVO.getRegisterDateEnd())
                // ã€æœ¬éœ€æ±‚】预约日期**时间段**查询(预约记录页的「预约日期」日期范围)
                .betweenIfPresent(AppointmentDO::getBookDate, reqVO.getBookDateStart(), reqVO.getBookDateEnd());
        // åŠ¨æ€æŽ’åº
        if (reqVO.getOrderCreateTime() != null) {
            wrapper.orderByDesc(AppointmentDO::getCreateTime);
@@ -36,21 +38,75 @@
    }
    @Select("select * from lihu.appointment where pat_id = #{patId} and to_days(book_date) = to_days(Now())")
    @Select("select * from lihu.appointment where deleted = 0 and pat_id = #{patId} and to_days(book_date) = to_days(Now())")
    List<AppointmentDO> getCurrentPatId(String patId);
    @Select("select * from lihu.appointment where (apply_no = #{code} or episode_id= #{code} or hospital_no =#{code} or pat_identity_id=#{code}) and book_date >=  DATE_ADD(CURDATE(), INTERVAL -5 DAY)    AND book_date <=  DATE_ADD(CURDATE(), INTERVAL 15 DAY)")
    /**
     * ã€æœ¬éœ€æ±‚】按「患者编号 / ç”³è¯·å•号 / å°±è¯Šæµæ°´å· / ä½é™¢å· / èº«ä»½è¯å·ã€åŒ¹é…é¢„约(±5/+15 å¤©çª—口)。
     *
     * <h3>为什么加 {@code pat_id} ä¸Ž {@code deleted = 0}</h3>
     * <ul>
     *   <li><b>pat_id</b>:手动新建的预约({@code /ecg/appointment/create})只有 pat_id,
     *       {@code apply_no} / {@code episode_id} / {@code hospital_no} / {@code pat_identity_id} å…¨ä¸ºç©ºï¼Œ
     *       ç­¾åˆ°é¡µæŒ‰æ‚£è€…编号查询时永远查不到 â†’ æç¤ºã€Œè¯·å…ˆé¢„约」→ æ— æ³•签到;</li>
     *   <li><b>deleted = 0</b>:原 SQL æ˜¯è£¸æŸ¥è¯¢ï¼Œé€»è¾‘删除的旧行也会被捞出来,
     *       å†ç» {@code queryAndCreateAppointmentByPatId} çš„"不存在则插入"逻辑可能把已删的单再插回来。</li>
     * </ul>
     */
    @Select("select * from lihu.appointment where deleted = 0 "
            + " and (apply_no = #{code} or episode_id = #{code} or hospital_no = #{code} "
            + "      or pat_identity_id = #{code} or pat_id = #{code}) "
            + " and book_date >= DATE_ADD(CURDATE(), INTERVAL -5 DAY) "
            + " and book_date <= DATE_ADD(CURDATE(), INTERVAL 15 DAY) "
            + " order by book_date, id")
    List<AppointmentDO> getCurrentCode(String code);
    @Select("select * from lihu.appointment where pat_id = #{patId} and book_check_type = #{checkType} and to_days(book_date) = to_days(Now()) limit 1")
    AppointmentDO getByPatAndCheckTypeAndBookDate(@Param("patId") String patId, @Param("checkType") Integer checkType);
    /**
     * ã€æœ¬éœ€æ±‚:紧急患者不排队直接检查】按「申请单号 / å°±è¯Šå· / æ‚£è€…编号」查近一个月的预约记录。
     *
     * <p>窗口 = [今天-30 å¤©, ä»Šå¤©+30 å¤©];结果**按预约时间倒序**(最近的排前面),便于医生选择。
     * æ£€æŸ¥é¡¹ç›®è¿‡æ»¤ï¼ˆæœ¬çª—口开展的检查项目)由 {@code checkTypeList} ä¼ å…¥ï¼Œç©º/未传表示不限制。
     */
    @Select("<script>" +
            "select * from lihu.appointment where deleted = 0 " +
            " and (apply_no = #{code} or episode_id = #{code} or pat_id = #{code}) " +
            " and book_date &gt;= DATE_ADD(CURDATE(), INTERVAL -30 DAY) " +
            " and book_date &lt;= DATE_ADD(CURDATE(), INTERVAL 30 DAY) " +
            " <if test='checkTypeList != null and checkTypeList.size() > 0'> " +
            "   and book_check_type in ( <foreach collection='checkTypeList' separator=',' item='ct'>#{ct}</foreach> ) " +
            " </if> " +
            " order by book_date desc, book_timeslot desc, id desc " +
            "</script>")
    List<AppointmentDO> getEmergencyCandidates(@Param("code") String code,
                                               @Param("checkTypeList") List<Integer> checkTypeList);
    Integer updateStatusByApplyNo(@Param("applyNo") String applyNo, @Param("status") String status, @Param("registerDate") Date registerDate);
    @Select("select * from lihu.appointment where episode_id = #{episodeId} and apply_no = #{applyNo} limit 1")
    /**
     * åªæ›´æ–°çŠ¶æ€ï¼ˆä¸åŠ¨å…¶å®ƒå­—æ®µï¼‰ã€‚
     *
     * <p>背景:状态流原来是把 {@code getByApplyNo} æŸ¥å‡ºæ¥çš„æ•´è¡Œå¯¹è±¡å† {@code updateById} å†™å›žï¼Œ
     * å±žäºŽã€Œè¯»-改-写」。并发/交错时会把别的字段(如 book_date / book_check_type)按旧快照覆盖掉,
     * å®žæµ‹ 2026-10-05 07:58 å°±å‡ºçŽ°è¿‡ä¸€ç¬”é¢„çº¦çš„ check_type è¢«çŠ¶æ€æµè¦†ç›–ã€‚
     * çŠ¶æ€æµåªè¯¥æ”¹çŠ¶æ€ï¼Œå› æ­¤å•ç‹¬æä¾›è¿™ä¸ªåªæ”¹ status çš„语句。</p>
     */
    @Update("update lihu.appointment set status = #{status}, update_time = sysdate() where apply_no = #{applyNo} and deleted = 0")
    Integer updateStatusOnlyByApplyNo(@Param("applyNo") String applyNo, @Param("status") String status);
    /** ã€æœ¬æ¬¡ä¿®å¤ã€‘è¡¥ {@code deleted = 0}:逻辑删除的行不应再被当作"已存在"而跳过插入。 */
    @Select("select * from lihu.appointment where deleted = 0 and episode_id = #{episodeId} and apply_no = #{applyNo} limit 1")
    AppointmentDO getByEpisodeIdAndApplyNo(@Param("episodeId") String episodeId, @Param("applyNo") String applyNo);
    @Select("select * from lihu.appointment where apply_no = #{applyNo} limit 1")
    /**
     * æŒ‰ç”³è¯·å•号取未删除的记录。
     *
     * <p>注意:这里必须带 {@code deleted = 0}。原实现是裸 select,会把**逻辑删除**的旧行也捞出来,
     * é…åˆ {@code LIMIT 1} æ—¶è‹¥æ—§è¡Œ id æ›´å°å°±ä¼šå–到已删除的那条 â€”— è¿™æ˜¯"删了又被更新回来"的隐患。
     * å¦å¤–本方法配合唯一索引 {@code uk_appointment_apply_no_active}(只约束未删除行)使用。</p>
     */
    @Select("select * from lihu.appointment where apply_no = #{applyNo} and deleted = 0 limit 1")
    AppointmentDO getByApplyNo(@Param("applyNo") String applyNo);
    /**
@@ -65,5 +121,19 @@
            " </foreach> )" +
            "</script>")
    List<AppointmentDO> getByApplyNoList(@Param("applyNoList") List<String> applyNoList);
    /**
     * å–该患者**最近一条**未删除记录,用于容错新增时继承患者固有属性
     * ï¼ˆç”Ÿæ—¥/电话/身份证/地址等 â€”— S050401 æŠ¥æ–‡åªå¸¦å§“名,这些字段报文里没有)。
     *
     * <p>按 {@code create_time desc, id desc} æŽ’序,保证结果稳定(同一秒内插入的多条也能定序)。
     * åªç»§æ‰¿**患者固有属性**,不继承开单医生/开单时间等流水属性(那属于推断,可能张冠李戴)。</p>
     *
     * @param patId æ‚£è€…编号({@code appointment.pat_id})
     * @return æœ€è¿‘一条记录;无记录时返回 {@code null}
     */
    @Select("select * from lihu.appointment where pat_id = #{patId} and deleted = 0 "
            + "order by create_time desc, id desc limit 1")
    AppointmentDO getLatestByPatId(@Param("patId") String patId);
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/mysql/call/CallMapper.java
@@ -37,6 +37,20 @@
    @Select("select * from lihu.call_patient where pat_id = #{patId} order by id desc limit 1")
    CallDO getLatestPatientCall(@Param("patId") String patId);
    /**
     * å„患者**最近一次叫号记录**的 id(key=pat_id,value=最新 id)。
     *
     * <p>【重叫(本需求)】大屏/诊间屏靠"数据变化"触发播报,重叫本身不会改变队列数据,
     * å› æ­¤ä¸‹å‘这个 callId:医生每点一次重叫都会**新增一条叫号记录**(见 {@code CallService#callAgain}),
     * callId éšä¹‹å˜å¤§ â†’ å±å¹•据此重新播报。
     *
     * <p>用 create_time é™å®šè¿‘ 2 å¤©ï¼ˆcall_patient ä¼šè¢«æ¯æ—¥æ¸…理,见 clearCall),
     * é¿å…å…¨è¡¨ group by;不依赖 book_date(可能为空)。
     */
    @Select("select pat_id as patId, max(id) as callId from lihu.call_patient " +
            "where create_time >= date_sub(now(), interval 2 day) group by pat_id")
    List<Map<String, Object>> selectLatestCallIds();
    // call_type 0  å¤§å±
    @Select("select * from lihu.call_patient where called = 0 and call_type = 0 order by id limit 1")
    CallDO getNextCall( );
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/mysql/devrent/DevRentMapper.java
@@ -63,6 +63,28 @@
    @Select("select * from lihu.dev_rent where state=#{state} and pat_id=#{patId} and check_type=#{checkType} and to_days(create_time)=to_days(now())")
    List<DevRentDO> selectByPatAndChecktypeAndState(@Param("patId") String patId, @Param("checkType") Integer checkType, @Param("state") Integer state);
    /**
     * è¯¥æ‚£è€… + æ£€æŸ¥é¡¹ç›®ã€**今日**、状态在给定集合里的**最近一条**检查单。
     *
     * <p>两处用途:
     * <ol>
     *   <li>【撤销回滚】撤销「检查完成」时,把 60(常规检查完成) çš„单找出来回滚成 0(待检查);</li>
     *   <li>【完成兜底】再次完成时若找不到「待检查(0)」的单,用最近一条 60/50 çš„单兜底复用,
     *       é¿å…ç›´æŽ¥æŠ¥ã€Œè¯·å…ˆé¢„约确认」。</li>
     * </ol>
     */
    @Select("<script>" +
            "select * from lihu.dev_rent where pat_id=#{patId} and check_type=#{checkType} " +
            "  and to_days(create_time)=to_days(now()) and state in ( " +
            "   <foreach collection='stateList' separator=',' item='state'>#{state}</foreach> ) " +
            " order by id desc limit 1" +
            "</script>")
    @Results({
            @Result(property = "patDetails", column = "pat_details", typeHandler=com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
    })
    DevRentDO selectLatestByPatCheckTypeStates(@Param("patId") String patId, @Param("checkType") Integer checkType,
                                               @Param("stateList") List<Integer> stateList);
    @Select("<script>" +
            "SELECT paid FROM lihu.dev_rent " +
            "where id in (" +
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/mysql/queue/QueueMapper.java
@@ -10,6 +10,7 @@
import cn.lihu.jh.module.ecg.controller.admin.queue.vo.*;
import java.util.List;
import java.util.Map;
/**
 * æŽ’队 Mapper
@@ -36,21 +37,20 @@
                .orderByDesc(QueueDO::getId));
    }
    @Select("SELECT max(seq_num) FROM lihu.queue")
    Integer getMaxSeqNum();
    @Select("SELECT * FROM lihu.queue where seq_num = #{seqNum}")
    @Results({
            @Result(property = "patDetails", column = "pat_details", typeHandler=com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
    })
    QueueDO getQueueItemBySeqNum(@Param("seqNum")Integer seqNum);
    @Select("SELECT * FROM lihu.queue where pat_id = #{patId} and book_check_type = #{bookCheckType}")
    @Results({
            @Result(property = "patDetails", column = "pat_details", typeHandler=com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
    })
    QueueDO getQueueByPatId(@Param("patId")String patId, @Param("bookCheckType")Integer bookCheckType);
    /**
     * è¯Šé—´å±ï¼ˆ{@code ecg_call/small.html})取数:本诊室的检查队列。
     *
     * <p>排序规则(号统一后的最终规则):<b>就诊中(30) ç½®é¡¶ â†’ åŠ æ€¥(jump_flag=1) â†’ å…¶ä½™æŒ‰æŽ’队号 seq_num å‡åº</b>;
     * å·ä¸ºç©ºï¼ˆæ”¹é€ å½“天尚未迁移的存量行)的排在最后。
     * <p>过号(3/5) <b>不再单独提前一组</b>:它与「等候」混排、留在自己的号位上,
     * ç”±å±ä¸Šç”¨çº¢è‰² +「(过)」小字区分(需求:诊间屏「等候」与「过号」合并为一个「等候」面板)。
     */
    @Select("""
            <script>
                SELECT * FROM lihu.queue
@@ -60,7 +60,8 @@
                 </foreach> )
                order by case
                           when status = 30 then 0
                           when status in (3, 5) then 1
                           when jump_flag = 1 then 1
                           when seq_num is null then 3
                           else 2
                         end,
                         seq_num, book_timeslot
@@ -73,13 +74,18 @@
    /**
     * å¤§å±å–数。
     * <p>
     * æŽ’序规则(需求8):**就诊中(30) ç½®é¡¶ â†’ è¿‡å·(3/5) ä¸Žç­‰å€™(10/15/20/12) æŒ‰æŽ’队序号 seq_num å‡åºæ··æŽ’**。
     * åŽŸå…ˆç”¨ {@code order by status desc},会把过号(3/5)排到每一列最底部,
     * åœ¨ {@code overflow-y:auto} çš„列里滚不到,等同于"过号不显示"。
     * <p>
     * åŒæ—¶è¡¥ä¸Š {@code deleted = 0}:本方法是自定义 {@code @Select},
     * ä¼šç»•过 MyBatis-Plus çš„ {@code @TableLogic} é€»è¾‘删除过滤。
     *
     * <p>排序规则(号统一后的最终规则):<b>就诊中(30) ç½®é¡¶ â†’ åŠ æ€¥(jump_flag=1) â†’ å…¶ä½™æŒ‰æŽ’队号 seq_num å‡åº</b>;
     * å·ä¸ºç©ºï¼ˆæ”¹é€ å½“天尚未迁移的存量行)的排在最后。
     * <ul>
     *   <li>过号(3/5) <b>不再单独提前一组</b>:它留在自己的号位上,屏上用红色 + è¿‡å·æ ‡è®°åŒºåˆ†ï¼Œ
     *       ä¸Žè¯Šé—´å±ï¼ˆ{@code ecg_call/small.html})改造后的口径一致。
     *       ï¼ˆåŽŸå®žçŽ°æ˜¯ {@code status desc},会把过号压到最底部、等同于"过号不显示"。)</li>
     *   <li>加急必须参与排序:排队号改为签到时一次性分配、之后不再改动,
     *       åŠ æ€¥æ— æ³•å†é "分流时改号/插队重编号"体现。</li>
     *   <li>沿用 {@code deleted = 0}:本方法是自定义 {@code @Select},
     *       ä¼šç»•过 MyBatis-Plus çš„ {@code @TableLogic} é€»è¾‘删除过滤。</li>
     * </ul>
     */
    @Select("""
            <script>
@@ -90,7 +96,8 @@
                 </foreach> )
                order by case
                           when status = 30 then 0
                           when status in (3, 5) then 1
                           when jump_flag = 1 then 1
                           when seq_num is null then 3
                           else 2
                         end,
                         seq_num, book_timeslot
@@ -226,7 +233,10 @@
                  <foreach collection='statusList' separator=',' item='status'>
                    #{status} 
                  </foreach> ) 
                order by status desc, seq_num, book_timeslot
                order by case when status = 30 then 0 else 1 end,
                         jump_flag desc,
                         case when seq_num is null then 1 else 0 end,
                         seq_num, book_timeslot
            </script>
            """)
    @Results({
@@ -234,9 +244,20 @@
    })
    List<QueueDO> getBedQueueByStatus(@Param("roomId")Long roomId, @Param("bedNo")String bedNo, @Param("statusList")List<Integer> statusList);
    /**
     * å«å·å–人:本工位「候诊中(20)」里**应优先叫**的那一位。
     *
     * <p>排序(号统一后的最终规则):<b>加急(jump_flag=1) â†’ æŽ’队号 seq_num å‡åº</b>,号为空(存量行)排最后。
     * <p>排队号在签到时一次性分配、之后不再改动,所以"下一位叫谁"完全由本方法决定;
     * åŠ æ€¥å¿…é¡»åœ¨è¿™é‡Œå‚ä¸ŽæŽ’åºï¼Œå¦åˆ™åŠ æ€¥æ‚£è€…æ°¸è¿œä¸ä¼šè¢«ä¼˜å…ˆå«åˆ°ã€‚
     */
    @Select("""
            SELECT * from lihu.queue where room_id = #{roomId} and bed_no = #{bedNo}
               and status = #{curStatus} order by seq_num, book_timeslot limit 1
               and status = #{curStatus} and deleted = 0
             order by jump_flag desc,
                      case when seq_num is null then 1 else 0 end,
                      seq_num, book_timeslot
             limit 1
            """)
    QueueDO getBedFirstQueueItemByStatus(@Param("roomId")Long roomId, @Param("bedNo")String bedNo, @Param("curStatus")Integer curStatus);
@@ -289,22 +310,30 @@
    // æŸä¸ªè¯Šå®¤èŒƒå›´å†… [已领用] [已召回-安装] äººå‘˜ æŠ¢å , ä¼šæ›´æ–° [工位号]
    // æ²¡æœ‰é™å®šæ£€æŸ¥ç±»åž‹ï¼Œä¸è¦ä½¿ç”¨æ—¶é—´æ§½
    // æŽ’序:加急优先 â†’ æŽ’队号升序(号为空排最后)。装机叫号取人也走这里。
    @Select("<script> " +
            "select * from lihu.queue where room_id = #{roomId} and status in ( " +
            "select * from lihu.queue where deleted = 0 and room_id = #{roomId} and status in ( " +
            "       <foreach collection='curStatusList' separator=',' item='status'>" +
            "           #{status} " +
            "       </foreach> )" +
            "       order by seq_num, book_timeslot limit 1 " +
            "       order by jump_flag desc, case when seq_num is null then 1 else 0 end, seq_num, book_timeslot limit 1 " +
            "</script>" )
    QueueDO getRoomFirstQueueItemByStatus(@Param("roomId")Long roomId, @Param("curStatusList")List<Integer> curStatusList);
    /**
     * æŒ‰é¢„约单 id æ‰¹é‡å– queue è¡Œã€‚
     *
     * <p>用途:预约签到前,把「该预约是否已排队、拿到的排队号是多少」回填给前端。
     * å·ç»Ÿä¸€åŽå–的是 {@code seq_num}(见 AppointmentServiceImpl#queryAndCreateAppointmentByPatId)。
     * <p>原方法名为 {@code selectBookSeqNumByAppointIdList},因号已统一为 seq_num è€Œæ”¹åã€‚
     */
    @Select("<script> " +
            "select * from lihu.queue where appoint_id in ( " +
            "select * from lihu.queue where deleted = 0 and appoint_id in ( " +
            "       <foreach collection='appointIdList' separator=',' item='appointId'>" +
            "           #{appointId} " +
            "       </foreach> )" +
            "</script>" )
    List<QueueDO> selectBookSeqNumByAppointIdList(@Param("appointIdList") List<Long> appointIdList);
    List<QueueDO> selectQueueByAppointIdList(@Param("appointIdList") List<Long> appointIdList);
/*
    @Select("select * from lihu.queue where room_id = #{roomId} and bed_no = #{bedNo} " +
@@ -317,9 +346,21 @@
    @Update("update lihu.queue set status = #{newStatus} where pat_id = #{patId} and book_check_type = #{checkType} and status = #{curStatus}; " )
    Integer updatePatientQueueStatus(@Param("patId")String patId, @Param("checkType")Integer checkType, @Param("curStatus")Integer curStatus, @Param("newStatus")Integer newStatus);
    @Update("update lihu.queue set status = #{newStatus} where status = #{curStatus} and seq_num = \n" +
            "(select a.min_seq_num from \n" +
            " (select min(seq_num) as min_seq_num from lihu.queue where room_id = #{roomId} and bed_no = #{bedNo} and status = #{curStatus}) a )")
    /**
     * æŠŠæœ¬å·¥ä½ã€æŒ‡å®šçŠ¶æ€ä¸­ã€ŒæŽ’é˜Ÿå·æœ€å°ã€çš„ä¸€ä½æ”¹æˆæ–°çŠ¶æ€ï¼ˆè¿‡å·ç”¨ï¼‰ã€‚
     *
     * <p><b>【号统一改造必修】必须按行 id æ›´æ–°ã€‚</b>
     * æ—§å®žçŽ°å¤–å±‚æ¡ä»¶æ˜¯ {@code where status = ? and seq_num = (select min(seq_num) ...)}——
     * å¤–层<b>没有 room_id / bed_no æ¡ä»¶</b>,完全依赖"seq_num å…¨è¡¨å”¯ä¸€"才不会误伤。
     * æœ¬éœ€æ±‚把排队号改为「按检查类型各自编号」,号会跨检查类型重复,
     * æ—§å†™æ³•会一次性把<b>全院所有同号同状态的患者</b>一起改掉
     * ï¼ˆä¾‹ï¼šå¸¸è§„心电 5 å·è¿‡å·ï¼ŒåŠ¨æ€å¿ƒç”µçš„ 5 å·ä¹Ÿè¢«è¿‡å·ï¼‰ã€‚故改为先取 id、再按 id æ›´æ–°ã€‚
     */
    @Update("update lihu.queue set status = #{newStatus} where id = " +
            "  (select a.id from " +
            "    (select id from lihu.queue where room_id = #{roomId} and bed_no = #{bedNo} " +
            "       and status = #{curStatus} and deleted = 0 " +
            "       order by case when seq_num is null then 1 else 0 end, seq_num, book_timeslot limit 1) a)")
    Integer updateBedQueueStatus(@Param("roomId")Long roomId, @Param("bedNo")String bedNo, @Param("curStatus")Integer curStatus, @Param("newStatus")Integer newStatus);
    // å¼ƒç”¨åŽŸå› ï¼šéœ€è¦å…ˆèŽ·å–ï¼Œåˆ¤æ–­é€»è¾‘å¤„ç†åŽï¼Œå†æ›´æ–°
@@ -340,40 +381,84 @@
    )
    Integer preemptToBeInstalledPatient(@Param("roomId")Long roomId, @Param("bedNo")String bedNo, @Param("curStatusList")List<Integer> curStatusList, @Param("newStatus")Integer newStatus);
    // æ•´ä¸ªç³»ç»ŸèŒƒå›´å†… [排队中]人员 [被工位医生]抢占
    @Update("update lihu.queue set status = #{newStatus}, room_id = #{roomId}, room_name = #{roomName}, \n" +
            " bed_no = #{bedNo}, seq_num = #{seqNum} where id = \n" +
            "  (select a.id from \n" +
            "    (select id from lihu.queue where status = #{curStatus} and book_check_type= #{checkType} order by jump_flag desc, book_timeslot, create_time limit 1) a)")
    Integer preemptWaitingPatient(@Param("roomId")Long roomId, @Param("roomName")String roomName, @Param("bedNo")String bedNo,
                                  @Param("seqNum")Integer seqNum, @Param("curStatus")Integer curStatus, @Param("newStatus")Integer newStatus,
                                  @Param("checkType")Integer checkType);
    // æ•´ä¸ªç³»ç»ŸèŒƒå›´å†… [排队中]人员 [被工位医生]抢占 æ ¹æ® å¤šä¸ªæ£€æŸ¥é¡¹ç›®
    @Update("<script>" +
            "update lihu.queue set status = #{newStatus}, room_id = #{roomId}, room_name = #{roomName}, \n" +
            " bed_no = #{bedNo}, seq_num = #{seqNum} where id = \n" +
            "  (select a.id from \n" +
            "    (select id from lihu.queue where status = #{curStatus} " +
            "       and book_check_type in ( " +
            "           <foreach collection='checkTypeArray' separator=',' item='_checkType'>" +
            "             #{_checkType} " +
            "           </foreach> )" +
            "       order by jump_flag desc, book_timeslot, create_time limit 1) a) " +
    /**
     * å–「本工位开展的检查项目」中下一位应分流的「排队中(10)」患者。
     *
     * <p>排序:<b>加急(jump_flag=1) â†’ é¢„约时段 â†’ å»ºå•æ—¶é—´</b>;号为空(存量行)排最后。
     * <p><b>这里不再分配排队号</b>:号已在签到时一次性写入 {@code seq_num},
     * åˆ†æµåªè´Ÿè´£æŠŠæ‚£è€…从「排队中」搬到某个工位的「候诊中」。
     * <p>取出后再用 {@link #preemptWaitingPatientById} åšæ¡ä»¶æ›´æ–°ï¼Œ
     * é  {@code and status = 10} ä¿è¯å¹¶å‘下只有一方能抢到该行。
     */
    @Select("<script>" +
            "select * from lihu.queue where deleted = 0 and status = #{curStatus} " +
            "   and book_check_type in ( " +
            "     <foreach collection='checkTypeArray' separator=',' item='_checkType'>" +
            "       #{_checkType} " +
            "     </foreach> )" +
            "   order by jump_flag desc, case when seq_num is null then 1 else 0 end, book_timeslot, create_time limit 1" +
            "</script>")
    Integer preemptWaitingPatientWithBedCheckTypes(@Param("roomId")Long roomId, @Param("roomName")String roomName, @Param("bedNo")String bedNo,
                                  @Param("seqNum")Integer seqNum, @Param("curStatus")Integer curStatus, @Param("newStatus")Integer newStatus,
                                  @Param("checkTypeArray")Integer[] checkTypeArray);
    @Results({
            @Result(property = "patDetails", column = "pat_details", typeHandler=com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
    })
    QueueDO getFirstWaitingByCheckTypes(@Param("curStatus")Integer curStatus, @Param("checkTypeArray")Integer[] checkTypeArray);
    @Update("update lihu.queue set status = #{newStatus}, seq_num = #{seqNum} " +
            " where id = " +
            "  (select a.id from \n" +
            "    (select id from lihu.queue where room_id = #{roomId} and bed_no = #{bedNo} and status = #{curStatus} " +
            "      order by seq_num, book_timeslot limit 1" +
            "    ) a" +
            "  )" )
    Integer queueRecalledPatient(@Param("roomId")Long roomId, @Param("roomName")String roomName, @Param("bedNo")String bedNo,
                                 @Param("seqNum")Integer seqNum, @Param("curStatus")Integer curStatus, @Param("newStatus")Integer newStatus);
    /**
     * ã€Œæœ¬è¯Šå®¤/本工位**排队中、还没分配工位**」的患者(工作台"本诊室排队中(未分配)"分区用)。
     *
     * <p>背景:工作台的候诊列表按 {@code room_id + bed_no} æŸ¥ï¼ŒåªåŒ…含**已经分流到该工位**的患者;
     * è€Œ"排队中(10)"的患者 {@code bed_no} æ˜¯ç©ºçš„(见 QueueServiceImpl é‡ç½®é€»è¾‘:room_id=0 / bed_no=' '),
     * å¤§å±ä¼šæ˜¾ç¤ºä»–们(大屏不按诊室/工位过滤),于是出现"大屏有、工作台列表没有"的困惑。
     * è¿™é‡ŒæŠŠ"本工位检查类型、尚未分配工位"的排队患者单独查出来,由医生**手工拉入**。
     *
     * <p>判定"未分配"用 {@code bed_no} ä¸ºç©ºï¼ˆå…¼å®¹ null / ç©ºä¸² / å•个空格),
     * ä¸ä¾èµ– {@code room_id = 0} è¿™ä¸€çº¦å®šï¼Œå®¹é”™æ›´å¥½ã€‚
     */
    @Select("""
            <script>
                SELECT * FROM lihu.queue
                where deleted = 0 and status = #{curStatus}
                  and (bed_no is null or trim(bed_no) = '')
                  and book_check_type in (
                    <foreach collection='checkTypeArray' separator=',' item='_checkType'>
                      #{_checkType}
                    </foreach> )
                order by jump_flag desc,
                         case when seq_num is null then 1 else 0 end,
                         book_timeslot, create_time
            </script>
            """)
    @Results({
            @Result(property = "patDetails", column = "pat_details", typeHandler=com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
    })
    List<QueueDO> selectUnassignedWaitingByCheckTypes(@Param("curStatus")Integer curStatus,
                                                      @Param("checkTypeArray")Integer[] checkTypeArray);
    /**
     * æŠŠã€ŒæŽ’队中」的某一行抢占到指定工位。
     *
     * <p><b>不改排队号</b>(号是签到时定的):只改状态、诊室、工位。
     * <p>{@code and status = #{curStatus}} æ˜¯å¹¶å‘保护 â€”— ä¸¤ä¸ªå·¥ä½åŒæ—¶æŠ¢åŒä¸€ä½æ‚£è€…时,
     * åªæœ‰ç¬¬ä¸€ä¸ª UPDATE ä¼šå‘½ä¸­ï¼ˆè¿”回 1),另一方返回 0,需重新取人。
     */
    @Update("update lihu.queue set status = #{newStatus}, room_id = #{roomId}, room_name = #{roomName}, bed_no = #{bedNo} " +
            " where id = #{id} and status = #{curStatus} and deleted = 0")
    Integer preemptWaitingPatientById(@Param("id")Integer id, @Param("roomId")Long roomId, @Param("roomName")String roomName,
                                      @Param("bedNo")String bedNo, @Param("curStatus")Integer curStatus, @Param("newStatus")Integer newStatus);
    /**
     * ã€æœ¬éœ€æ±‚·并发加固】按主键 + **当前状态**条件推进状态(叫号:候诊中(20) â†’ å°±è¯Šä¸­(30))。
     *
     * <p>为什么需要它:{@code nextPatient} åŽŸæ¥æ˜¯"先查最小号的候诊行 â†’ updateById",
     * ä¸¤ä¸ªå¹¶å‘请求(双击 / å¤šç«¯ï¼‰å¯èƒ½å„推进一条 READY,导致同一工位出现 **2 ä¸ªã€Œå°±è¯Šä¸­ã€**。
     * æœ‰äº† {@code and status = #{curStatus}}:只有第一个 UPDATE å‘½ä¸­ï¼ˆè¿”回 1),
     * å¦ä¸€æ–¹è¿”回 0,由调用方放弃本次叫号,等下一次再取。
     */
    @Update("update lihu.queue set status = #{newStatus}, update_time = NOW() "
            + " where id = #{id} and status = #{curStatus} and deleted = 0")
    Integer updateStatusByIdIfCurrent(@Param("id") Integer id,
                                      @Param("curStatus") Integer curStatus,
                                      @Param("newStatus") Integer newStatus);
    // å¸¸è§„检查 æˆ– é¢†ç”¨ çš„æ‚£è€…召回, åŽŸå·¥ä½å¤„ç†
    @Update("update lihu.queue set status = #{newStatus}, passed = 1 " +
@@ -397,6 +482,24 @@
    // ==================== éœ€æ±‚2:误点「叫号 / æ£€æŸ¥å®Œæˆã€çš„æ’¤é”€ ====================
    /**
     * æ‰¹é‡å–「这些患者今日的队列状态」(用于判断"已完成记录"是否被撤销过)。
     *
     * <p>【需求:撤销标识】撤销「检查完成」只是把队列行 40(已就诊) æ”¹å›ž 20(准备中),
     * ä¸ç¢° job_record。因此"这条完成记录是否已撤销"用**当日队列行的当前状态**判断即可:
     * çŠ¶æ€ â‰  40 â†’ å·²è¢«æ’¤é”€ï¼›å†æ¬¡å®Œæˆæ—¶ä¼šå˜å›ž 40 â†’ è‡ªåŠ¨æ¢å¤æˆ"已完成"(无需额外清标记)。
     *
     * <p>只取当日(撤销本身就只支持当日),一次查询解决整页记录。
     */
    @Select("<script>" +
            "select pat_id as patId, book_check_type as checkType, status " +
            "  from lihu.queue " +
            " where deleted = 0 and TO_DAYS(book_date) = TO_DAYS(NOW()) " +
            "   and pat_id in ( " +
            "     <foreach collection='patIdList' separator=',' item='_patId'>#{_patId}</foreach> ) " +
            "</script>")
    List<Map<String, Object>> selectTodayStatusByPatIds(@Param("patIdList")List<String> patIdList);
    /**
     * æ’¤é”€ã€Œæ£€æŸ¥å®Œæˆã€ï¼šæŠŠæœ¬å·¥ä½ä»Šæ—¥çš„**指定患者**从 å·²å°±è¯Š(40) é€€å›ž å€™è¯Šä¸­(20)。
     * <p>
     * å¿…须按 {@code pat_id + book_check_type + ä»Šæ—¥} ç²¾ç¡®å®šä½ï¼Œ**不能**只按
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/mysql/queuesequence/QueueSequenceMapper.java
@@ -58,9 +58,48 @@
    @Update("update lihu.queue_sequence set queue_vip_no = queue_vip_no + 1 where check_type=#{checkType} and time_slot=#{timeslot} and queue_vip_no=#{curQueueVipNo} and queue_vip_no < queue_vip_full; ")
    Integer updateGivenCheckTypeTimeslotVipSeqNo(@Param("checkType") Integer checkType, @Param("timeslot") Integer timeslot, @Param("curQueueVipNo") Integer curQueueVipNo);
    @Update("UPDATE lihu.queue_sequence SET queue_no=queue_set,queue_vip_no=queue_vip_set WHERE deleted=0; ")
    @Update("UPDATE lihu.queue_sequence SET queue_no=COALESCE(queue_set, 0), queue_vip_no=COALESCE(queue_vip_set, 0) WHERE deleted=0; ")
    Integer initNumber();
    // =========================================================================
    // ã€æœ¬éœ€æ±‚】按时段预约三级取号:本段普通号 â†’ æœ¬æ®µé¢„留号 â†’ æ€»é¢„留池(time_slot=0)
    //
    // å·æ®µçº¦å®šï¼ˆæ™®é€šåœ¨å‰ã€é¢„留紧跟其后、下一段起点 = ä¸Šä¸€æ®µ queue_vip_full):
    //   æ™®é€šå·: queue_set+1        â€¦ queue_full
    //   é¢„留号: queue_full+1       â€¦ queue_vip_full
    //   æ€»é¢„留池: å·æ®µæ”¾åœ¨å½“天所有时段之后,且**不设上限**
    // =========================================================================
    /** å–某时段整行(拿到 queue_set/queue_full/queue_vip_set/queue_vip_full è¾¹ç•Œï¼‰ */
    @Select("select * from lihu.queue_sequence where check_type=#{checkType} and time_slot=#{timeslot} and deleted=0 limit 1")
    QueueSequenceDO selectSegByCheckTypeAndTimeSlot(@Param("checkType") Integer checkType,
                                                    @Param("timeslot") Integer timeslot);
    /** å–该检查类型的「总预留池」行(time_slot = 0) */
    @Select("select * from lihu.queue_sequence where check_type=#{checkType} and time_slot=0 and deleted=0 limit 1")
    QueueSequenceDO selectTotalPool(@Param("checkType") Integer checkType);
    /** è¯¥æ£€æŸ¥ç±»åž‹å½“天所有时段的**最大预留号上限**(用于给总预留池定位号段起点) */
    @Select("select COALESCE(MAX(queue_vip_full), 0) from lihu.queue_sequence "
            + " where check_type=#{checkType} and time_slot<>0 and deleted=0")
    Integer selectMaxVipFullOfCheckType(@Param("checkType") Integer checkType);
    /**
     * æ€»é¢„留池发号:**不设上限**。
     * <p>
     * è¯­ä¹‰ä¸Žå…¶å®ƒå·æ± ä¿æŒä¸€è‡´ï¼ˆ{@code queue_vip_full} = å½“前最大可发号):
     * å· +1 çš„同时把 {@code queue_vip_full} é¡¶ä¸Šæ¥ï¼Œå› æ­¤æ°¸è¿œä¸ä¼šå‡ºçް"总池已满、签不上"。
     */
    @Update("update lihu.queue_sequence "
            + " set queue_vip_no = queue_vip_no + 1, "
            + "     queue_vip_full = GREATEST(queue_vip_full, queue_vip_no + 1) "
            + " where check_type=#{checkType} and time_slot=0 and deleted=0")
    Integer advanceTotalPoolVipNo(@Param("checkType") Integer checkType);
    /** è¯»å›žæ€»é¢„留池当前已发号(必须与推进在同一事务内) */
    @Select("select queue_vip_no from lihu.queue_sequence where check_type=#{checkType} and time_slot=0 and deleted=0")
    Integer selectTotalPoolVipNo(@Param("checkType") Integer checkType);
    /**
     * ã€ä¿®å¤ã€Œæ—¶æ®µä¸å…¨/已满导致没法签到」】判断指定 (检查类型, æ—¶æ®µ) æ˜¯å¦**还有可取号**。
     * <p>
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/mysql/room/RoomMapper.java
@@ -19,16 +19,64 @@
@Mapper
public interface RoomMapper extends BaseMapperX<RoomDO> {
    /**
     * è¯Šå®¤å·¥ä½åˆ†é¡µï¼ˆè¯Šå®¤ç®¡ç†åˆ—表 / å¯¼å‡ºå…±ç”¨ï¼‰ã€‚
     *
     * <p>排序(本需求):**诊室编号升序 â†’ å·¥ä½å·å‡åº**,同一诊室有多个工位时顺序稳定。
     * åŽŸå®žçŽ°æ˜¯ {@code orderByDesc(id)}(后加的排最前),与"按诊室编号看"的现场习惯不符。
     */
    default PageResult<RoomDO> selectPage(RoomPageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<RoomDO>()
                .eqIfPresent(RoomDO::getRoomId, reqVO.getRoomId())
                .eqIfPresent(RoomDO::getBedNo, reqVO.getBedNo())
                .betweenIfPresent(RoomDO::getCreateTime, reqVO.getCreateTime())
                .orderByDesc(RoomDO::getId));
                .orderByAsc(RoomDO::getRoomId)
                .orderByAsc(RoomDO::getBedNo));
    }
    @Select("select * from lihu.clinic_room where ip = #{ip}")
    /**
     * æŒ‰ IP æŸ¥è¯¥ IP ä¸‹**可用**的工位(供"按IP自动匹配工位"使用)。
     *
     * <p>三条过滤缺一不可:
     * <ul>
     *   <li>{@code deleted = 0}:原实现是裸查,已删除的工位也会被匹配到——
     *       ä¸€æ—¦æŸ IP è¢«å›žæ”¶å†åˆ†é…ç»™æ–°å·¥ä½ï¼Œå°±ä¼šè‡ªåŠ¨å…¥åº§åˆ°åŽ†å²è„å·¥ä½ä¸Šï¼›</li>
     *   <li>{@code status != 0(已关闭)}:已关闭的工位不该被自动入座(否则人坐上去却没有候诊队列,看着像"没病人");</li>
     *   <li>{@code ip <> ''}:空 IP ä¸åšåŒ¹é…ï¼ˆé¿å…åŽ†å²ç©ºå€¼è¡Œè¢«æ‰«åˆ°ï¼‰ã€‚</li>
     * </ul>
     * çŠ¶æ€å–å€¼è§ {@link cn.lihu.jh.module.ecg.enums.BedStatusEnum}:0已关闭 / 10已开通 / 20有医生 / 30暂停。</p>
     */
    @Select("select * from lihu.clinic_room " +
            " where ip = #{ip} and deleted = 0 and coalesce(status, 0) <> 0")
    @Results({
            @Result(property = "checkTypes", column = "check_types", typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
    })
    List<RoomDO> queueByIp(@Param("ip") String ip);
    /**
     * è¯¥ IP æ˜¯å¦å·²ç»‘定在**别的**工位上(用于"选择工位模式"里写 IP å‰çš„冲突检测)。
     * <p>一个工位只有一个 ip å­—段,若两台电脑走同一出口 IP,后绑定的会把前面的抢走,
     * æ‰€ä»¥è¦†ç›–前要先让用户确认(前端拿到冲突工位后弹确认,再带 force é‡è°ƒï¼‰ã€‚</p>
     *
     * @param ip        å¾…绑定的 IP
     * @param excludeId æŽ’除的工位主键(通常是本次要绑定的那个),可为空
     */
    @Select("<script> " +
            "SELECT * FROM lihu.clinic_room " +
            " WHERE deleted = 0 AND ip = #{ip} " +
            " <if test='excludeId != null'> AND id != #{excludeId} </if> " +
            " ORDER BY id" +
            "</script>")
    @Results({
            @Result(property = "checkTypes", column = "check_types", typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
    })
    List<RoomDO> listByIpExcludeId(@Param("ip") String ip, @Param("excludeId") Integer excludeId);
    /**
     * æŠŠæœ¬æœº IP å†™åˆ°æŒ‡å®šå·¥ä½ï¼ˆåªæ”¹ ip ä¸€ä¸ªå­—段,供"选择工位模式 2/3"使用)。
     */
    @Update("update lihu.clinic_room set ip = #{ip} where id = #{id} and deleted = 0")
    Integer updateIpById(@Param("id") Integer id, @Param("ip") String ip);
    @Update("<script> " +
            "update lihu.clinic_room set status=#{newStatus}, doc_id=null, doc_name=null " +
@@ -61,6 +109,38 @@
    @Select("SELECT * FROM lihu.clinic_room where doc_id=#{docId}")
    List<RoomDO> getRoomByDocId(@Param("docId")Long docId);
    /**
     * ã€æœ¬éœ€æ±‚·一人多工位】该医生**当前有效**占用的全部工位。
     *
     * <p>比 {@link #getRoomByDocId} å¤šäº†ä¸¤ä¸ªè¿‡æ»¤ï¼š
     * <ul>
     *   <li>{@code deleted = 0}:排除历史逻辑删除行(原 SQL æ²¡æœ‰ï¼Œå¯èƒ½è¿”回脏行);</li>
     *   <li>{@code status in (20,30)}:只取「医生在座(DOCTOR_ON) / åŒ»ç”Ÿæš‚停(DOCTOR_PAUSE)」,
     *       æŽ’除数据不一致残留的"doc_id è¿˜åœ¨ä½†çŠ¶æ€å·²å›žç©ºé—²"。两个值以常量传入,避免硬编码。</li>
     * </ul>
     */
    @Select("<script> " +
            "SELECT * FROM lihu.clinic_room where doc_id = #{docId} and deleted = 0 " +
            " and status in ( " +
            "  <foreach collection='statusList' separator=',' item='st'>#{st}</foreach> " +
            " ) order by room_id, bed_no " +
            "</script>")
    List<RoomDO> getMyActiveBeds(@Param("docId") Long docId, @Param("statusList") List<Integer> statusList);
    /**
     * ã€æœ¬éœ€æ±‚·统计按人合并】当前**在座医生数**(按 doc_id åŽ»é‡ï¼‰ã€‚
     *
     * <p>一人多工位后,"在座工位数"会按工位累计(数值本身正确),
     * ä½†ç›‘控页还需要"到底几个医生在岗",用它补一个指标。
     */
    @Select("<script> " +
            "select count(distinct doc_id) from lihu.clinic_room " +
            " where doc_id is not null and deleted = 0 and status in ( " +
            "  <foreach collection='statusList' separator=',' item='st'>#{st}</foreach> " +
            " ) " +
            "</script>")
    Integer countSeatedDoctors(@Param("statusList") List<Integer> statusList);
    @Update("<script> " +
            "update lihu.clinic_room set status=#{newStatus.status} " +
@@ -103,23 +183,130 @@
    Integer setBedStopReady(@Param("roomId")Long roomId, @Param("bedNo")String bedNo,
                            @Param("stopReady")Integer stopReady);
    /**
     * å…¥åº§ï¼šæŠŠè¯¥å·¥ä½çš„**主人**设为我(按 room_id + bed_no å®šä½ï¼‰ã€‚
     *
     * <p>【本次放宽 + ä¿®å¤ã€‘
     * <ul>
     *   <li>可入座条件统一为:<b>(无主人 æˆ– ä¸»äººæ˜¯æˆ‘)且 çŠ¶æ€ âˆˆ claimableStatusList</b>。
     *       çŠ¶æ€é›†åˆç”±è°ƒç”¨æ–¹ä¼  {已开通, æœ‰åŒ»ç”Ÿ, æš‚停}(**排除"已关闭"**)。<br>
     *       åŽŸæ¥"无主人"时只认"已开通":一旦数据里出现"doc_id ä¸ºç©ºä½†çŠ¶æ€ä»æ˜¯æœ‰åŒ»ç”Ÿ/暂停"
     *       è¿™ç§ä¸ä¸€è‡´ï¼ŒåŒ»ç”Ÿç«™åœ¨å·¥ä½å‰å´å§‹ç»ˆåä¸ä¸Šï¼ŒåªæŠ¥"状态异常",现场无从处理。</li>
     *   <li>主人是我时同样放行 â†’ ä¸Šæ¬¡æ²¡æ­£å¸¸ç¦»åº§ï¼ˆå…³æµè§ˆå™¨/超时)也能坐回自己的工位。</li>
     *   <li>主人是**别人**时更新 0 è¡Œï¼Œè°ƒç”¨æ–¹æ®æ­¤æ‹’绝(不挤掉正在操作的人)。</li>
     * </ul>
     */
    @Update("<script> " +
            "update lihu.clinic_room set status=#{newStatus.status}, doc_id=#{docId}, doc_name=#{docName} " +
            "where room_id=#{roomId} and bed_no=#{bedNo} and isNull(doc_id)" +
            "and status in ( " +
            " <foreach collection='curStatusList' separator=',' item='curStatus'>" +
            "  #{curStatus.status} " +
            " </foreach> );" +
            "update lihu.clinic_room set status=#{newStatus.status}, doc_id=#{docId}, doc_name=#{docName}, update_time=NOW() " +
            "where room_id=#{roomId} and bed_no=#{bedNo} and deleted = 0 " +
            "  and ( isNull(doc_id) or doc_id = #{docId} ) " +
            "  and status in ( " +
            "      <foreach collection='claimableStatusList' separator=',' item='curStatus'>#{curStatus.status}</foreach> );" +
            "</script>")
    Integer setBedDoctorOn(@Param("roomId")Long roomId, @Param("bedNo")String bedNo, @Param("docId")Long docId, @Param("docName")String docName, @Param("newStatus") BedStatusEnum newStatus, @Param("curStatusList") List<BedStatusEnum> curStatusList);
    Integer setBedDoctorOn(@Param("roomId")Long roomId, @Param("bedNo")String bedNo, @Param("docId")Long docId, @Param("docName")String docName,
                           @Param("newStatus") BedStatusEnum newStatus,
                           @Param("claimableStatusList") List<BedStatusEnum> claimableStatusList);
    /**
     * å…¥åº§ï¼šåŒä¸Šï¼Œä½†æŒ‰**主键 id** å®šä½ã€‚
     *
     * <p>【本次修复】现场出现过"工位在库里明明是空闲的,入座却更新 0 è¡Œ â†’ æŠ¥çŠ¶æ€å¼‚å¸¸"。
     * å·²ç¡®è®¤ä¸æ˜¯è¢«å ç”¨ï¼›å‰©ä¸‹çš„可能是 {@code bed_no} ä¸Žè¯·æ±‚值存在肉眼难辨的差异
     * ï¼ˆç»“尾空格、大小写、全角等 â€”— JSON ä¸Žå¤åˆ¶ç²˜è´´éƒ½çœ‹ä¸å‡ºæ¥ï¼‰ã€‚
     * å‰ç«¯è¯·æ±‚里本来就带 {@code id},因此优先按主键更新,彻底绕开这类问题。
     */
    @Update("<script> " +
            "update lihu.clinic_room set status=#{newStatus.status}, doc_id=#{docId}, doc_name=#{docName}, update_time=NOW() " +
            "where id=#{id} and deleted = 0 " +
            "  and ( isNull(doc_id) or doc_id = #{docId} ) " +
            "  and status in ( " +
            "      <foreach collection='claimableStatusList' separator=',' item='curStatus'>#{curStatus.status}</foreach> );" +
            "</script>")
    Integer setBedDoctorOnById(@Param("id")Integer id, @Param("docId")Long docId, @Param("docName")String docName,
                               @Param("newStatus") BedStatusEnum newStatus,
                               @Param("claimableStatusList") List<BedStatusEnum> claimableStatusList);
    /**
     * è¯Šæ–­ç”¨ï¼šå–工位**原始行**(含 {@code bed_no} çš„长度与十六进制,用于发现结尾空格/全角差异)。
     *
     * <p>传入 id æ—¶æŒ‰ id æŸ¥ï¼ˆå«å·²åˆ é™¤è¡Œï¼‰ï¼›å¦åˆ™æŒ‰ room_id + {@code TRIM(bed_no)} å®½æ¾æŸ¥ã€‚
     * è¿”回 Map è€Œä¸æ˜¯ DO:这里要把 {@code deleted}、长度、HEX è¿™äº›"非实体字段"一起带出来。
     */
    @Select("<script>" +
            "select id, room_id, room_name, bed_no," +
            "       CHAR_LENGTH(bed_no) as bed_no_len, HEX(bed_no) as bed_no_hex," +
            "       ip, status, doc_id, doc_name, op_type, deleted, update_time" +
            "  from lihu.clinic_room" +
            " where 1 = 1" +
            " <if test='id != null'> and id = #{id} </if>" +
            " <if test='id == null'> and room_id = #{roomId} and TRIM(bed_no) = TRIM(#{bedNo}) </if>" +
            " order by deleted, id" +
            "</script>")
    List<Map<String, Object>> selectBedRawState(@Param("id") Integer id, @Param("roomId") Long roomId, @Param("bedNo") String bedNo);
    /**
     * ã€æœ¬éœ€æ±‚】一个用户只允许占一个工位:入座成功后,把**我的其它工位**释放掉。
     *
     * <p>只清 {@code doc_id/doc_name} å¹¶æŠŠçŠ¶æ€ç½®å›ž"已开通",不动 check_types / ip / stop_ready;
     * ç”¨ {@code not (room_id = ? and bed_no = ?)} æŽ’除本次入座的工位,避免把自己刚坐上的也清掉。
     *
     * @param keepRoomId æœ¬æ¬¡å…¥åº§çš„诊室编号(保留不动)
     * @param keepBedNo  æœ¬æ¬¡å…¥åº§çš„工位号(保留不动)
     */
    @Update("<script> " +
            "update lihu.clinic_room set status=#{newStatus.status}, doc_id=null, doc_name=null, update_time=NOW() " +
            "where doc_id=#{docId} and deleted = 0 " +
            "<if test='keepRoomId != null and keepBedNo != null'>" +
            "  and not (room_id = #{keepRoomId} and bed_no = #{keepBedNo}) " +
            "</if>" +
            "</script>")
    Integer releaseOtherBedsOfDoc(@Param("docId")Long docId, @Param("keepRoomId")Long keepRoomId,
                                  @Param("keepBedNo")String keepBedNo, @Param("newStatus") BedStatusEnum newStatus);
    @Update("<script> " +
            "update lihu.clinic_room set status=#{newStatus.status}, doc_id=null, doc_name=null " +
            "where room_id=#{roomId} and bed_no=#{bedNo} " +
            "where room_id=#{roomId} and bed_no=#{bedNo} and doc_id=#{docId} " +
            "and status in ( " +
            " <foreach collection='curStatusList' separator=',' item='curStatus'>" +
            "  #{curStatus.status} " +
            " </foreach> );" +
            "</script>")
    Integer setBedDoctorOff(@Param("roomId")Long roomId, @Param("bedNo")String bedNo, @Param("docId")Long docId, @Param("newStatus") BedStatusEnum newStatus, @Param("curStatusList") List<BedStatusEnum> curStatusList);
    /**
     * ã€æœ¬æ¬¡éœ€æ±‚】闭诊时清空**所有被医生占用**的工位(doc_id éžç©ºï¼Œä¸”状态为「医生在岗 / æš‚停」)。
     * <p>
     * èƒŒæ™¯ï¼šå·¥ä½ä¸å†å› ã€Œç©ºé—²è¶…æ—¶ / ä»¤ç‰Œå¤±æ•ˆ / åˆ·æ–°é¡µé¢ã€è¢«é‡Šæ”¾ï¼ˆé¿å…åŒ»ç”Ÿç¦»å¼€ä¸€ä¼šå„¿å°±ä¸¢å·¥ä½ï¼‰ï¼Œ
     * å› æ­¤éœ€è¦ä¸€ä¸ªå…œåº•,防止医生下班忘离座导致工位长期被占用 â€”— é—­è¯Šæ—¶ç»Ÿä¸€æ¸…空当班工位。
     *
     * @param newStatus     æ¸…空后置为的状态(空闲 / å·²å¼€é€šï¼‰
     * @param curStatusList å…è®¸è¢«æ¸…空的当前状态集合
     */
    @Update("<script> " +
            "update lihu.clinic_room set status=#{newStatus.status}, doc_id=null, doc_name=null, update_time=NOW() " +
            "where deleted = 0 and doc_id is not null " +
            "and status in ( " +
            " <foreach collection='curStatusList' separator=',' item='curStatus'>" +
            "  #{curStatus.status} " +
            " </foreach> )" +
            "</script>")
    Integer clearAllBedDoctors(@Param("newStatus") BedStatusEnum newStatus, @Param("curStatusList") List<BedStatusEnum> curStatusList);
    /**
     * ã€æœ¬æ¬¡éœ€æ±‚】管理员强制清空某个工位。
     * <p>
     * ä¸Ž {@link #setBedDoctorOff} çš„唯一区别:**不带 `doc_id = ?` æ¡ä»¶**,
     * å› æ­¤å¯ä»¥æŠŠ"别人占用中"的工位清掉(医生下班忘离座、换人使用同一工位时用)。
     * ä»ç„¶è¦æ±‚当前状态是「医生在岗 / æš‚停」且 doc_id éžç©ºï¼Œé¿å…è¯¯æ¸…空闲工位。
     */
    @Update("<script> " +
            "update lihu.clinic_room set status=#{newStatus.status}, doc_id=null, doc_name=null, update_time=NOW() " +
            "where room_id=#{roomId} and bed_no=#{bedNo} and doc_id is not null " +
            "and status in ( " +
            " <foreach collection='curStatusList' separator=',' item='curStatus'>" +
            "  #{curStatus.status} " +
            " </foreach> )" +
            "</script>")
    Integer setBedDoctorOffAdmin(@Param("roomId")Long roomId, @Param("bedNo")String bedNo,
                                 @Param("newStatus") BedStatusEnum newStatus, @Param("curStatusList") List<BedStatusEnum> curStatusList);
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/enums/HisCheckNameEnum.java
@@ -4,7 +4,20 @@
import lombok.Getter;
/**
 * His的Check_type类型枚举
 * His的Check_type类型枚举(按 HIS ä¼ æ¥çš„**检查项目名称**映射本地 check_type)
 *
 * <p>注意:同一个检查在 HIS ä¾§å¯èƒ½æœ‰å¤šä¸ªå«æ³•,本枚举用「一个值挂多个名称」的方式兼容。
 * å®žæµ‹ 2026-10-04/05:运动试验这一项 HIS ä¾§æ”¹åè¿‡â€”—
 * ã€Œå¹³æ¿è¿åŠ¨å¿ƒç”µã€ï¼ˆæ—§ï¼‰â†’ã€Œå¹³æ¿è¿åŠ¨è¯•éªŒã€ï¼ˆçŽ°ï¼‰ï¼Œè€Œæœ¬åœ° check_type ä¸€ç›´æ˜¯ 400。
 * åç§°æ˜ å°„不到时,{@code handleAppointmentCreate} ä¼šé€€å›žæŒ‰ HIS æ£€æŸ¥ä»£ç æ˜ å°„
 * ï¼ˆ{@link HisCheckCodeEnum}),两条路任一条通即可,避免再出现 book_check_type ä¸ºç©ºã€‚</p>
 *
 * <p>实测 2026-10-07 åˆå‘现血压类同样的问题:HIS å‘的是「24小时动态血压监测」,
 * è€Œæžšä¸¾é‡Œå½“时只有「动态血压」,精确匹配失败(日志:
 * {@code [handleAppointmentCreate][体检患者检查项目名称无法映射] checkName=24小时动态血压监测})。
 * çŽ°å·²è¡¥é½åˆ«åï¼›åŒæ—¶ {@link #getByName(String)} å¢žåР䏀层**规范化兜底匹配**,
 * ç”¨äºŽå¸æ”¶ HIS ä¾§ã€Œæœ‰æ— ã€Œ24小时」前缀」「全/半角括号」「多余空格」这类无意义差异,
 * é¿å…æ¯æ¢ä¸€ç§å«æ³•就要改一次代码。</p>
 */
@Getter
@AllArgsConstructor
@@ -12,8 +25,23 @@
    Y691133607("常规心电图", "100"),
    Y201605("24小时动态心电图", "200"),
    Y201606("24小时动态心电监测", "200"),
    // åŒä¸€æ£€æŸ¥ï¼ˆæœ¬åœ° check_type=200 åŠ¨æ€å¿ƒç”µï¼‰çš„å…¶å®ƒå«æ³•ã€‚
    // å®žæµ‹æŠ¥æ–‡é‡Œè¯¥é¡¹çš„ code å­˜åœ¨ä¸¤ç§å½¢æ€ï¼šY201605(带Y前缀)与 201605(不带),
    // è€Œ HisCheckCodeEnum åªç™»è®°äº† 201605,故带 Y çš„那种必须靠名称映射兜住。
    Y201605_ALIAS1("动态心电图", "200"),
    Y201605_ALIAS2("24小时动态心电", "200"),
    Y201605_ALIAS3("动态心电监测", "200"),
    Y200327("动态血压", "300"),
    // åŒä¸€æ£€æŸ¥ï¼ˆæœ¬åœ° check_type=300 åŠ¨æ€è¡€åŽ‹ï¼‰çš„å…¶å®ƒå«æ³•ã€‚
    // å®žæµ‹ 2026-10-07:HIS å®žé™…下发的是「24小时动态血压监测」(jcxmcode=Y200327),
    // å½“时枚举只有「动态血压」→ åç§°åŒ¹é…å¤±è´¥ï¼Œä¸”代码兜底也对不上(代码是 Y200327,表里记的是 200327)。
    Y200327_ALIAS1("24小时动态血压监测", "300"),
    Y200327_ALIAS2("24小时动态血压", "300"),
    Y200327_ALIAS3("动态血压监测", "300"),
    Y201652("平板运动心电", "400"),
    // åŒä¸€æ£€æŸ¥ï¼ˆæœ¬åœ° check_type=400)的其它叫法:枚举常量名不能重复,故加后缀
    Y201652_ALIAS1("平板运动试验", "400"),
    Y201652_ALIAS2("心电图运动试验", "400"),
    Y502490914("动脉硬化检测", "500"),
    Y419562119("食道电生理", "600"),
    Y201604("心电图药物负荷试验", "700"),
@@ -32,20 +60,66 @@
    private final String value;
    /**
     * æ ¹æ®ç±»åž‹èŽ·å–æžšä¸¾
     * æ ¹æ®åç§°èŽ·å–æœ¬åœ°æ£€æŸ¥ç±»åž‹ï¼šå…ˆç²¾ç¡®åŒ¹é…ï¼Œå†è§„èŒƒåŒ–å…œåº•åŒ¹é…ã€‚
     *
     * @param type ç±»åž‹
     * @return æžšä¸¾
     * @param name HIS ä¼ æ¥çš„æ£€æŸ¥é¡¹ç›®åç§°
     * @return æœ¬åœ° check_type å­—符串;都匹配不到返回 null
     */
    public static String getByName(String name) {
        if (name == null) {
            return null;
        }
        // â‘  ç²¾ç¡®åŒ¹é…ï¼ˆä¿æŒåŽŸæœ‰è¡Œä¸ºï¼‰
        for (HisCheckNameEnum value : values()) {
            if (value.getName().equals(name)) {
                return value.getValue();
            }
        }
        // â‘¡ è§„范化后再匹配:吸收「24小时」前缀、全/半角括号、空格、大小写等无意义差异,
        //    é¿å… HIS æ¯æ¢ä¸€ç§å«æ³•就漏映射(例如「24小时动态血压监测」vs「动态血压监测」)
        String normalized = normalizeName(name);
        if (normalized.isEmpty()) {
            return null;
        }
        for (HisCheckNameEnum value : values()) {
            String enumNormalized = normalizeName(value.getName());
            if (enumNormalized.equals(normalized)) {
                return value.getValue();
            }
            // â‘¢ åŽ»æŽ‰ã€Œ24小时」后再比一次:HIS ä¾§æœ‰æ— è¯¥å‰ç¼€éƒ½å¯èƒ½å‡ºçް
            if (strip24Hour(enumNormalized).equals(strip24Hour(normalized)) && !strip24Hour(normalized).isEmpty()) {
                return value.getValue();
            }
        }
        return null;
    }
    /** è§„范化项目名称:去空白、统一大小写、全角括号→半角、去掉「24小时」前缀 */
    private static String normalizeName(String name) {
        if (name == null) {
            return "";
        }
        return name.trim()
                .replace(" ", "")
                .replace("\u3000", "")
                .replace("(", "(")
                .replace(")", ")")
                .replace("24", "24")
                .toLowerCase();
    }
    /** åŽ»æŽ‰ã€Œ24小时」/「24h」前缀,用于比较同义名称 */
    private static String strip24Hour(String normalizedName) {
        String v = normalizedName;
        if (v.startsWith("24小时")) {
            v = v.substring("24小时".length());
        }
        if (v.startsWith("24h")) {
            v = v.substring("24h".length());
        }
        return v;
    }
    /**
     * æ ¹æ®ç±»åž‹èŽ·å–æžšä¸¾
     *
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/enums/HisCheckStatusEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,110 @@
package cn.lihu.jh.module.ecg.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Objects;
/**
 * HIS æ£€æŸ¥çŠ¶æ€ç¼–ç ã€‚
 *
 * <h3>来源</h3>
 * é™¢æ–¹ã€Šæ£€æŸ¥çŠ¶æ€ç¼–ç è¡¨ã€‹ï¼š
 * <pre>
 *   1  ç”³è¯·å•开立      2  ç”³è¯·å•撤销      3  æ£€æŸ¥ç™»è®°
 *   4  å–消检查        5  æ£€æŸ¥å¼€å§‹        6  ç»™é€ å½±å‰‚
 *   7  æ£€æŸ¥ç»“束        8  æŠ¥å‘Šç”Ÿæˆ        9  æŠ¥å‘Šæ’¤é”€
 *   10 æŠ¥å‘Šå®¡æ ¸
 *   21 æ ‡æœ¬é€æ£€ï¼ˆç—…理用)  22 å–消标本送检(病理用)  23 å·²ç¼´è´¹ï¼ˆç—…理用)
 * </pre>
 * å…¶ä¸­ 21/22/23 ä¸º**病理专用,心电系统不涉及**,本枚举不包含。
 *
 * <h3>为什么需要归一化</h3>
 * HIS æŠ¥æ–‡ä¸­åŒä¸€ä¸ªçŠ¶æ€ä¼šä»¥ä¸åŒå­—é¢å½¢å¼å‡ºçŽ°ï¼Œå®žæµ‹æ—¥å¿—ä¸­å­˜åœ¨ï¼š
 * <pre>
 *   processStep.code = "3" / "03" / "7" / "08" / "10"
 * </pre>
 * å³**前导零是否存在并不稳定**(接口文档示例给的是 {@code code="01"})。
 * è‹¥ç›´æŽ¥å†™å…¥æ•°æ®åº“,会产生 "3" ä¸Ž "03" ä¸¤ç§å€¼ï¼Œå¯¼è‡´çŠ¶æ€åˆ¤æ–­é”™ä¹±ã€‚
 * å› æ­¤ç»Ÿä¸€ç» {@link #normalize(String)} å½’一化为无前导零的标准编码后再入库。
 *
 * @author å¿ƒç”µæŽ’队装机系统
 */
@Getter
@AllArgsConstructor
public enum HisCheckStatusEnum {
    APPLY_CREATED(1, "申请单开立"),
    APPLY_CANCELLED(2, "申请单撤销"),
    REGISTERED(3, "检查登记"),
    CHECK_CANCELLED(4, "取消检查"),
    CHECK_STARTED(5, "检查开始"),
    CONTRAST_GIVEN(6, "给造影剂"),
    CHECK_FINISHED(7, "检查结束"),
    REPORT_GENERATED(8, "报告生成"),
    REPORT_CANCELLED(9, "报告撤销"),
    REPORT_REVIEWED(10, "报告审核");
    /** æ ‡å‡†ç¼–码(无前导零) */
    private final Integer code;
    /** çŠ¶æ€åç§° */
    private final String name;
    /**
     * å½’一化 HIS æŠ¥æ–‡ä¸­çš„状态编码字符串。
     * <p>
     * å¤„理内容:去空白、去前导零、校验是否在编码表内。
     *
     * @param raw æŠ¥æ–‡åŽŸå§‹å€¼ï¼Œå¦‚ "3"、"03"、" 10 "
     * @return å½’一化后的标准编码字符串(如 "3"、"10");无法识别时返回 null
     */
    public static String normalize(String raw) {
        if (raw == null) {
            return null;
        }
        String trimmed = raw.trim();
        if (trimmed.isEmpty()) {
            return null;
        }
        // ä»…接受数字,避免把非数字内容写入 status
        if (!trimmed.matches("\\d+")) {
            return null;
        }
        // åŽ»å‰å¯¼é›¶ï¼š"03" -> "3";"0" -> "0"
        String stripped = trimmed.replaceFirst("^0+(?=\\d)", "");
        int code;
        try {
            code = Integer.parseInt(stripped);
        } catch (NumberFormatException e) {
            return null;
        }
        // åªæŽ¥å—编码表内的值
        return contains(code) ? String.valueOf(code) : null;
    }
    /** åˆ¤æ–­ç¼–码是否属于本编码表(不含病理专用状态) */
    public static boolean contains(Integer code) {
        if (code == null) {
            return false;
        }
        for (HisCheckStatusEnum item : values()) {
            if (Objects.equals(item.code, code)) {
                return true;
            }
        }
        return false;
    }
    /** æŒ‰ç¼–码取状态名称,未匹配返回 null */
    public static String getNameByCode(Integer code) {
        if (code == null) {
            return null;
        }
        for (HisCheckStatusEnum item : values()) {
            if (Objects.equals(item.code, code)) {
                return item.name;
            }
        }
        return null;
    }
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/appointment/AppointmentService.java
@@ -114,6 +114,15 @@
     */
    List<AppointmentDO> queryAndCreateAppointmentByPatId(String patCode);
    /**
     * ã€æœ¬éœ€æ±‚:紧急患者不排队直接检查】按「申请单号 / å°±è¯Šå· / æ‚£è€…编号」查近一个月的预约记录。
     *
     * @param code       ç”³è¯·å•号 / å°±è¯Šæµæ°´å· / æ‚£è€…编号(三者之一)
     * @param checkTypes æœ¬çª—口开展的检查项目,逗号分隔;为空表示不限制
     * @return é¢„约记录,**按预约时间倒序**(最近的在前)
     */
    List<AppointmentDO> getEmergencyCandidates(String code, String checkTypes);
    List<AppointmentDO> getAppointmentExtermal(String patId);
    Integer appoitmentConfirm(AppointmentConfirmReqVO appointmentConfirmReqVO);
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/appointment/AppointmentServiceImpl.java
@@ -37,6 +37,7 @@
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
@@ -104,6 +105,79 @@
        // è¿”回
        return appointment.getId();
    }
    /** å¹¶å‘冲突后的重试次数与等待间隔(仅用于"插入撞唯一索引"这一种情况) */
    private static final int APPLY_NO_CONFLICT_RETRY = 3;
    private static final long APPLY_NO_CONFLICT_WAIT_MS = 150L;
    /**
     * æŒ‰ {@code apply_no} åŽŸå­è½åº“ï¼šå·²å­˜åœ¨åˆ™æ›´æ–°ï¼Œä¸å­˜åœ¨åˆ™æ’å…¥ï¼›å¹¶å‘é‡å¤æ’å…¥æ—¶ä¸ä¼šç•™é‡å¤è¡Œã€‚
     *
     * <h3>为什么需要它</h3>
     * åŽŸå®žçŽ°æ˜¯ã€Œ{@code getByApplyNo} æŸ¥ä¸€ä¸‹ â†’ æ²¡æœ‰å°± insert」,查与插之间没有原子性。
     * å®žæµ‹ 2026-10-04 12:16:HIS åœ¨ 423ms å†…把同一张申请单推了两遍,两个线程都读到 0 è¡Œã€
     * éƒ½æ‰§è¡Œäº† insert,于是同一 {@code apply_no} è½ä¸‹ä¸¤æ¡ï¼ˆid 211740 / 211741)。
     * è¡¨ä¸ŠåŽŸæœ‰çš„å”¯ä¸€ç´¢å¼• {@code uniq-pat-book(pat_id, book_date, book_check_type)}
     * åœ¨ book_date / book_check_type ä¸º NULL æ—¶ä¸ç”Ÿæ•ˆï¼Œæ‹¦ä¸ä½ã€‚
     *
     * <h3>现在的做法</h3>
     * æ•°æ®åº“侧已加唯一索引 {@code uk_appointment_apply_no_active}(只约束未删除行)。
     * è¿™é‡Œæ”¹æˆã€Œå…ˆå°è¯•插入,撞唯一键就回读并改为更新」:
     * <ul>
     *   <li>串行场景:插入成功,行为与原来一致;</li>
     *   <li>并发场景:其中一个插入必然失败(DuplicateKeyException),失败方走更新,不再产生重复行;</li>
     *   <li>对方事务尚未提交导致回读不到时,短暂等待后重试(最多 {@value #APPLY_NO_CONFLICT_RETRY} æ¬¡ï¼‰ã€‚</li>
     * </ul>
     *
     * @return è½åº“记录的 id
     */
    private Long atomicSaveByApplyNo(AppointmentSaveReqVO saveReqVO) {
        String applyNo = saveReqVO.getApplyNo();
        // â‘  å…ˆæŒ‰ç”³è¯·å•号走"更新"分支(与改造前的语义一致:命中已存在则以本次报文内容更新)
        AppointmentDO exists = (applyNo == null) ? null : appointmentMapper.getByApplyNo(applyNo);
        if (exists != null) {
            saveReqVO.setId(exists.getId());
            updateAppointment(saveReqVO);
            return exists.getId();
        }
        // â‘¡ ä¸å­˜åœ¨ â†’ å°è¯•插入;撞唯一索引说明有并发插入,转更新重试
        for (int attempt = 0; attempt <= APPLY_NO_CONFLICT_RETRY; attempt++) {
            try {
                AppointmentDO toInsert = BeanUtils.toBean(saveReqVO, AppointmentDO.class);
                toInsert.setId(null);
                appointmentMapper.insert(toInsert);
                return toInsert.getId();
            } catch (DuplicateKeyException e) {
                AppointmentDO concurrent = (applyNo == null) ? null : appointmentMapper.getByApplyNo(applyNo);
                if (concurrent != null) {
                    log.warn("[atomicSaveByApplyNo][并发重复插入已拦截,改为更新已有记录] applyNo={} existingId={}",
                            applyNo, concurrent.getId());
                    saveReqVO.setId(concurrent.getId());
                    updateAppointment(saveReqVO);
                    return concurrent.getId();
                }
                // å¯¹æ–¹å¯èƒ½è¿˜æ²¡æäº¤ï¼šç­‰ä¸€ä¸‹å†è¯•(最后仍拿不到就上抛,由外层记录错误日志)
                if (attempt < APPLY_NO_CONFLICT_RETRY) {
                    try {
                        Thread.sleep(APPLY_NO_CONFLICT_WAIT_MS);
                    } catch (InterruptedException ie) {
                        Thread.currentThread().interrupt();
                        throw e;
                    }
                    AppointmentDO retry = appointmentMapper.getByApplyNo(applyNo);
                    if (retry != null) {
                        saveReqVO.setId(retry.getId());
                        updateAppointment(saveReqVO);
                        return retry.getId();
                    }
                } else {
                    throw e;
                }
            }
        }
        throw exception(APPOINTMENT_CREATE_FAIL);
    }
    @Override
@@ -183,6 +257,12 @@
    @Override
    public List<AppointmentDO> queryAndCreateAppointmentByPatId(String patCode) {
        // ä»ŽåŒ»é™¢å¹³å°æŸ¥è¯¢
        //
        // ã€æœ¬éœ€æ±‚】匹配口径从「申请单号 / å°±è¯Šæµæ°´å· / ä½é™¢å· / èº«ä»½è¯å·ã€æ‰©å±•为**再加上 pat_id**
        // ï¼ˆè§ AppointmentMapper.getCurrentCode çš„ SQL)。原因:
        //   æ‰‹åŠ¨æ–°å»ºçš„é¢„çº¦ï¼ˆ/ecg/appointment/create)只有 pat_id,
        //   apply_no / episode_id / hospital_no / pat_identity_id å…¨ä¸ºç©ºï¼Œ
        //   ç­¾åˆ°é¡µæŒ‰æ‚£è€…编号查询时**永远查不到** â†’ é¡µé¢æç¤ºã€Œè¯·å…ˆé¢„约」→ æ— æ³•签到。
        List<AppointmentDO> appointmentExtermalList = appointmentMapper.getCurrentCode(patCode);
        for (int appointmentIndex = 0; appointmentIndex < appointmentExtermalList.size(); appointmentIndex++) {
@@ -190,26 +270,47 @@
            if (null == appointmentExtermal) continue;
            // ä»ŽDB查询预约,是否已经存在
            AppointmentDO appointmentDO = appointmentMapper.getByEpisodeIdAndApplyNo(appointmentExtermal.getEpisodeId(), appointmentExtermal.getApplyNo());
            //
            // ã€æœ¬éœ€æ±‚】优先按**主键 id** åˆ¤æ–­ã€Œæ˜¯å¦å·²å­˜åœ¨ã€ï¼š
            //   getCurrentCode æ˜¯æœ¬åœ°åº“查询(select * ä¼šæŠŠ id å¸¦å›žæ¥ï¼‰ï¼Œ
            //   è€ŒåŽŸé€»è¾‘ç”¨ getByEpisodeIdAndApplyNo(episodeId, applyNo) åˆ¤æ–­ï¼Œ
            //   æ‰‹åŠ¨æ–°å»ºçš„è¡Œè¿™ä¸¤ä¸ªå­—æ®µæ˜¯ NULL/空 â‡’ `= NULL` æ°¸è¿œä¸åŒ¹é… â‡’ æ¯æ‰“开一次签到页就**重复插入**一条。
            AppointmentDO appointmentDO = null;
            if (null != appointmentExtermal.getId()) {
                appointmentDO = appointmentMapper.selectById(appointmentExtermal.getId());
            }
            if (null == appointmentDO) {
                appointmentDO = appointmentMapper.getByEpisodeIdAndApplyNo(
                        appointmentExtermal.getEpisodeId(), appointmentExtermal.getApplyNo());
            }
            if (null == appointmentDO) {
                appointmentMapper.insert(appointmentExtermal);
            } else {
                appointmentExtermal.setId(appointmentDO.getId()); // ç¡®ä¿ è¿”回值包含 appointment id
                // å¤„理第一次确认时还没有预约时间,再来确认时已有预约时间的情况
                if (appointmentDO.getBookDate() != appointmentExtermal.getBookDate()) {
                // ã€æœ¬æ¬¡ä¿®å¤ã€‘原写法 `appointmentDO.getBookDate() != appointmentExtermal.getBookDate()`
                // æ˜¯**引用比较**(LocalDate ä¸¤ä¸ªä¸åŒå®žä¾‹å³ä½¿å€¼ç›¸åŒä¹Ÿä¼šåˆ¤ä¸ºä¸ç­‰ï¼‰ï¼Œ
                // ä¼šå¯¼è‡´æ¯æ¬¡éƒ½ç™½è·‘一次 updateById(无谓改写 update_time)。改为值比较。
                if (!java.util.Objects.equals(appointmentDO.getBookDate(), appointmentExtermal.getBookDate())) {
                    appointmentMapper.updateById(appointmentExtermal);
                }
            }
        }
        // æŸ¥è¯¢[queue表]补充 book_seq_num åˆ°è¿”回值中
        // æŸ¥è¯¢[queue表]补充「排队号」到返回值中
        //
        // ã€å·ç»Ÿä¸€ï¼ˆæœ¬éœ€æ±‚)】排队号改为在签到时一次性写进 queue.seq_num,不再写 book_seq_num。
        // å› æ­¤è¿™é‡Œå– seq_num;对外字段名仍叫 bookSeqNum(需求要求接口字段保留不动)。
        // è¿‡æ»¤ null:存量行(改造前签到的)seq_num å¯èƒ½ä¸ºç©ºï¼Œè€Œ toMap çš„ value ä¸º null ä¼šæŠ› NPE。
        if (!appointmentExtermalList.isEmpty()) {
            List<Long> appointIdlist = appointmentExtermalList.stream().map(appointmentDO -> appointmentDO.getId()).toList();
            List<QueueDO> simpleQueueDOList = queueService.selectBookSeqNumByAppointIdList(appointIdlist);
            List<QueueDO> simpleQueueDOList = queueService.selectQueueByAppointIdList(appointIdlist);
            if (!simpleQueueDOList.isEmpty()) {
                Map<Long, Integer> mapAppointIdVsBookSeqNo = simpleQueueDOList.stream().collect(Collectors.toMap(QueueDO::getAppointId, QueueDO::getBookSeqNum));
                appointmentExtermalList.forEach(appointmentExterma -> appointmentExterma.setBookSeqNum(mapAppointIdVsBookSeqNo.get(appointmentExterma.getId())));
                Map<Long, Integer> mapAppointIdVsSeqNo = simpleQueueDOList.stream()
                        .filter(queueDO -> null != queueDO.getAppointId() && null != queueDO.getSeqNum())
                        .collect(Collectors.toMap(QueueDO::getAppointId, QueueDO::getSeqNum, (a, b) -> a));
                appointmentExtermalList.forEach(appointmentExterma -> appointmentExterma.setBookSeqNum(mapAppointIdVsSeqNo.get(appointmentExterma.getId())));
            }
        }
@@ -315,27 +416,52 @@
        AppointmentDO appointment = getAppointment(confirmReqVO.getId());
        if (null == appointment) throw exception(PATIENT_NOT_EXISTS);
        // æ‰‹åŠ¨é¢„çº¦ï¼Œç›´æŽ¥è®¾ç½®é¢„çº¦æ—¥æœŸä¸ºå½“å¤©
        if (confirmReqVO.getIsVip() == 1) {
        boolean isVipFlag = (null != confirmReqVO.getIsVip() && 1 == confirmReqVO.getIsVip());
        boolean forceCheckIn = Boolean.TRUE.equals(confirmReqVO.getForceCheckIn());
        // æ³¨æ„ï¼šè¿™é‡Œçš„"是否当天"必须在改写 bookDate ä¹‹å‰åˆ¤æ–­ï¼ˆä¸‹é¢ä¼šæŠŠéžå½“天改为当天)
        boolean bookDateIsToday = DateUtils.isToday(appointment.getBookDate());
        // æ‰‹åŠ¨é¢„çº¦ / VIP åŠ å·ï¼šé¢„çº¦æ—¥æœŸç›´æŽ¥æŒ‰å½“å¤©ç­¾åˆ°
        if (isVipFlag) {
            appointment.setBookDate(LocalDate.now());
            bookDateIsToday = true;
        }
        if (!DateUtils.isToday(appointment.getBookDate())) throw exception(APPOINTMENT_NOT_TODAY);
        // ã€æœ¬éœ€æ±‚】非当天预约的签到:先提醒、确认后允许签入(前端弹窗确认后带 forceCheckIn=true)
        //   â‘  æœªç¡®è®¤ï¼ˆå‰ç«¯è¿˜æ²¡è®©ç”¨æˆ·ç¡®è®¤ï¼‰â†’ ä»è¿”回 APPOINTMENT_NOT_TODAY,前端据此弹「非当天,是否签到」;
        //   â‘¡ å·²ç¡®è®¤ â†’ å…è®¸ç­¾å…¥ï¼Œå¹¶ä¸”**强制走预留号通道**,避免占用当天同时段的正常预约号;
        //   â‘¢ é¢„约日期改写为当天:队列/大屏/工作台都按 book_date = ä»Šå¤© è¿‡æ»¤ï¼Œ
        //      è‹¥ä¿ç•™åŽŸæ—¥æœŸï¼Œæ‚£è€…ç­¾å…¥åŽä¸ä¼šå‡ºçŽ°åœ¨ä»»ä½•åˆ—è¡¨é‡Œã€‚
        if (!bookDateIsToday) {
            if (!forceCheckIn) {
                throw exception(APPOINTMENT_NOT_TODAY);
            }
            log.info("[appoitmentConfirm] éžå½“天预约签入(改用预留号,预约日期改今天). appointmentId={} åŽŸé¢„çº¦æ—¥æœŸ={} checkType={} timeslot={}",
                    appointment.getId(), appointment.getBookDate(), confirmReqVO.getBookCheckType(),
                    confirmReqVO.getBookTimeslot());
            appointment.setBookDate(LocalDate.now());
        }
        // ã€éœ€æ±‚:排队序号规则】按 check_type.seq_rule å†³å®šå–号方式:
        //   0=预约号(用预约系统已下发的号;当前 HIS æœªä¸‹å‘,会自动回退到签到序号)
        //   1=签到序号(当天同检查类型连续递增,每 9 ä¸ªé¢„ç•™ 1 ä¸ªç»™åŠ æ€¥ï¼‰
        //   2=时段号(按 bookTimeslot ä»Žå·æ± å–号 â€”— æ”¹é€ å‰çš„原有行为,也是未配置时的默认)
        //
        // ã€æœ¬éœ€æ±‚:按时段预约】增加 VIP/加号标记:
        //   Â· VIP/加号/非当天签入 â†’ æœ¬æ®µé¢„留号 â†’ æ€»é¢„留池(time_slot=0)
        //   Â· æ™®é€š               â†’ æœ¬æ®µæ™®é€šå· â†’ æœ¬æ®µé¢„留号 â†’ æ€»é¢„留池
        //   æ²¡æœ‰é¢„约时段(bookTimeslot ä¸ºç©º/0/字典外)时也能签到:直接落到总预留池。
        boolean urgent = isHisUrgent(appointment);
        boolean vip = isVipFlag || urgent || !bookDateIsToday;
        Integer newSeqNo = queueSequenceService.distributeSeqNoByRule(
                confirmReqVO.getBookCheckType(),
                confirmReqVO.getBookTimeslot(),
                appointment.getBookSeqNum(),
                urgent);
        log.info("[appoitmentConfirm][分配排队序号] appointmentId={} checkType={} timeslot={} è§„则取号={} åŠ æ€¥={}",
                urgent,
                vip);
        log.info("[appoitmentConfirm][分配排队序号] appointmentId={} checkType={} timeslot={} è§„则取号={} åŠ æ€¥={} VIP/加号={}",
                appointment.getId(), confirmReqVO.getBookCheckType(), confirmReqVO.getBookTimeslot(),
                newSeqNo, urgent);
                newSeqNo, urgent, vip);
        try {
            QueueSaveReqVO queueSaveReqVO = new QueueSaveReqVO();
@@ -348,14 +474,19 @@
            queueSaveReqVO.setBookDate(appointment.getBookDate());
            queueSaveReqVO.setBookTimeslot(confirmReqVO.getBookTimeslot()); /*appointment.getBookTimeslot()*/
            queueSaveReqVO.setBookCheckType(appointment.getBookCheckType());
            queueSaveReqVO.setBookSeqNum(newSeqNo);
            // ã€å·ç»Ÿä¸€ï¼ˆæœ¬éœ€æ±‚)】签到时取的号**直接写入 seq_num**(不再写 book_seq_num)。
            // ä¹‹åŽä»»ä½•环节都不再改这个号:分流、过号、召回、加急、装机均只改状态/工位。
            // å–号规则仍由 check_type.seq_rule å†³å®šï¼ˆé¢„约号 / ç­¾åˆ°åºå· / æ—¶æ®µå·ï¼‰ï¼Œè§ distributeSeqNoByRule。
            queueSaveReqVO.setSeqNum(newSeqNo);
            queueSaveReqVO.setIsVip(confirmReqVO.getIsVip());
            queueSaveReqVO.setPassed((byte) 0);
            queueSaveReqVO.setExpired((byte) 0);
            // éœ€æ±‚2:ISCB「是否床边」随队列一起落库,供屏幕/页面展示
            queueSaveReqVO.setIsBedside(appointment.getIsBedside());
            // HIS åŠ æ€¥æ ‡è®°ï¼ˆjjbz=J)→ æŽ’队加急,与人工插队共用 jump_flag。
            // queue è¡¨æŒ‰ "order by jump_flag desc, book_timeslot, create_time" å–号,置 1 å³ä¼˜å…ˆã€‚
            // ã€å·ç»Ÿä¸€åŽã€‘加急**不改排队号**(号在下面 setSeqNum æ—¶å·²ç»å®šæ­»ï¼‰ï¼Œ
            // è€Œæ˜¯é æŽ’序体现:分流取人、叫号取人、工作台/诊间屏/大屏列表都是
            // "jump_flag desc ä¼˜å…ˆ â†’ seq_num å‡åº",置 1 å³æŽ’到最前。
            if (urgent) {
                queueSaveReqVO.setJumpFlag((byte) 1);
                log.info("[appoitmentConfirm][HIS åŠ æ€¥æ‚£è€…ï¼ŒæŽ’é˜Ÿæ ‡è®°ä¸ºåŠ æ€¥] applyNo={} patId={}",
@@ -471,6 +602,40 @@
                    appointment.setTargetSiteCode(targetSite);
                }
                // é¢„约时间/时间段/诊室(S0201ECG æŠ¥æ–‡çš„ component2.observationRequest.ZDY,实测形如:
                //   ZDY={time=2026-10-06 08:00-08:30, area=..., roomname=6号诊室(厅右侧), roomcode=, jcxmcode=201652, jcxmname=平板运动试验}
                // åŽŸå®žçŽ°åªæŠŠå®ƒå½“"检查类型"用,没解析预约信息,导致这条流建出来的记录
                // book_date / book_timeslot / book_time / room_name å…¨ä¸ºç©ºï¼ˆå®žæµ‹ 211741、211962 å°±æ˜¯è¿™ç§åŠæ¡æ•°æ®ï¼‰ã€‚
                Object zdyRaw = c2.get("ZDY");
                if (zdyRaw == null) {
                    zdyRaw = c2.get("zdy");
                }
                if (zdyRaw instanceof Map) {
                    Map<String, Object> zdyMap = (Map<String, Object>) zdyRaw;
                    Object timeObj = zdyMap.get("time");
                    String timeRange = timeObj == null ? null : timeObj.toString();
                    Integer timeslot = parseBookTimeslot(timeRange);
                    if (timeslot != null) {
                        appointment.setBookTimeslot(timeslot);
                        // ç”±æ—¶é—´æ®µèµ·å§‹æŽ¨ç®—预约时刻(如 8000830 â†’ 08:00);book_date è§ä¸‹æ–¹ â‘£ çš„统一推导
                        LocalTime startTime = timeOfTimeslot(timeslot);
                        if (startTime != null) {
                            appointment.setBookTime(LocalDateTime.of(LocalDate.now(), startTime));
                        }
                    } else {
                        // æ—¶é—´æ®µè§£æžä¸å‡ºæ¥æ—¶ï¼Œé€€å›žç”¨ effectiveTime(报文里另一个预约时刻字段)
                        LocalDateTime bookTime = parseDateTime(digText(c2, "effectiveTime", "any", "value"));
                        if (bookTime != null) {
                            appointment.setBookTime(bookTime);
                            appointment.setBookDate(bookTime.toLocalDate());
                        }
                    }
                    Object roomName = zdyMap.get("roomname");
                    if (roomName != null && StringUtils.isNotEmpty(roomName.toString())) {
                        appointment.setRoomName(truncate(roomName.toString(), ROOM_NAME_MAX_LEN));
                    }
                }
                // åŒ»å˜±å·ï¼šcomponent2.observationRequest.id.item[root=2.16.156.10011.1.28].extension
                //
                // ä¾æ®ï¼ˆHIS《交互服务通用接口文档》):
@@ -491,15 +656,29 @@
                }
            }
            // åŒ»ç”Ÿå¼€å•æ—¶é—´
            Optional.ofNullable(dataMap.get("controlActProcess")).map(controlActProcess -> (Map<String, Object>) controlActProcess).map(controlActProcess -> (Map<String, Object>) controlActProcess.get("subject")).map(subject -> (Map<String, Object>) subject.get("observationRequest")).map(observationRequest -> (Map<String, Object>) observationRequest.get("author")).map(author -> (Map<String, Object>) author.get("time")).map(time -> (String) time.get("value")).map(str -> {
                try {
                    return new SimpleDateFormat("yyyyMMddHHmmss").parse(str);
                } catch (Exception e) {
                    e.printStackTrace();
                    return null;
            // åŒ»ç”Ÿå¼€å•时间(报文路径:controlActProcess.subject.observationRequest.author.time.value)
            //
            // ã€ä¸ºä»€ä¹ˆä¸ç”¨ä¸€è¡Œ Optional é“¾ç›´æŽ¥ parse】
            // åŽŸå®žçŽ°å†™æ­» `SimpleDateFormat("yyyyMMddHHmmss")`,格式不符只会 **静默丢弃**
            // ï¼ˆcatch é‡Œä»… printStackTrace,不落日志字段)——线上出现问题时无从定位。
            // çŽ°æ”¹ä¸ºæ˜¾å¼åˆ†æ”¯ + ç»“构化 WARN æ—¥å¿—,至少把「报文原值 / ç”³è¯·å•号」记下来。
            String doctorTimeRaw = digText(dataMap, "controlActProcess", "subject",
                    "observationRequest", "author", "time", "value");
            // è¯¥å¤„尚未执行到下方的 setApplyNo(原 L575),故单独取一次申请单号,仅用于日志定位
            String applyNoForLog = digText(dataMap, "controlActProcess", "subject",
                    "observationRequest", "id", "item", "extension");
            if (StringUtils.isNotEmpty(doctorTimeRaw)) {
                // åŽŸæ ·é‡‡çº³ HIS æ‰€å‘的值(不做取舍,避免误判反而写坏数据)。
                // è‹¥ä¸ºã€Œæ—¥æœŸå€¼ã€ï¼ˆæ—¶åˆ†ç§’=000000,实测约 61%),parseHisDoctorTime ä¼šæ‰“ WARN
                // è®°å½•原始报文值 + ç”³è¯·å•号,便于向 HIS ä¾§åé¦ˆï¼›å†™å…¥è¡Œä¸ºä¸Žæ”¹åŠ¨å‰ä¸€è‡´ã€‚
                java.util.Date parsedDoctorTime = parseHisDoctorTime(doctorTimeRaw, applyNoForLog);
                if (parsedDoctorTime != null) {
                    appointment.setDoctorTime(parsedDoctorTime);
                }
            }).filter(Objects::nonNull).ifPresent(appointment::setDoctorTime);
            } else {
                log.warn("[handleAppointmentCreate][报文缺少开单时间 author/time/value,doctor_time å°†ä¸ºç©º] applyNo={}",
                        applyNoForLog);
            }
            //设置开单医生姓名
            Optional.ofNullable(dataMap.get("controlActProcess")).map(controlActProcess -> (Map<String, Object>) controlActProcess).map(controlActProcess -> (Map<String, Object>) controlActProcess.get("subject")).map(subject -> (Map<String, Object>) subject.get("observationRequest")).map(observationRequest -> (Map<String, Object>) observationRequest.get("author")).map(author -> (Map<String, Object>) author.get("assignedEntity")).map(assignedEntity -> (Map<String, Object>) assignedEntity.get("assignedPerson")).map(assignedPerson -> (Map<String, Object>) assignedPerson.get("name")).map(name -> (Map<String, Object>) name.get("item")).map(item -> (Map<String, Object>) item.get("part")).map(part -> (String) part.get("value")).ifPresent(appointment::setDoctor);
@@ -563,6 +742,33 @@
            // è®¾ç½®ç”³è¯·å•号
            Optional.ofNullable(dataMap.get("controlActProcess")).map(controlActProcess -> (Map<String, Object>) controlActProcess).map(controlActProcess -> (Map<String, Object>) controlActProcess.get("subject")).map(subject -> (Map<String, Object>) subject.get("observationRequest")).map(observationRequest -> (Map<String, Object>) observationRequest.get("id")).map(id -> (Map<String, Object>) id.get("item")).map(item -> (String) item.get("extension")).ifPresent(appointment::setApplyNo);
            // é¢„约日期:报文里的预约时间段 ZDY.time å½¢å¦‚ "2026-10-06 08:00-08:30"。
            // ä¸Šé¢è§£æžæ—¶é—´æ®µæ—¶åªæ‹¿åˆ°äº†æ—¶åˆ†ï¼ˆç”¨äºŽ book_timeslot/book_time),这里补日期:
            // è‹¥åªæ‹¿åˆ°æ—¶é—´æ®µè€Œæ—¥æœŸä¸ºç©ºï¼Œå°±ä»Ž ZDY.time çš„æ—¥æœŸéƒ¨åˆ†è¡¥ä¸Šï¼Œå¹¶æ®æ­¤ä¿®æ­£ book_time çš„æ—¥æœŸã€‚
            String zdyTimeRange = digText(dataMap, "controlActProcess", "subject", "observationRequest",
                    "component2", "observationRequest", "ZDY", "time");
            if (StringUtils.isEmpty(zdyTimeRange)) {
                zdyTimeRange = digText(dataMap, "controlActProcess", "subject", "observationRequest",
                        "component2", "observationRequest", "zdy", "time");
            }
            if (StringUtils.isNotEmpty(zdyTimeRange)) {
                String[] parts = zdyTimeRange.trim().split("\\s+");
                if (parts.length >= 1) {
                    LocalDateTime parsedDate = parseDateTime(parts[0] + " 00:00");
                    if (parsedDate != null) {
                        LocalDate zdyBookDate = parsedDate.toLocalDate();
                        if (appointment.getBookDate() == null) {
                            appointment.setBookDate(zdyBookDate);
                        }
                        // book_time ä¹‹å‰åªæ˜¯ç”¨"今天"占位,这里换成真实预约日期
                        if (appointment.getBookTime() != null) {
                            appointment.setBookTime(appointment.getBookTime().withYear(zdyBookDate.getYear())
                                    .withMonth(zdyBookDate.getMonthValue()).withDayOfMonth(zdyBookDate.getDayOfMonth()));
                        }
                    }
                }
            }
            //设置检查类型
//            String checkType = Optional.ofNullable(dataMap.get("controlActProcess")).map(controlActProcess -> (Map<String, Object>) controlActProcess).map(controlActProcess -> (Map<String, Object>) controlActProcess.get("component2")).map(component2 -> (Map<String, Object>) component2.get("observationRequest")).map(observationRequest -> (Map<String, Object>) observationRequest.get("code")).map(code -> (String) code.get("code")).orElseThrow(() -> exception(APPOINTMENT_CREATE_FAIL));
            String checkType = Optional.ofNullable(dataMap.get("controlActProcess")).map(controlActProcess -> (Map<String, Object>) controlActProcess).map(controlActProcess -> (Map<String, Object>) controlActProcess.get("subject")).map(subject -> (Map<String, Object>) subject.get("observationRequest")).map(observationRequest -> (Map<String, Object>) observationRequest.get("component2")).map(component2 -> (Map<String, Object>) component2.get("observationRequest")).map(observationRequest -> (Map<String, Object>) observationRequest.get("code")).map(code -> (String) code.get("code")).orElseThrow(() -> exception(APPOINTMENT_CREATE_FAIL));
@@ -582,7 +788,17 @@
                if (value != null) {
                    appointment.setBookCheckType(value);
                } else {
                    log.warn("[handleAppointmentCreate][体检患者检查项目名称无法映射] checkName={}", checkName);
                    // åç§°æ˜ å°„不到就退回「按 HIS æ£€æŸ¥ä»£ç æ˜ å°„」:
                    // å®žæµ‹è¿åŠ¨è¯•éªŒåœ¨ HIS ä¾§æ”¹è¿‡åï¼ˆå¹³æ¿è¿åŠ¨å¿ƒç”µ â†’ å¹³æ¿è¿åŠ¨è¯•éªŒï¼‰ï¼Œ
                    // åªè®¤åç§°ä¼šè®© book_check_type è½ç©ºï¼›è€ŒæŠ¥æ–‡é‡Œçš„ code(如 201652)是稳定的。
                    Integer fallback = resolveBookCheckTypeFromComponent2(dataMap);
                    if (fallback != null) {
                        appointment.setBookCheckType(fallback);
                        log.info("[handleAppointmentCreate][体检患者名称未映射,已按检查代码兜底] checkName={} bookCheckType={}",
                                checkName, fallback);
                    } else {
                        log.warn("[handleAppointmentCreate][体检患者检查项目名称与代码均无法映射] checkName={}", checkName);
                    }
                }
            } else {
                Integer value = resolveBookCheckTypeFromComponent2(dataMap);
@@ -627,13 +843,12 @@
            if (appointmentDO != null) {
                // ä¿ç•™åŽŸè®°å½•ä¸­æœ¬æ¬¡æŠ¥æ–‡æœªæä¾›çš„å­—æ®µï¼Œé¿å…è¢«ç©ºå€¼è¦†ç›–
                mergeUnsetFields(appointment, appointmentDO);
                // åˆå¹¶ç»“果要回到 saveReqVO ä¸Šï¼Œå¦åˆ™æ›´æ–°/插入用的还是"未合并"的报文值
                saveReqVO = BeanUtils.toBean(appointment, AppointmentSaveReqVO.class);
                saveReqVO.setId(appointmentDO.getId());
                updateAppointment(saveReqVO);
                return appointmentDO.getId();
            }
            return createAppointment(saveReqVO);
            // äº¤ç»™åŽŸå­è½åº“ï¼šä¸å­˜åœ¨åˆ™æ’å…¥ï¼Œå·²å­˜åœ¨åˆ™æ›´æ–°ï¼›å¹¶å‘é‡å¤æ’å…¥ä¼šè¢«å”¯ä¸€ç´¢å¼•æ‹¦ä¸‹å¹¶è½¬ä¸ºæ›´æ–°ï¼Œ
            // ä¸ä¼šå†å‡ºçް"同一 apply_no ä¸¤æ¡è®°å½•"(原实现在这里"查一次再插",并发下必然漏)。
            return atomicSaveByApplyNo(saveReqVO);
        } catch (Exception e) {
            log.error("[handleAppointmentCreate][处理预约创建失败 dataMap({})]", dataMap, e);
            throw exception(APPOINTMENT_CREATE_FAIL);
@@ -904,12 +1119,18 @@
        // 3. æœ¬åœ°è®°å½•匹配
        AppointmentDO existingAppointment = appointmentMapper.getByApplyNo(applyNo);
        if (existingAppointment != null) {
            existingAppointment.setStatus(status);
            if (HisCheckStatusEnum.REGISTERED.getCode().toString().equals(status)) {
                existingAppointment.setRegisterDate(new Date());
            }
            AppointmentSaveReqVO updateReqVO = BeanUtils.toBean(existingAppointment, AppointmentSaveReqVO.class);
            updateAppointment(updateReqVO);
            // çŠ¶æ€æµåªå…è®¸æ”¹ status / register_date:
            // åŽŸå®žçŽ°æŠŠæŸ¥å‡ºæ¥çš„æ•´è¡Œå¯¹è±¡å† updateById å†™å›žï¼ˆè¯»-改-写),会把 book_date / book_check_type
            // ç­‰å­—段按旧快照覆盖。实测 2026-10-05 07:58,一笔预约刚被 S050401 å†™å¥½é¢„约时间与检查类型,
            // 3.6 ç§’后 S0405 çŠ¶æ€æµåˆæŠŠæ•´è¡Œå†™å›žä¸€æ¬¡ï¼Œcheck_type è¢«å†™æˆäº†ç©ºã€‚
            // è¿™é‡Œæ”¹æˆåªæ›´æ–°çŠ¶æ€ï¼ˆregister_date ä»…在状态=登记时写入,由 updateStatusByApplyNo å¤„理)。
            Date registerDate = HisCheckStatusEnum.REGISTERED.getCode().toString().equals(status)
                    ? new Date() : null;
            Integer updated = registerDate != null
                    ? appointmentMapper.updateStatusByApplyNo(applyNo, status, registerDate)
                    : appointmentMapper.updateStatusOnlyByApplyNo(applyNo, status);
            log.info("[handleAppointmentStateUpdate][仅更新状态] applyNo={} status={} å½±å“è¡Œæ•°={}",
                    applyNo, status, updated);
            return;
        }
@@ -1062,6 +1283,121 @@
        return o == null ? null : o.toString();
    }
    /**
     * ä»Ž S050401/S050501(检查预约)报文的 {@code ZDY} èŠ‚ç‚¹è§£æžæœ¬åœ° {@code book_check_type}。
     *
     * <h3>为什么需要这个方法</h3>
     * {@code S050401/S050501} æŠ¥æ–‡çš„æ£€æŸ¥é¡¹ç›®æ”¾åœ¨ {@code ZDY.jcxmcode / ZDY.jcxmname}
     * ï¼ˆå½¢å¦‚ {@code jcxmcode=201652, jcxmname=平板运动试验}),
     * ä¸Ž {@code S0201ECG} çš„ {@code component2.observationRequest.code.code} **不是同一路径**。
     * åŽŸå…ˆåªæœ‰ S0201ECG é‚£æ¡è·¯å¾„解析检查类型,于是「S050401 å…ˆåˆ°ã€S0201ECG æœªåˆ°ã€æ—¶èµ°
     * {@link #insertAppointmentFromCheckAppointment} å®¹é”™æ–°å¢žï¼Œ
     * {@code book_check_type} ä¼š**永远为空** â€”— å®žæµ‹ 2026-09-29~10-09 å…± 279 æ¡
     * ï¼ˆå¦‚ {@code apply_no=8826092420371106},报文里 {@code jcxmcode=201652} æ˜Žæ˜Žå­˜åœ¨ï¼‰ã€‚
     *
     * <h3>解析顺序</h3>
     * <ol>
     *   <li><b>名称优先</b>:{@code jcxmname} â†’ {@link HisCheckNameEnum#getByName}。
     *       è¯¥æžšä¸¾å·²å«ã€Œå¹³æ¿è¿åŠ¨è¯•éªŒã€ç­‰ HIS æ”¹ååˆ«åï¼Œä¸”内置规范化匹配;</li>
     *   <li><b>代码兜底</b>:{@code jcxmcode} â†’ {@link HisCheckCodeEnum},
     *       å…¼å®¹å¸¦/不带 {@code Y} å‰ç¼€ã€‚</li>
     * </ol>
     *
     * <h3>多值处理</h3>
     * å®žæµ‹è¯¥å­—段可能为逗号分隔多值(如 S0405 çš„ {@code jcxmcode=1080,3016,3014})。
     * ç­–略:**按顺序取第一个能映射成功的值**;先整体试探(单值场景),再逐段试探。
     *
     * @param zdy æŠ¥æ–‡ä¸­çš„ {@code ZDY} èŠ‚ç‚¹ï¼Œå¯ä¸º null
     * @return æœ¬åœ°æ£€æŸ¥ç±»åž‹å€¼ï¼ˆå¦‚ 400);无法解析时返回 null(调用方保持原值不写)
     */
    private Integer resolveBookCheckTypeFromZdy(Map<String, Object> zdy, String applyNo) {
        if (zdy == null) {
            log.warn("[resolveBookCheckTypeFromZdy][报文无 ZDY èŠ‚ç‚¹ï¼Œæ— æ³•è§£æžæ£€æŸ¥ç±»åž‹] applyNo={}", applyNo);
            return null;
        }
        String jcxmName = extractZdyValueFromMap(zdy, "jcxmname");
        String jcxmCode = extractZdyValueFromMap(zdy, "jcxmcode");
        // â‘  åç§°æ˜ å°„(优先:可读,且枚举已覆盖 HIS æ”¹åä¸Žè§„范化差异)
        Integer byName = mapCheckNameToType(jcxmName);
        if (byName != null) {
            return byName;
        }
        // â‘¡ ä»£ç æ˜ å°„(兜底:实测 HIS ä¾§æ”¹è¿‡åï¼Œä»£ç æ›´ç¨³å®šï¼‰
        Integer byCode = mapCheckCodeToType(jcxmCode);
        if (byCode != null) {
            log.info("[resolveBookCheckTypeFromZdy][名称未映射,已按检查代码兜底] "
                            + "applyNo={} jcxmname={} jcxmcode={} bookCheckType={}",
                    applyNo, jcxmName, jcxmCode, byCode);
            return byCode;
        }
        log.warn("[resolveBookCheckTypeFromZdy][检查项目名称与代码均无法映射,book_check_type ä¿æŒåŽŸå€¼] "
                + "applyNo={} jcxmname={} jcxmcode={}", applyNo, jcxmName, jcxmCode);
        return null;
    }
    /** æ£€æŸ¥é¡¹ç›®åç§° â†’ æœ¬åœ°æ£€æŸ¥ç±»åž‹ï¼ˆæ”¯æŒé€—号分隔多值,取第一个可映射的) */
    private Integer mapCheckNameToType(String rawName) {
        if (StringUtils.isEmpty(rawName)) {
            return null;
        }
        for (String candidate : splitMultiValue(rawName)) {
            // åŽ»ç©ºç™½ï¼šHIS æŠ¥æ–‡é‡Œå¶è§ã€Œå¹³æ¿è¿åŠ¨è¯•éªŒ ã€è¿™ç±»å°¾éšç©ºæ ¼
            String mapped = HisCheckNameEnum.getByName(candidate.replaceAll("\\s+", ""));
            Integer value = parseIntegerQuietly(mapped);
            if (value != null) {
                return value;
            }
        }
        return null;
    }
    /** HIS æ£€æŸ¥ä»£ç  â†’ æœ¬åœ°æ£€æŸ¥ç±»åž‹ï¼ˆæ”¯æŒé€—号分隔多值;兼容带/不带 Y å‰ç¼€ï¼‰ */
    private Integer mapCheckCodeToType(String rawCode) {
        if (StringUtils.isEmpty(rawCode)) {
            return null;
        }
        for (String candidate : splitMultiValue(rawCode)) {
            String code = candidate.replaceAll("\\s+", "");
            if (code.isEmpty()) {
                continue;
            }
            // å…¼å®¹ä¸¤ç§æŠ¥æ–‡å½¢æ€ï¼šå¸¦å‰ç¼€ï¼ˆY1202065)与不带前缀(201652)
            String[] tries = code.length() > 1
                    ? new String[]{code, code.substring(1)}
                    : new String[]{code};
            for (String t : tries) {
                Integer value = parseIntegerQuietly(HisCheckCodeEnum.getByCode(t));
                if (value != null) {
                    return value;
                }
            }
        }
        return null;
    }
    /**
     * æ‹†åˆ†ã€Œå¯èƒ½çš„多值」字段。
     * <p>
     * å…ˆè¿”回**整体**(覆盖单值场景,也避免把名称里本就含逗号的项拆坏),
     * å†è¿”回按 {@code ,} / {@code ï¼Œ} æ‹†å‡ºçš„各段。
     */
    private List<String> splitMultiValue(String raw) {
        List<String> result = new ArrayList<>();
        if (StringUtils.isEmpty(raw)) {
            return result;
        }
        result.add(raw);
        for (String part : raw.split("[,,]")) {
            String p = part == null ? "" : part.trim();
            if (!p.isEmpty() && !p.equals(raw)) {
                result.add(p);
            }
        }
        return result;
    }
    /** {@code appointment.remark} åˆ—宽上限(见 08_fix_remark_length.sql,当前 varchar(2000)) */
    private static final int REMARK_MAX_LEN = 2000;
    /** {@code appointment.room_name} åˆ—宽上限 */
@@ -1139,6 +1475,27 @@
    /** HIS æŠ¥æ–‡ä¸­ã€ŒåŒ»å˜±å·/医嘱ID」的 id root(见《交互服务通用接口文档》6.1 / 6.4 / 6.5) */
    private static final String ORDER_NO_ROOT = "2.16.156.10011.1.28";
    /**
     * HIS「开单时间」({@code author/time/value}) çš„æ—¶é—´æ ¼å¼ã€‚
     * æ³¨æ„ï¼š{@link SimpleDateFormat} **不是线程安全**的,故这里只保存 pattern å­—符串,
     * æ¯æ¬¡éƒ½æ–°å»ºå®žä¾‹ï¼ˆè§ {@link #parseHisDoctorTime})。
     */
    private static final String HIS_DOCTOR_TIME_PATTERN = "yyyyMMddHHmmss";
    /**
     * åˆ¤æ–­ HIS å¼€å•时间是否为「日期值」(形如 {@code 20261001000000},时分秒全 0)。
     *
     * <p>用 {@code endsWith} è€Œä¸æ˜¯æ­£åˆ™ï¼šè¯­ä¹‰ç›´ç™½ä¸”无转义坑
     * ï¼ˆæ›¾å†™è¿‡ {@code value.matches("\\d{6}000000")},因转义层级出错导致永不匹配)。
     */
    private boolean isHisTimeOnlyDate(String value) {
        if (value == null || value.length() != 14) {
            return false;
        }
        // åŽ 6 ä½ï¼ˆHHmmss)全为 '0' å³ä¸ºã€Œæ—¥æœŸå€¼ã€å½¢æ€
        return value.endsWith("000000");
    }
    /**
     * åœ¨ HL7 çš„ {@code id} èŠ‚ç‚¹ä¸­ï¼ŒæŒ‰ {@code root} åŒ¹é…å¹¶å–出 {@code extension}。
@@ -1442,6 +1799,15 @@
            if (roomName != null && StringUtils.isNotEmpty(roomName.toString())) {
                appointment.setRoomName(truncate(roomName.toString(), ROOM_NAME_MAX_LEN));
            }
            // æ£€æŸ¥ç±»åž‹ï¼š**仅在为空时补**
            //   ç­–ç•¥ (a):不覆盖已有值,避免改坏数据(例如该单曾由 S0201ECG æ­£ç¡®å†™å…¥è¿‡ç±»åž‹ï¼‰ã€‚
            //   ä¸Žå®¹é”™æ–°å¢žåˆ†æ”¯ä¿æŒåŒä¸€é€»è¾‘。
            if (appointment.getBookCheckType() == null) {
                Integer checkTypeValue = resolveBookCheckTypeFromZdy(zdy, applyNo);
                if (checkTypeValue != null) {
                    appointment.setBookCheckType(checkTypeValue);
                }
            }
        }
        // é¢„约排序号(patient.id.item[4])
        Integer seqNum = extractBookSeqNum(actAppointment);
@@ -1548,6 +1914,23 @@
        appointment.setBookSrc(bookSrcValue);
        appointment.setStatus(status);
        // æ‚£è€…来源(pat_src):与 S0201ECG ä¿æŒåŒä¸€åšæ³• â€”— ç›´æŽ¥å–报文中的患者类型代码。
        //
        // âš ï¸ å·²çŸ¥é£Žé™©ï¼ˆä¿ç•™çŽ°çŠ¶ï¼Œä¸åœ¨æœ¬æ¬¡æ”¹åŠ¨èŒƒå›´å†…ï¼‰ï¼š
        //    å®žæµ‹ S050401 æŠ¥æ–‡å¯¹æœ¬æ¡ä½“检单给出的是 code=3 / displayName=体检,
        //    è€Œæœ¬åœ°å­—典(ecg_patient_source)与数据分布都表明「体检 = 4、住院 = 3」:
        //    å…¨åº“ pat_src=4 çš„ 55,729 æ¡ç§‘室均为「健康管理中心」,88 å‰ç¼€ï¼ˆä½“检)单 55,874 æ¡ä¸º pat_src=4。
        //    å³æŠ¥æ–‡ code ä¸Žæœ¬åœ°å­—典编码并不一致。此处**刻意沿用报文原值**以与 S0201ECG è¡Œä¸ºä¸€è‡´ï¼Œ
        //    å¦‚需按名称纠正(体检→4),应统一在两处一起改,避免两条流写出不同值。
        Map<String, Object> codeNode = (Map<String, Object>) actAppointment.get("code");
        Integer patSrcValue = codeNode == null ? null : parseIntegerQuietly(str(codeNode.get("code")));
        if (patSrcValue != null) {
            appointment.setPatSrc(patSrcValue);
        } else {
            log.warn("[insertAppointmentFromCheckAppointment][报文缺患者类型(actAppointment.code.code),"
                    + "pat_src å°†ä¸ºç©º] applyNo={} code={}", applyNo, codeNode);
        }
        Map<String, Object> zdy = (Map<String, Object>) actAppointment.get("ZDY");
        // ZDY ä¸­çš„ remark(注意事项)与 roomname(房间名称)已随表新增列落库;
        // area(位置)与 roomcode(房间编码)目前仍无对应列,暂不保存。
@@ -1559,6 +1942,18 @@
            Object roomName = zdy.get("roomname");
            if (roomName != null && StringUtils.isNotEmpty(roomName.toString())) {
                appointment.setRoomName(truncate(roomName.toString(), ROOM_NAME_MAX_LEN));
            }
            // æ£€æŸ¥ç±»åž‹ï¼šæŠ¥æ–‡é‡Œçš„ ZDY.jcxmcode / ZDY.jcxmname
            //
            // ã€ä¿®å¤ã€‘原先此处**完全没有**解析检查类型,导致「S050401 å…ˆåˆ°ã€ä¸”å°šæ—  S0201ECG è®°å½•」
            // æ—¶å®¹é”™æ–°å¢žå‡ºæ¥çš„ appointment.book_check_type æ°¸è¿œä¸ºç©ºã€‚
            // å®žæµ‹ 2026-09-29~10-09 å…± 279 æ¡ï¼ˆå¦‚ apply_no=8826092420371106,
            // æŠ¥æ–‡ ZDY é‡Œ jcxmcode=201652 / jcxmname=平板运动试验 æ˜Žæ˜Žå­˜åœ¨ï¼‰ã€‚
            if (appointment.getBookCheckType() == null) {
                Integer checkTypeValue = resolveBookCheckTypeFromZdy(zdy, applyNo);
                if (checkTypeValue != null) {
                    appointment.setBookCheckType(checkTypeValue);
                }
            }
        }
        // é¢„约排序号(patient.id.item[4])
@@ -1584,6 +1979,17 @@
                }
                if (patIdItems.size() > 3) {
                    appointment.setHospitalNo((String) ((Map<String, Object>) patIdItems.get(3)).get("extension"));
                }
                // å°±è¯Šæµæ°´å·ï¼ˆepisode_id):patient.id.item[5]
                //
                // ä¸Ž S0201ECG åŒæºï¼ˆé‚£è¾¹å–自 placerGroup.componentOf1.encounter.id.item[1],
                // å®žæµ‹ä¸¤å¤„值相同:本条均为 882609242037)。
                // ã€ä¿®å¤ã€‘原先容错新增**完全没有**写入就诊流水号,导致该字段为空。
                if (patIdItems.size() > 5) {
                    String episodeId = (String) ((Map<String, Object>) patIdItems.get(5)).get("extension");
                    if (StringUtils.isNotEmpty(episodeId)) {
                        appointment.setEpisodeId(episodeId);
                    }
                }
                // é¢„约排序号已在上方通过 extractBookSeqNum ç»Ÿä¸€èµ‹å€¼ï¼Œæ­¤å¤„不再重复处理
                // æ‚£è€…姓名
@@ -1628,8 +2034,84 @@
            log.warn("[insertAppointmentFromCheckAppointment][报文未提供患者性别,按未知(0)写入] applyNo={}", applyNo);
            appointment.setPatGender(UNKNOWN_GENDER);
        }
        // æ‚£è€…固有属性回填(生日 / ç”µè¯ / èº«ä»½è¯ / åœ°å€ï¼‰
        //
        // ã€ä¸ºä»€ä¹ˆéœ€è¦ã€‘S050401 æŠ¥æ–‡çš„ patient.patientPerson **只带姓名**,
        // ä¸å«ç”Ÿæ—¥/电话/身份证(S0201ECG æœ‰ã€S0405 ä¹Ÿæœ‰ï¼Œä½†è¿™ä¸¤æ¡æŠ¥æ–‡åœ¨æœ¬åœºæ™¯æ²¡æœ‰å…ˆåˆ°ï¼‰ï¼Œ
        // äºŽæ˜¯å®¹é”™æ–°å¢žå‡ºæ¥çš„记录这些字段全为空,与 S0201ECG å»ºçš„单相比"缺字段"。
        //
        // ã€ä¸ºä»€ä¹ˆè¿™æ ·è¡¥æ˜¯å‡†ç¡®çš„】这些是**患者固有属性**(不是流水属性),
        // åŒä¸€ pat_id çš„历史记录里必然一致(全库填充率 99%)。
        // æ³¨æ„ï¼šå¼€å•医生/开单时间属流水属性,**刻意不继承**(那属于推断,可能张冠李戴)。
        inheritPatientDemographics(appointment, applyNo);
        AppointmentSaveReqVO saveReqVO = BeanUtils.toBean(appointment, AppointmentSaveReqVO.class);
        createAppointment(saveReqVO);
    }
    /**
     * å®¹é”™æ–°å¢žæ—¶ï¼Œä»Žè¯¥æ‚£è€…最近一条记录继承**患者固有属性**。
     *
     * <p>只补「本次为空」的字段,绝不覆盖报文已提供的值。
     * ç»§æ‰¿å¤±è´¥ï¼ˆæ— åŽ†å²è®°å½• / æŸ¥è¯¢å¼‚常)不影响落库,仅记日志。</p>
     *
     * <p><b>刻意不继承</b>的字段:{@code doctor}/{@code doctor_code}/{@code doctor_time}
     * ï¼ˆå¼€å•医生与开单时间属流水属性,报文未提供时只能推断,实测同患者同日一致率 90%,
     * å­˜åœ¨å¼ å† æŽæˆ´é£Žé™©ï¼Œæ•…按业务确认保持为空)、{@code register_date}
     * ï¼ˆåªåº”在 HIS æŠ¥ã€Œæ£€æŸ¥ç™»è®°ã€çŠ¶æ€æ—¶å†™å…¥ï¼‰ã€‚</p>
     *
     * @param target  æœ¬æ¬¡å®¹é”™æ–°å¢žçš„对象(就地补全)
     * @param applyNo ç”³è¯·å•号,仅用于日志
     */
    private void inheritPatientDemographics(AppointmentDO target, String applyNo) {
        if (StringUtils.isEmpty(target.getPatId())) {
            return;
        }
        // ä¸‰é¡¹å…³é”®äººå£å­¦ä¿¡æ¯æŠ¥æ–‡éƒ½ç»™äº†å°±æ— éœ€ç»§æ‰¿ï¼ˆæŠ¥æ–‡å€¼ä¼˜å…ˆï¼‰
        if (target.getPatBirthday() != null
                && StringUtils.isNotEmpty(target.getPatIdentityId())
                && StringUtils.isNotEmpty(target.getPatMobile())) {
            return;
        }
        AppointmentDO sibling;
        try {
            sibling = appointmentMapper.getLatestByPatId(target.getPatId());
        } catch (Exception e) {
            log.warn("[inheritPatientDemographics][查询同患者历史记录失败,跳过继承] applyNo={} patId={} err={}",
                    applyNo, target.getPatId(), e.getMessage());
            return;
        }
        if (sibling == null) {
            log.warn("[inheritPatientDemographics][该患者无历史记录可继承,患者信息将不完整] "
                    + "applyNo={} patId={}", applyNo, target.getPatId());
            return;
        }
        StringBuilder inherited = new StringBuilder();
        if (target.getPatBirthday() == null && sibling.getPatBirthday() != null) {
            target.setPatBirthday(sibling.getPatBirthday());
            inherited.append("pat_birthday ");
        }
        if (StringUtils.isEmpty(target.getPatIdentityId()) && StringUtils.isNotEmpty(sibling.getPatIdentityId())) {
            target.setPatIdentityId(sibling.getPatIdentityId());
            inherited.append("pat_identity_id ");
        }
        if (StringUtils.isEmpty(target.getPatMobile()) && StringUtils.isNotEmpty(sibling.getPatMobile())) {
            target.setPatMobile(sibling.getPatMobile());
            inherited.append("pat_mobile ");
        }
        if (StringUtils.isEmpty(target.getPatPhone()) && StringUtils.isNotEmpty(sibling.getPatPhone())) {
            target.setPatPhone(sibling.getPatPhone());
            inherited.append("pat_phone ");
        }
        if (StringUtils.isEmpty(target.getPatAddr()) && StringUtils.isNotEmpty(sibling.getPatAddr())) {
            target.setPatAddr(sibling.getPatAddr());
            inherited.append("pat_addr ");
        }
        if (inherited.length() > 0) {
            log.info("[inheritPatientDemographics][S050401 æŠ¥æ–‡ä¸å«æ‚£è€…人口学信息,"
                            + "已从同患者最近记录继承] applyNo={} patId={} fromApplyNo={} fields=[{}]",
                    applyNo, target.getPatId(), sibling.getApplyNo(), inherited.toString().trim());
        }
    }
    /** è§£æž {@code yyyyMMddHHmmss} æ—¶é—´ï¼Œå¤±è´¥æˆ–为空返回 null(不抛异常) */
@@ -1641,6 +2123,96 @@
            return LocalDateTime.parse(value.trim(), DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
        } catch (Exception e) {
            log.warn("[parseDateTime][时间格式非法,忽略] value={}", value);
            return null;
        }
    }
    /**
     * è§£æž HIS æŠ¥æ–‡ä¸­çš„「开单时间」({@code author/time/value})。
     *
     * <h3>背景(实测结论,2026-10 æŽ’查)</h3>
     * åº“中约 <b>61%</b> çš„ {@code appointment.doctor_time} ä¸º {@code yyyy-MM-dd 00:00:00}。
     * ç»æ ¸æŸ¥**不是**本项目的格式化缺陷:
     * <ul>
     *   <li>列类型是 {@code datetime},写入的是 {@code java.util.Date},保留时分秒;</li>
     *   <li>真实报文样例中该字段形如 {@code 20260917005255}(带时分秒);</li>
     *   <li>有真实时刻的记录呈正常工作时段分布(06:00→08:00 ä¸ºé«˜å³°ï¼‰ï¼Œ
     *       è€Œè½åœ¨å¼€è¯Šå‰çš„ 00:00-05:59 çš„ä»… 15 æ¡ â€”— è¯´æ˜Žè¯¥æ—¶æ®µæœ¬å°±å‡ ä¹Žä¸å¼€å•ï¼›</li>
     *   <li>按就诊类别看:门诊({@code book_src=1}) 99.6%、住院({@code book_src=2}) 90.5%
     *       ä¸º 0 ç‚¹ï¼Œä¸”该现象贯穿 2026 å…¨å¹´ï¼ˆä¸Ž 2026-09-27 çš„ {@code register_date}
     *       æˆªæ–­ç¼ºé™·æ— å…³ï¼‰ã€‚</li>
     * </ul>
     * â‡’ ç»“论:**是 HIS ä¾§æŠŠè¯¥å­—段的时分秒发成了 {@code 000000}**(很可能是「只精确到日」
     * æˆ–补录/批量回传的医嘱)。
     *
     * <h3>本方法做什么</h3>
     * <ul>
     *   <li>按 {@code yyyyMMddHHmmss} è§£æžï¼›</li>
     *   <li>识别「时分秒 = 000000」的**日期值**,打出带**原始报文值 + ç”³è¯·å•号**的 WARN
     *       â€”— åŽŸå®žçŽ°åª {@code printStackTrace()},出问题时无从定位;</li>
     *   <li><b>仍然写入</b>(保留日期部分):因为「距今天数」「按日期排序/筛选」都用日期,
     *       å†™å…¥ NULL ä¼šè®©è¿™äº›åŠŸèƒ½é€€åŒ–ï¼Œä¸”è¯¥å€¼ç¡®ä¸º HIS æ‰€å‘、原样保存更可追溯。</li>
     * </ul>
     *
     * @param rawValue HIS æŠ¥æ–‡åŽŸå€¼ï¼ˆå½¢å¦‚ {@code 20260917005255})
     * @param applyNo  ç”³è¯·å•号,仅用于日志定位
     * @return è§£æžåŽçš„æ—¶é—´ï¼›æ ¼å¼éžæ³•时返回 {@code null}(不写库)
     */
    private java.util.Date parseHisDoctorTime(String rawValue, String applyNo) {
        String v = rawValue == null ? null : rawValue.trim();
        if (StringUtils.isEmpty(v)) {
            return null;
        }
        if (!v.matches("\\d{14}")) {
            // å®žæµ‹ HIS æ–‡æ¡£æ ·ä¾‹ä¸­å­˜åœ¨ 12 ä½ï¼ˆyyyyMMddHHmm)甚至空值,统一记 WARN ä¾¿äºŽå‘ HIS åé¦ˆ
            log.warn("[parseHisDoctorTime][开单时间格式非 14 ä½ï¼Œæ— æ³•解析,doctor_time å°†ä¸ºç©º] "
                    + "applyNo={} raw={} length={}", applyNo, v, v.length());
            return null;
        }
        try {
            // âš ï¸ å¿…é¡» setLenient(false):SimpleDateFormat é»˜è®¤å®½æ¾ï¼Œ
            // ä¼šæŠŠéžæ³•日期静默滚成合法值(如 13 æœˆ â†’ æ¬¡å¹´ 1 æœˆï¼‰ï¼Œå¯¼è‡´è„æ•°æ®å…¥åº“。
            // å®žæµ‹ï¼šlenient ä¸‹ "20261301000000" è¢«è§£æžæˆ 2027-01-01 ä¸”不报错。
            SimpleDateFormat sdf = new SimpleDateFormat(HIS_DOCTOR_TIME_PATTERN);
            sdf.setLenient(false);
            java.util.Date parsed = sdf.parse(v);
            if (isHisTimeOnlyDate(v)) {
                log.warn("[parseHisDoctorTime][HIS å¼€å•时间时分秒为 000000(日期值),"
                        + "doctor_time å°†ä¿å­˜ä¸ºå½“æ—¥ 00:00:00 â€”— è¯¥å€¼ç”± HIS æŠ¥æ–‡åŽŸæ ·è€Œæ¥] "
                        + "applyNo={} raw={}", applyNo, v);
            }
            return parsed;
        } catch (Exception e) {
            log.warn("[parseHisDoctorTime][开单时间解析失败,doctor_time å°†ä¸ºç©º] applyNo={} raw={} err={}",
                    applyNo, v, e.getMessage());
            return null;
        }
    }
    /**
     * ç”± {@code book_timeslot}(hhmmhhmm,如 8000830=08:00-08:30、13301400=13:30-14:00)取预约时刻(只含时分)。
     * <p>用来给 S0201ECG è¿™æ¡æµè¡¥ {@code book_time}:原实现整条流不写预约时间,
     * å»ºå‡ºæ¥çš„记录 book_date/book_timeslot/book_time å…¨ç©ºï¼ˆå®žæµ‹ 211741、211962)。</p>
     */
    private LocalTime timeOfTimeslot(Integer timeslot) {
        if (timeslot == null) {
            return null;
        }
        try {
            String s = String.valueOf(timeslot);
            if (s.length() < 5) {
                return null;
            }
            String start = s.substring(0, s.length() - 4);   // å‰ä¸€æ®µ = å¼€å§‹æ—¶é—´ hhmm
            int hhmm = Integer.parseInt(start);
            int hour = hhmm / 100;
            int minute = hhmm % 100;
            if (hour > 23 || minute > 59) {
                return null;
            }
            return LocalTime.of(hour, minute);
        } catch (Exception e) {
            log.warn("[timeOfTimeslot][时间段解析失败,忽略] timeslot={}", timeslot);
            return null;
        }
    }
@@ -1680,6 +2252,38 @@
        }
    }
    /**
     * ã€æœ¬éœ€æ±‚:紧急患者不排队直接检查】按「申请单号 / å°±è¯Šå· / æ‚£è€…编号」查近一个月的预约记录。
     * <p>
     * çª—口 [今天-30 å¤©, ä»Šå¤©+30 å¤©],按本窗口开展的检查项目过滤,结果按**预约时间倒序**。
     */
    @Override
    public List<AppointmentDO> getEmergencyCandidates(String code, String checkTypes) {
        if (StringUtils.isEmpty(code)) {
            return new ArrayList<>();
        }
        List<Integer> checkTypeList = splitToIntList(checkTypes);
        List<AppointmentDO> list = appointmentMapper.getEmergencyCandidates(code.trim(), checkTypeList);
        log.info("[getEmergencyCandidates] ç´§æ€¥åŠ åšå€™é€‰æŸ¥è¯¢. code={} checkTypes={} å‘½ä¸­={}",
                code, checkTypes, null == list ? 0 : list.size());
        return null == list ? new ArrayList<>() : list;
    }
    /** æŠŠ `100,200` è¿™ç±»é€—号分隔串解析成 List&lt;Integer&gt;;非法项忽略 */
    private List<Integer> splitToIntList(String raw) {
        List<Integer> result = new ArrayList<>();
        if (StringUtils.isEmpty(raw)) {
            return result;
        }
        for (String part : raw.split("[,,]")) {
            Integer v = parseIntegerQuietly(part);
            if (null != v && !result.contains(v)) {
                result.add(v);
            }
        }
        return result;
    }
    /** å®½æ¾ Byte è§£æžï¼šéžæ•°å­—或空返回 null(用于患者性别等 tinyint å­—段) */
    private Byte parseByteQuietly(String value) {
        if (StringUtils.isEmpty(value) || !value.trim().matches("-?\\d+")) {
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/call/CallServiceImpl.java
@@ -16,6 +16,8 @@
import javax.annotation.Resource;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@@ -30,6 +32,7 @@
 */
@Service
@Validated
@lombok.extern.slf4j.Slf4j
public class CallServiceImpl implements CallService {
    @Resource
@@ -99,12 +102,31 @@
        return callMapper.selectPage(pageReqVO);
    }
    /**
     * é‡å«ï¼šå†ç”Ÿæˆä¸€æ¡å«å·è®°å½•,让大屏 / è¯Šé—´å±é‡æ–°æ’­æŠ¥ã€‚
     *
     * <p>【本次修复】原实现:
     * <pre>
     *   CallDO callDO = callMapper.getLatestPatientCall(patId);
     *   if (null != callDO &amp;&amp; callDO.getCalled() == 0) return 0;   // â† æ°¸è¿œæˆç«‹
     *   return createCall(createReqVO);
     * </pre>
     * è€Œ**全项目没有任何地方把 {@code called} ç½®ä¸º 1**({@code getNextCall} /
     * {@code getNextInstallCall} åªæŸ¥ {@code called = 0})⇒ è¯¥å®ˆå«æ¯æ¬¡éƒ½å‘½ä¸­ â‡’
     * <b>重叫从不写入新记录</b>,两块屏也就完全没有反应(现场反馈即此)。
     *
     * <p>现在只做"防连点":同一患者 **3 ç§’内**的重复重叫忽略,其余情况一律正常新建。
     */
    @Override
    public Integer callAgain(CallSaveReqVO createReqVO) {
        CallDO callDO = callMapper.getLatestPatientCall(createReqVO.getPatId());
        if (null != callDO && callDO.getCalled() == 0)
            return 0;
        CallDO latest = callMapper.getLatestPatientCall(createReqVO.getPatId());
        if (null != latest && null != latest.getCreateTime()) {
            long seconds = Duration.between(latest.getCreateTime(), LocalDateTime.now()).getSeconds();
            if (seconds >= 0 && seconds < 3) {
                log.info("[callAgain] 3 ç§’内重复重叫,忽略. patId={} ä¸Šæ¬¡={}", createReqVO.getPatId(), latest.getCreateTime());
                return 0;
            }
        }
        return createCall(createReqVO);
    }
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/callingscreen/CallingScreenServiceImpl.java
@@ -1,6 +1,7 @@
package cn.lihu.jh.module.ecg.service.callingscreen;
import cn.lihu.jh.framework.common.pojo.PageResult;
import cn.lihu.jh.module.ecg.Utils;
import cn.lihu.jh.module.ecg.controller.admin.checktype.vo.CheckTypePageReqVO;
import cn.lihu.jh.module.ecg.dal.dataobject.checktype.CheckTypeDO;
import cn.lihu.jh.module.ecg.dal.dataobject.queue.QueueDO;
@@ -157,7 +158,7 @@
        });
        mapDisplayColQueue.values().forEach( _queueDOList -> {
            //把 [亲和的] æŒªåˆ°ä¸»é¡¹åŽé¢
            //把 [亲和的] æŒªåˆ°ä¸»é¡¹åŽé¢ï¼ˆåŒæŽ’队号 + åŒæ‚£è€…才算一组,见 Utils#insertAffinityItemsAfterMain)
            List<QueueDO> affinityWaitingList = new ArrayList<>();
            ListIterator<QueueDO> iterator = _queueDOList.listIterator();
            while (iterator.hasNext()) {
@@ -167,14 +168,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 mapDisplayColQueue;
@@ -213,7 +207,7 @@
        // éœ€æ±‚1:回填患者信息(诊间屏-检查队列)
        queueService.fillAppointmentInfo(queueDOList);
        //把 [亲和的] æŒªåˆ°ä¸»é¡¹åŽé¢
        //把 [亲和的] æŒªåˆ°ä¸»é¡¹åŽé¢ï¼ˆåŒæŽ’队号 + åŒæ‚£è€…才算一组,见 Utils#insertAffinityItemsAfterMain)
        List<QueueDO> affinityWaitingList = new ArrayList<>();
        ListIterator<QueueDO> iterator = queueDOList.listIterator();
        while (iterator.hasNext()) {
@@ -223,14 +217,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;
    }
@@ -260,7 +247,7 @@
        // éœ€æ±‚1:回填患者信息(诊间屏-装机队列)
        queueService.fillAppointmentInfo(queueDOList);
        //把 [亲和的] æŒªåˆ°ä¸»é¡¹åŽé¢
        //把 [亲和的] æŒªåˆ°ä¸»é¡¹åŽé¢ï¼ˆåŒæŽ’队号 + åŒæ‚£è€…才算一组,见 Utils#insertAffinityItemsAfterMain)
        List<QueueDO> affinityReceivedList = new ArrayList<>();
        ListIterator<QueueDO> iterator = queueDOList.listIterator();
        while (iterator.hasNext()) {
@@ -270,14 +257,7 @@
                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(queueDOList, affinityReceivedList);
        return queueDOList;
    }
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/config/EcgConfigServiceImpl.java
@@ -62,24 +62,45 @@
        String strRoomResetTime = configApi.getConfigValueByKey(ECG_ROOM_RESET_TIME_KEY);
        LocalTime roomResetTime = Utils.parseTime(strRoomResetTime);
        // ã€æœ¬éœ€æ±‚·兜底加固】配置项 ecg.room.reset.time ç¼ºå¤±/格式非法时 parseTime è¿”回 null,
        // åŽŸå®žçŽ°ç›´æŽ¥ roomResetTime.getMinute() ä¼šæŠ› NPE,而 resetScheduler() åœ¨å¯åŠ¨ç›‘å¬é‡Œ
        // **没有 try/catch** â‡’ å¼€è¯Š/闭诊/重置三个定时任务全部注册失败(等于"每日重置"从未生效)。
        // è¿™é‡Œç»™é»˜è®¤ 23:00,并保证每个任务注册失败只记 ERROR、不影响启动。
        if (null == roomResetTime) {
            roomResetTime = LocalTime.of(23, 0);
            log.warn("[resetScheduler] é…ç½®é¡¹ {} ç¼ºå¤±æˆ–格式非法(原值=[{}]),每日工位重置时间回退为默认 23:00",
                    ECG_ROOM_RESET_TIME_KEY, strRoomResetTime);
        }
        String roomResetCronExpression = String.format("0 %d %d  * * ?", roomResetTime.getMinute(), roomResetTime.getHour());
        startBizTask = taskRegistrar.scheduleCronTask(new CronTask(() -> {
            System.out.println("Opening Task executed at: " + System.currentTimeMillis());
            queueService.startBiz();
        }, openCronExpression));
        try {
            startBizTask = taskRegistrar.scheduleCronTask(new CronTask(() -> {
                System.out.println("Opening Task executed at: " + System.currentTimeMillis());
                queueService.startBiz();
            }, openCronExpression));
        } catch (Exception e) {
            log.error("[resetScheduler] æ³¨å†Œã€Œå¼€è¯Šã€å®šæ—¶ä»»åŠ¡å¤±è´¥ï¼ˆä¸å½±å“å…¶å®ƒä»»åŠ¡ï¼‰. cron={}", openCronExpression, e);
        }
        closeBizTask = taskRegistrar.scheduleCronTask(new CronTask(() -> {
            System.out.println("Close Task executed at: " + System.currentTimeMillis());
            queueService.closeBiz();
        }, closeCronExpression));
        try {
            closeBizTask = taskRegistrar.scheduleCronTask(new CronTask(() -> {
                System.out.println("Close Task executed at: " + System.currentTimeMillis());
                queueService.closeBiz();
            }, closeCronExpression));
        } catch (Exception e) {
            log.error("[resetScheduler] æ³¨å†Œã€Œé—­è¯Šã€å®šæ—¶ä»»åŠ¡å¤±è´¥ï¼ˆä¸å½±å“å…¶å®ƒä»»åŠ¡ï¼‰. cron={}", closeCronExpression, e);
        }
        resetRoomTask = taskRegistrar.scheduleCronTask(new CronTask(() -> {
            System.out.println("Room Reset Task executed at: " + System.currentTimeMillis());
            roomService.resetRoom(false);
            //queueSequenceService.resetQueueSequence();
            queueSequenceService.initNumber();
        }, roomResetCronExpression));
        try {
            resetRoomTask = taskRegistrar.scheduleCronTask(new CronTask(() -> {
                System.out.println("Room Reset Task executed at: " + System.currentTimeMillis());
                roomService.resetRoom(false);
                //queueSequenceService.resetQueueSequence();
                queueSequenceService.initNumber();
            }, roomResetCronExpression));
        } catch (Exception e) {
            log.error("[resetScheduler] æ³¨å†Œã€Œæ¯æ—¥å·¥ä½é‡ç½®ã€å®šæ—¶ä»»åŠ¡å¤±è´¥. cron={}", roomResetCronExpression, e);
        }
        taskRegistrar.afterPropertiesSet();
    }
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/devrent/DevRentService.java
@@ -139,4 +139,15 @@
    DevRentDO getDismantledRent(DevRentSearchReqVO searchReqVO);
    Integer feeConfirm(Long rentId, String hisId, String userNickname, Boolean isFeeConfirmOrCancel);
    /**
     * æ’¤é”€ã€Œæ£€æŸ¥å®Œæˆã€åŽçš„回滚(本次新增):
     * æŠŠè¯¥æ‚£è€…今日、该检查项目的 **60(常规检查完成) â†’ 0(待检查)**,并**取消确费**。
     *
     * <p>为什么必须回滚:撤销原先只把队列行 40→20,检查单仍是 60;
     * å‰ç«¯å†æ¬¡å®Œæˆæ—¶ `getFreeRent` å–不到「待检查」单(rentId ä¸ºç©ºï¼‰ï¼ŒåŽç«¯ä¾¿æŠ¥ã€Œè¯·å…ˆé¢„约确认」。
     *
     * <p>内部已 try/catch:回滚失败不应影响"撤销"本身的结果。
     */
    void rollbackRoutineFinish(String patId, Integer checkType);
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/devrent/DevRentServiceImpl.java
@@ -85,6 +85,9 @@
    @Autowired
    private WebServiceClient webServiceClient;
    /** ã€æœ¬éœ€æ±‚】紧急加做的备注标记,便于在完成列表/报表里区分这类"不排队直接检查"的记录 */
    private static final String EMERGENCY_REMARK = "紧急加做";
    @Override
    public CommonResult<Long> registerOperation(DevRentSaveReqVO createReqVO) {
@@ -101,6 +104,13 @@
        String userNickname = SecurityFrameworkUtils.getLoginUserNickname();
        String userHisId = SecurityFrameworkUtils.getLoginUserHisId();
        // ã€æœ¬éœ€æ±‚:紧急患者不排队直接检查】
        // æ­£å¸¸æ‚£è€…的「待检查(0)」检查单是签到建队列时一起插的;紧急加做的患者没走签到,
        // å› æ­¤è¿™é‡Œå…ˆæŠŠè¿™æ¡å•补出来,后续逻辑(写作业记录、更新队列、回写集成平台)完全复用。
        if (Boolean.TRUE.equals(routineFinishReqVO.getEmergency())) {
            prepareEmergencyDevRent(routineFinishReqVO, createReqVO);
        }
        // åœ¨é¢„约确认时 å°±è¿›å…¥ [dev_rent] è¡¨äº†
        Long rent_id = createReqVO.getId();
@@ -110,33 +120,64 @@
        JobRecordDO jobRecordDO = jobRecordMapper.selectByRentIdDocId(rent_id, userId, jobTypeList);
        if (null == jobRecordDO) {
            List<DevRentDO> devRentDOList = devRentMapper.selectByPatAndChecktypeAndState(createReqVO.getPatId(), createReqVO.getCheckType(), DevRentStateEnum.FREE.getState());
            if (devRentDOList.size() == 0) throw exception(DEV_INSTALL_NOT_APPOINTMENT);
            else if (devRentDOList.size() != 1) {
                throw exception(DEV_INSTALL_EXCEPTION);
            // ã€æœ¬æ¬¡ä¿®å¤ã€‘撤销后再次完成时的兜底:
            // è‹¥ã€Œå¾…检查(0)」的单子不存在(撤销时没能回滚到 0、或历史数据),
            // ä¸èƒ½å†ç›´æŽ¥æŠ¥ã€Œè¯·å…ˆé¢„约确认」,否则医生无法把误完成的患者重新完成。
            // è¿™é‡Œå¤ç”¨**今日最近一条**「常规检查完成(60)/常规检查放弃(50)」的单;
            // ä¸”如果该单已经有完成记录,就走下面的"更新"分支,避免重复插入 job_record。
            if (devRentDOList.isEmpty()) {
                List<Integer> fallbackStateList = new ArrayList<>();
                fallbackStateList.add(DevRentStateEnum.ROUTINE_FINISH.getState());
                fallbackStateList.add(DevRentStateEnum.ROUTINE_CANCELLED.getState());
                DevRentDO fallbackRent = devRentMapper.selectLatestByPatCheckTypeStates(
                        createReqVO.getPatId(), createReqVO.getCheckType(), fallbackStateList);
                if (null == fallbackRent) {
                    throw exception(DEV_INSTALL_NOT_APPOINTMENT);
                }
                log.warn("[routineFinishOperation] ã€Œå¾…检查」单不存在,兜底复用最近一条已完成/已放弃单. "
                                + "rentId={} patId={} checkType={}",
                        fallbackRent.getId(), createReqVO.getPatId(), createReqVO.getCheckType());
                devRentDOList = new ArrayList<>(List.of(fallbackRent));
                jobRecordDO = jobRecordMapper.selectByRentIdDocId(fallbackRent.getId(), userId, jobTypeList);
                if (null != jobRecordDO) {
                    rent_id = fallbackRent.getId();
                    createReqVO.setId(rent_id);
                }
            }
            rent_id = devRentDOList.get(0).getId();
            createReqVO.setId(rent_id);
            createReqVO.setState(DevRentStateEnum.ROUTINE_FINISH.getState());
            updateDevRent(createReqVO);
            if (null != jobRecordDO) {
                // å…œåº•命中已有完成记录 â†’ è½åˆ°ä¸‹æ–¹"更新"分支(与 else åˆ†æ”¯åŒæ ·çš„处理)
            } else {
                if (devRentDOList.size() != 1) {
                    throw exception(DEV_INSTALL_EXCEPTION);
                }
            JobRecordSaveReqVO jobRecordSaveReqVO = new JobRecordSaveReqVO();
            jobRecordSaveReqVO.setRentId(rent_id);
            jobRecordSaveReqVO.setDevId(createReqVO.getDevId());
            jobRecordSaveReqVO.setDocId(userId);
            jobRecordSaveReqVO.setDocName(userNickname);
            jobRecordSaveReqVO.setPatId(createReqVO.getPatId());
            jobRecordSaveReqVO.setPatName(createReqVO.getPatName());
            jobRecordSaveReqVO.setJobTime(createReqVO.getRentTime());
            jobRecordSaveReqVO.setJobType(DevRentStateEnum.ROUTINE_FINISH.getState());
            jobRecordSaveReqVO.setRemark(createReqVO.getRemark());
            jobRecordSaveReqVO.setSummary("");
            jobRecordSaveReqVO.setCheckType(createReqVO.getCheckType());
                rent_id = devRentDOList.get(0).getId();
                createReqVO.setId(rent_id);
                createReqVO.setState(DevRentStateEnum.ROUTINE_FINISH.getState());
                updateDevRent(createReqVO);
            JobRecordDO jobRecord = BeanUtils.toBean(jobRecordSaveReqVO, JobRecordDO.class);
            jobRecordMapper.insert(jobRecord);
        } else {
                JobRecordSaveReqVO jobRecordSaveReqVO = new JobRecordSaveReqVO();
                jobRecordSaveReqVO.setRentId(rent_id);
                jobRecordSaveReqVO.setDevId(createReqVO.getDevId());
                jobRecordSaveReqVO.setDocId(userId);
                jobRecordSaveReqVO.setDocName(userNickname);
                jobRecordSaveReqVO.setPatId(createReqVO.getPatId());
                jobRecordSaveReqVO.setPatName(createReqVO.getPatName());
                jobRecordSaveReqVO.setJobTime(createReqVO.getRentTime());
                jobRecordSaveReqVO.setJobType(DevRentStateEnum.ROUTINE_FINISH.getState());
                jobRecordSaveReqVO.setRemark(createReqVO.getRemark());
                jobRecordSaveReqVO.setSummary("");
                jobRecordSaveReqVO.setCheckType(createReqVO.getCheckType());
                JobRecordDO jobRecord = BeanUtils.toBean(jobRecordSaveReqVO, JobRecordDO.class);
                jobRecordMapper.insert(jobRecord);
            }
        }
        if (null != jobRecordDO) {
            // æ’¤é”€ æ”¾å¼ƒæ“ä½œ
            createReqVO.setId(rent_id);
            createReqVO.setState(DevRentStateEnum.ROUTINE_FINISH.getState());
            updateDevRent(createReqVO);
@@ -240,6 +281,106 @@
        }
        return CommonResult.success(rent_id);
    }
    /**
     * ã€æœ¬éœ€æ±‚:紧急患者不排队直接检查】为「紧急加做」的患者准备「待检查(0)」检查单。
     *
     * <h3>为什么需要它</h3>
     * æ­£å¸¸æ‚£è€…çš„ {@code dev_rent(state=0)} è¡Œæ˜¯**签到建队列**时一起插入的(见
     * {@code QueueServiceImpl#queue})。紧急加做的患者不走签到/排队,所以完成时找不到这行,
     * åŽŸé€»è¾‘ä¼šç›´æŽ¥æŠ› {@code DEV_INSTALL_NOT_APPOINTMENT}("请先预约确认")。
     *
     * <h3>做法</h3>
     * <ol>
     *   <li>今天已有该患者 + æ£€æŸ¥é¡¹ç›®çš„「待检查」行 â†’ ä»€ä¹ˆéƒ½ä¸åšï¼ˆèµ°æ­£å¸¸æµç¨‹ï¼‰ï¼›</li>
     *   <li>否则按**预约单**(申请单号优先,其次患者编号 + æ£€æŸ¥é¡¹ç›®ï¼Œè¿‘ Â±30 å¤©ï¼‰è¡¥å»ºä¸€è¡Œï¼›</li>
     *   <li>同时把备注补上「紧急加做」,便于在完成列表/报表里区分。</li>
     * </ol>
     * é¢„约单不存在或检查项目不匹配时**不补建**,让原逻辑照常报错,避免产生脏数据。
     */
    private void prepareEmergencyDevRent(RoutineFinishReqVO req, DevRentSaveReqVO createReqVO) {
        // å¤‡æ³¨ç»Ÿä¸€æ‰“标(原备注保留)
        String remark = createReqVO.getRemark();
        if (org.springframework.util.StringUtils.hasText(remark)) {
            if (!remark.contains(EMERGENCY_REMARK)) {
                createReqVO.setRemark(EMERGENCY_REMARK + ";" + remark);
            }
        } else {
            createReqVO.setRemark(EMERGENCY_REMARK);
        }
        if (org.springframework.util.StringUtils.hasText(req.getPatId()) && null != req.getCheckType()) {
            List<DevRentDO> existList = devRentMapper.selectByPatAndChecktypeAndState(
                    req.getPatId(), req.getCheckType(), DevRentStateEnum.FREE.getState());
            if (!existList.isEmpty()) {
                log.info("[prepareEmergencyDevRent] ä»Šæ—¥å·²æœ‰å¾…检查单,按正常流程完成. patId={} checkType={} æ¡æ•°={}",
                        req.getPatId(), req.getCheckType(), existList.size());
                return;
            }
        }
        AppointmentDO appointment = findEmergencyAppointment(req);
        if (null == appointment) {
            log.warn("[prepareEmergencyDevRent] æœªæ‰¾åˆ°å¯ç”¨çš„预约单,跳过补建(原逻辑将报「请先预约确认」). "
                    + "applyNo={} patId={} checkType={}", req.getApplyNo(), req.getPatId(), req.getCheckType());
            return;
        }
        DevRentDO devRent = new DevRentDO();
        devRent.setApplyNo(nonBlank(appointment.getApplyNo(), req.getApplyNo()));
        devRent.setEpisodeId(nonBlank(appointment.getEpisodeId(), req.getEpisodeId()));
        devRent.setPatId(nonBlank(appointment.getPatId(), req.getPatId()));
        devRent.setPatName(nonBlank(appointment.getPatName(), req.getPatName()));
        devRent.setCheckType(appointment.getBookCheckType());
        devRent.setPaid(appointment.getPaid());
        devRent.setState(DevRentStateEnum.FREE.getState());
        devRent.setRemark(createReqVO.getRemark());
        devRentMapper.insert(devRent);
        // è®©åŽç»­ã€ŒæŒ‰æ‚£è€… + æ£€æŸ¥é¡¹ç›® + ä»Šæ—¥ + å¾…检查」能正常定位到
        createReqVO.setId(devRent.getId());
        createReqVO.setApplyNo(devRent.getApplyNo());
        createReqVO.setEpisodeId(devRent.getEpisodeId());
        // æ£€æŸ¥é¡¹ç›®ä»¥é¢„约单为准;预约单没写时才沿用请求里的值(避免把 checkType ç½®ç©ºå¯¼è‡´åŽç»­æŠ¥é”™ï¼‰
        if (null != devRent.getCheckType()) {
            createReqVO.setCheckType(devRent.getCheckType());
        }
        if (null == createReqVO.getPaid()) {
            createReqVO.setPaid(devRent.getPaid());
        }
        log.info("[prepareEmergencyDevRent] ç´§æ€¥åŠ åšï¼šå·²è¡¥å»ºã€Œå¾…æ£€æŸ¥ã€æ£€æŸ¥å•. rentId={} patId={} checkType={} applyNo={}",
                devRent.getId(), devRent.getPatId(), devRent.getCheckType(), devRent.getApplyNo());
    }
    /** ç´§æ€¥åŠ åšï¼šå®šä½è¦ç”¨å“ªå¼ é¢„çº¦å•ï¼ˆç”³è¯·å•å·ä¼˜å…ˆï¼Œå…¶æ¬¡æ‚£è€…ç¼–å· + æ£€æŸ¥é¡¹ç›®ï¼Œè¿‘ Â±30 å¤©ï¼Œå–最近的) */
    private AppointmentDO findEmergencyAppointment(RoutineFinishReqVO req) {
        // â‘  ç”³è¯·å•号能直接命中
        if (org.springframework.util.StringUtils.hasText(req.getApplyNo())) {
            AppointmentDO byApplyNo = appointmentMapper.getByApplyNo(req.getApplyNo());
            if (null != byApplyNo && matchesCheckType(byApplyNo, req.getCheckType())) {
                return byApplyNo;
            }
        }
        // â‘¡ æŒ‰æ‚£è€…编号 + æ£€æŸ¥é¡¹ç›®ï¼Œå–最近一张
        if (org.springframework.util.StringUtils.hasText(req.getPatId()) && null != req.getCheckType()) {
            List<Integer> one = new ArrayList<>();
            one.add(req.getCheckType());
            List<AppointmentDO> candidates = appointmentMapper.getEmergencyCandidates(req.getPatId(), one);
            if (!candidates.isEmpty()) {
                return candidates.get(0);
            }
        }
        return null;
    }
    private boolean matchesCheckType(AppointmentDO appointment, Integer checkType) {
        return null == checkType || null == appointment.getBookCheckType()
                || checkType.equals(appointment.getBookCheckType());
    }
    private String nonBlank(String first, String second) {
        return org.springframework.util.StringUtils.hasText(first) ? first : second;
    }
    /**
@@ -854,9 +995,55 @@
        return devRentDO;
    }
    /**
     * æ’¤é”€ã€Œæ£€æŸ¥å®Œæˆã€åŽçš„回滚:检查单 60(常规检查完成) â†’ 0(待检查),并取消确费。
     *
     * <p>【本次修复的根因】撤销原先只把队列行 40→20,`dev_rent` ä»æ˜¯ 60:
     * å‰ç«¯å†æ¬¡å®Œæˆæ—¶ `getFreeRent`(只查 state=0)取不到单 â†’ `formData.id` ä¸ºç©º
     * â†’ åŽç«¯ `routineFinishOperation` æŒ‰ (patId, checkType, state=0) ä¹Ÿæ‰¾ä¸åˆ° â†’ æŠ¥ã€Œè¯·å…ˆé¢„约确认」。
     * æŠŠçŠ¶æ€å›žæ»šåˆ° 0 åŽï¼Œæ•´æ¡é“¾è·¯æ¢å¤æ­£å¸¸ã€‚
     *
     * <p>失败策略:全程 try/catch,回滚/取消费失败都只记日志,**不影响"撤销"本身的结果**。
     */
    @Override
    public DevRentDO getFreeRent(DevRentSearchReqVO searchReqVO) {
        List<Integer> stateList = new ArrayList<>();
    public void rollbackRoutineFinish(String patId, Integer checkType) {
        try {
            List<Integer> finishedStateList = new ArrayList<>();
            finishedStateList.add(DevRentStateEnum.ROUTINE_FINISH.getState());
            DevRentDO rent = devRentMapper.selectLatestByPatCheckTypeStates(patId, checkType, finishedStateList);
            if (null == rent) {
                log.warn("[rollbackRoutineFinish] æœªæ‰¾åˆ°ä»Šæ—¥è¯¥æ‚£è€…的「常规检查完成」检查单,跳过回滚. patId={} checkType={}",
                        patId, checkType);
                return;
            }
            // â‘  çŠ¶æ€å›žæ»šï¼ˆåªæ”¹ state,rent_time/return_time ä¿ç•™ï¼Œä½œä¸º"做过一次"的痕迹)
            DevRentDO updateObj = new DevRentDO();
            updateObj.setId(rent.getId());
            updateObj.setState(DevRentStateEnum.FREE.getState());
            devRentMapper.updateById(updateObj);
            log.info("[rollbackRoutineFinish] æ£€æŸ¥å•已回滚为[待检查]. rentId={} patId={} checkType={}",
                    rent.getId(), patId, checkType);
            // â‘¡ å–消确费(第一次完成时控制器会自动确费;撤销后应回退,再次完成时重新确费)
            Integer paid = rent.getPaid();
            if (null != paid && 1 == paid) {
                try {
                    String hisId = SecurityFrameworkUtils.getLoginUserHisId();
                    String nickname = SecurityFrameworkUtils.getLoginUserNickname();
                    Integer ret = feeConfirm(rent.getId(), hisId, nickname, false);
                    log.info("[rollbackRoutineFinish] å·²å–消确费. rentId={} è¿”回={}", rent.getId(), ret);
                } catch (Exception e) {
                    log.error("[rollbackRoutineFinish] å–消确费失败(不影响撤销). rentId={}", rent.getId(), e);
                }
            }
        } catch (Exception e) {
            log.error("[rollbackRoutineFinish] å›žæ»šæ£€æŸ¥å•失败(不影响撤销). patId={} checkType={}", patId, checkType, e);
        }
    }
    @Override
    public DevRentDO getFreeRent(DevRentSearchReqVO searchReqVO) {        List<Integer> stateList = new ArrayList<>();
        stateList.add(DevRentStateEnum.FREE.getState());
        searchReqVO.setStateList(stateList);
        DevRentDO devRentDO = devRentMapper.getRentByState(searchReqVO);
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/his/HisEcgFilter.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,454 @@
package cn.lihu.jh.module.ecg.service.his;
import cn.lihu.jh.module.ecg.enums.HisCheckCodeEnum;
import cn.lihu.jh.module.ecg.enums.HisCheckNameEnum;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
/**
 * åˆ¤å®šä¸€æ¡ HIS æŠ¥æ–‡æ˜¯å¦ä¸ºã€Œå¿ƒç”µä¸šåŠ¡å•ã€ã€‚
 *
 * <h3>为什么需要它</h3>
 * {@code S0405}(申请单状态更新)是**全院通用**服务,接口文档定义为
 * {@code S0405|S0405HIS|S040501HIS},不区分科室。医院会把**所有检查科室**
 * ï¼ˆCT、MRI、DR、超声、骨密度…)的检查状态一并推送给心电系统。
 *
 * <p>实测 2026-09-17 æ—¥å¿—:S0405 å…± 1225 æ¬¡ï¼Œå…¶ä¸­ä»… 3.5% çš„申请单号能在
 * å¿ƒç”µåº“中找到。其余为放射/超声等科室的单,例如:
 * <pre>
 *   æ£€æŸ¥é¡¹ç›®ï¼šèƒ¸éƒ¨CT平扫、冠脉CTA、颅脑MRI平扫、彩超肝胆胰脾、骨密度…
 *   è¯Šå®¤    ï¼š10号机房(CT)、急诊CT室、15号机房(MRI)、产科超声1、急诊B超…
 * </pre>
 * è‹¥ä¸è¿‡æ»¤ï¼Œä¼šå¯¹è¿™äº›å•执行"查不到 â†’ æ’入新记录",向 appointment è¡¨çŒå…¥
 * å¤§é‡æ— å…³æ•°æ®ï¼ˆå®žæµ‹å•日逾千条)。
 *
 * <h3>判定方式(三重,任一命中即为心电单)</h3>
 * <ol>
 *   <li><b>A. ç”³è¯·å•号后缀</b>:心电单由 {@code S0201ECG} åˆ›å»ºï¼Œç¼–号带固定后缀
 *       {@value #ECG_APPLY_NO_SUFFIX_1101} / {@value #ECG_APPLY_NO_SUFFIX_6401}
 *       ï¼ˆå®žæµ‹æ ·ä¾‹ {@code 8826091720001101}、{@code 8826081120716401})。
 *       ä¸Žæ”¾å°„科的纯数字流水号(如 {@code 2126091700001})形态明显不同。</li>
 *   <li><b>B. æ£€æŸ¥ç±»åž‹</b>:HIS æ£€æŸ¥ä»£ç ï¼ˆ{@code jcxmcode} / æŠ¥æ–‡çš„
 *       {@code component2...code.code})能匹配 {@link HisCheckCodeEnum},
 *       æˆ–检查名称能匹配 {@link HisCheckNameEnum}(体检患者按名称匹配)。</li>
 *   <li><b>C. æ‰§è¡Œç§‘室</b>:HIS æ‰§è¡Œç§‘室代码在配置白名单内。
 *       è¯¥ç™½åå•来自配置项 {@code ecg.his.ecg-dept-codes}(逗号分隔)。
 *       <b>未配置时本项不参与判定</b>,避免误判。</li>
 * </ol>
 *
 * <h3>保守策略</h3>
 * å½“三项均无法判定时,返回 {@code false}(视为非心电单)并记录一条 DEBUG æ—¥å¿—。
 * è¿™æ˜¯åˆ»æ„çš„保守选择:宁可漏收(有日志可查、可补),也不向业务表灌入无关数据。
 *
 * @author å¿ƒç”µæŽ’队装机系统
 */
@Slf4j
@Component
public class HisEcgFilter {
    /** å¿ƒç”µç”³è¯·å•号后缀之一(对应 check_type=1100 å¸¸è§„心电图-心电向量图 ç³»åˆ—) */
    public static final String ECG_APPLY_NO_SUFFIX_1101 = "1101";
    /** å¿ƒç”µç”³è¯·å•号后缀之二(对应 check_type=500 åŠ¨è„‰ç¡¬åŒ–æ£€æµ‹ ç³»åˆ—) */
    public static final String ECG_APPLY_NO_SUFFIX_6401 = "6401";
    /** å¿ƒç”µç”³è¯·å•号后缀白名单 */
    private static final List<String> ECG_APPLY_NO_SUFFIXES = Arrays.asList(
            ECG_APPLY_NO_SUFFIX_1101, ECG_APPLY_NO_SUFFIX_6401);
    /**
     * å¿ƒç”µæ£€æŸ¥é¡¹ç›®åç§°å…³é”®å­—(**辅助信号,不是唯一依据**)。
     * <p>
     * <b>⚠️ é‡è¦æ›´æ­£</b>:早期版本注释曾声称"本院全部 15 ä¸ªå¿ƒç”µé¡¹ç›®åç§°å‡å«ã€Œå¿ƒç”µã€",
     * è¿™æ˜¯**错误的**。核对 {@code check_type} è¡¨åŽæœ‰ **4 ä¸ªå¿ƒç”µç±»åž‹ä¸å«"心电"**:
     * <pre>
     *   300  åŠ¨æ€è¡€åŽ‹        åº“中 9083 æ¡
     *   500  åŠ¨è„‰ç¡¬åŒ–æ£€æµ‹     åº“中 11205 æ¡
     *   600  é£Ÿé“电生理       åº“中 60 æ¡
     *   900  å¿ƒå®¤æ™šç”µä½       åº“中 0 æ¡
     * </pre>
     * å› æ­¤**绝不能**用"含心电"作为唯一判定依据,否则这 2 ä¸‡å¤šæ¡çœŸå®žå¿ƒç”µè®°å½•会被丢弃。
     * <p>
     * æœ¬å…³é”®å­—仅作为**白名单之外的补充**({@link #ECG_NAME_WHITELIST} æœªæ”¶å½•çš„
     * é•¿å°¾åç§°å˜ä½“,如"十二导联心电检查")。真正的权威依据是:
     * â‘  æ£€æŸ¥ä»£ç ï¼ˆ{@link HisCheckCodeEnum})、② åç§°ç™½åå•、③ éžå¿ƒç”µæŽ’除词。
     */
    private static final String ECG_NAME_KEYWORD = "心电";
    /**
     * å¿ƒç”µæ£€æŸ¥é¡¹ç›®åç§°ç™½åå•。
     * <p>
     * ä¸¤ä¸ªæ¥æºåˆå¹¶ï¼š
     * <ol>
     *   <li>{@code check_type} è¡¨çš„ 15 ä¸ªæƒå¨åç§°ï¼›</li>
     *   <li>{@code HisCheckNameEnum} ä¸­ HIS å®žé™…使用的名称变体(如"24小时动态心电图"、
     *       "24小时动态心电监测"、"24小时动态血压监测")。</li>
     * </ol>
     * ç”¨**去空白后的完整名称精确匹配**,避免"含心电"这类不严谨的模糊判断。
     * <p>
     * æ³¨æ„ï¼š300/500/600/900 è¿™å››ç±»åç§°æœ¬èº«ä¸å«"心电",靠本白名单与检查代码共同兜住。
     */
    private static final List<String> ECG_NAME_WHITELIST = Arrays.asList(
            // â€”— check_type è¡¨ 15 ä¸ªæƒå¨åç§°ï¼ˆå‰ç¼€åŒ¹é…ï¼Œå¯è¦†ç›–带前后缀的变体)——
            "常规心电图",
            "动态心电",
            "动态血压",
            "平板运动心电",
            "动脉硬化检测",
            "食道电生理",
            "心电图药物负荷试验",
            "频谱心电图",
            "心室晚电位",
            "床边心电图(常规+频谱)",
            "常规心电图-心电向量图",
            "动态心电图+血压",
            "(GCP)心电图",
            "常规心电图18导-心电向量图",
            "床边心电图18导(常规+频谱)",
            // â€”— HIS å®žé™…使用的名称变体(来自 HisCheckNameEnum ä¸Žç”Ÿäº§æ—¥å¿—)——
            "24小时动态心电图",
            "24小时动态心电监测",
            "24小时动态血压监测",
            "心电图运动试验",          // å®žæµ‹ S050401 å‡ºçŽ°ï¼Œå¯¹åº” check_type=400
            "运动心电图",
            "运动试验",                // å¿ƒç”µå›¾è¿åŠ¨è¯•éªŒçš„çŸ­åï¼ˆç”¨æˆ·æå‡ºï¼‰
            "平板运动",                // è¦†ç›–"平板运动试验"/"平板运动心电图"等变体
            "活动平板试验",
            "心电向量图",
            "床边心电图",
            "频谱心电",
            "食道调搏",
            "经食道心房调搏",
            "动脉硬化",                // check_type=500 çš„短名(表内为"动脉硬化检测")
            "踝臂指数",
            "ABI"
    );
    /**
     * æ˜Žç¡®çš„**非心电**排除词。
     * <p>
     * ç”¨äºŽåœ¨"名称既不在白名单、也不含心电关键字"时,快速判定为其他科室检查。
     * è¿™äº›è¯æ¥è‡ªå®žæµ‹ç”Ÿäº§æ—¥å¿—中大量出现的影像/超声/内镜/功能检查项目
     * ï¼ˆå¦‚"彩超肝胆胰脾"、"胸部CT平扫"、"无痛胃镜"、"骨密度")。
     * <p>
     * <b>刻意保守</b>:只收录与心电绝无交集的强特征词,避免误伤。例如
     * "彩超胎儿心脏"含"心脏"但属超声科,用"彩超"排除;而不收录孤立的"心脏",
     * ä»¥å…è¯¯ä¼¤å¯èƒ½çš„心电项目。
     */
    private static final List<String> NON_ECG_EXCLUDE_KEYWORDS = Arrays.asList(
            // è¶…声类
            "彩超", "B超", "超声", "多普勒超声",
            // æ”¾å°„影像类
            "CT", "MRI", "MR平扫", "DR", "CR", "X线", "摄片", "正侧位", "正斜位", "正位", "侧位",
            "平扫", "增强", "CTA", "MRA", "DWI", "三维重建", "重建", "造影", "钼靶",
            "机房", "放射", "核磁", "磁共振",
            // å†…镜类
            "胃镜", "肠镜", "支气管镜", "喉镜", "鼻咽镜", "内镜", "内窥镜",
            // å…¶ä»–功能检查(非心电)
            "肺功能", "肺通气", "肺弥散", "骨密度", "肌电图", "脑电图", "脑血流",
            "呼气", "激发试验", "舒张试验", "弹性测定", "弹性成像", "量表",
            "骨龄", "KUB", "排粪造影",
            // ç—…理/检验类
            "活检", "穿刺", "病理", "标本"
    );
    /**
     * HIS æ‰§è¡Œç§‘室代码白名单(配置项 {@code ecg.his.ecg-dept-codes},逗号分隔)。
     * <p>
     * è¯´æ˜Žï¼šå®žæµ‹åº“中查不到 HIS æ‰§è¡Œç§‘室代码(如 {@code YJ001})与本地科室的映射表,
     * å› æ­¤è¿™é‡Œåšæˆå¯é…ç½®é¡¹ã€‚未配置时「依据 C」不参与判定,不影响 A/B çš„过滤效果。
     * å¾…院方提供心电执行科室代码后,在 infra_config å¢žåŠ è¯¥é…ç½®å³å¯ç”Ÿæ•ˆã€‚
     */
    private volatile List<String> ecgDeptCodes = java.util.Collections.emptyList();
    /** ä¾›å¤–部(配置刷新)注入科室白名单 */
    public void setEcgDeptCodes(List<String> codes) {
        this.ecgDeptCodes = codes == null ? java.util.Collections.emptyList() : codes;
    }
    /**
     * åˆ¤å®šç»“果:是否为心电业务单,以及命中的依据(便于排查)。
     */
    public static class Result {
        private final boolean ecg;
        private final String reason;
        Result(boolean ecg, String reason) {
            this.ecg = ecg;
            this.reason = reason;
        }
        public boolean isEcg() {
            return ecg;
        }
        public String getReason() {
            return reason;
        }
    }
    /**
     * ç»¼åˆåˆ¤å®šï¼ˆä¸‰é‡ï¼‰ã€‚
     *
     * @param applyNo        ç”³è¯·å•号,可为空
     * @param hisCheckCode   HIS æ£€æŸ¥é¡¹ç›®ä»£ç ï¼ˆå¦‚ {@code Y1202065}),可为空
     * @param hisCheckName   HIS æ£€æŸ¥é¡¹ç›®åç§°ï¼ˆå¦‚ {@code åºŠè¾¹å¿ƒç”µå›¾(常规+频谱)}),可为空
     * @param hisDeptCode    HIS æ‰§è¡Œç§‘室代码,可为空
     * @return åˆ¤å®šç»“æžœ
     */
    public Result judge(String applyNo, String hisCheckCode, String hisCheckName, String hisDeptCode) {
        // ä¾æ® A:申请单号后缀
        if (matchApplyNoSuffix(applyNo)) {
            return new Result(true, "A:申请单号后缀");
        }
        // ä¾æ® B:检查类型(代码优先,其次名称)
        if (matchCheckCode(hisCheckCode)) {
            return new Result(true, "B:检查项目代码=" + hisCheckCode);
        }
        if (matchCheckName(hisCheckName)) {
            return new Result(true, "B:检查项目名称=" + hisCheckName);
        }
        // ä¾æ® C:执行科室(需配置白名单,未配置时不参与)
        if (matchDeptCode(hisDeptCode)) {
            return new Result(true, "C:执行科室=" + hisDeptCode);
        }
        return new Result(false, "未命中任何心电判定依据");
    }
    /**
     * ç»¼åˆåˆ¤å®šï¼ˆå« ZDY æ£€æŸ¥é¡¹ç›®ï¼Œç”¨äºŽ {@code S050401}/{@code S050501})。
     * <p>
     * è¿™ä¸¤ä¸ªæœåŠ¡çš„æ£€æŸ¥é¡¹ç›®ä¸åœ¨ {@code component2.observationRequest.code},
     * è€Œåœ¨ {@code actAppointment.ZDY.jcxmcode} / {@code ZDY.jcxmname}(逗号分隔多值)。
     *
     * @param zdy æŠ¥æ–‡ä¸­çš„ {@code ZDY} èŠ‚ç‚¹ï¼Œå¯ä¸ºç©º
     */
    public Result judgeWithZdy(String applyNo, String hisDeptCode, Object zdy) {
        // ä¾æ® A:申请单号后缀
        if (matchApplyNoSuffix(applyNo)) {
            return new Result(true, "A:申请单号后缀");
        }
        // ä¾æ® B:ZDY ä¸­çš„æ£€æŸ¥é¡¹ç›®ï¼ˆä»£ç  + åç§°ï¼Œæ”¯æŒé€—号分隔多值)
        String jcxmCode = extractZdyValue(zdy, "jcxmcode");
        String jcxmName = extractZdyValue(zdy, "jcxmname");
        if (matchCheckItems(jcxmCode, jcxmName)) {
            return new Result(true, "B:ZDY检查项目 code=" + jcxmCode + " name=" + jcxmName);
        }
        // ä¾æ® C:执行科室
        if (matchDeptCode(hisDeptCode)) {
            return new Result(true, "C:执行科室=" + hisDeptCode);
        }
        return new Result(false, "未命中任何心电判定依据");
    }
    /** å– ZDY ä¸­çš„æŸä¸ªæ–‡æœ¬å­—段 */
    private String extractZdyValue(Object zdy, String key) {
        if (!(zdy instanceof java.util.Map)) {
            return null;
        }
        Object v = ((java.util.Map<?, ?>) zdy).get(key);
        return v == null ? null : v.toString();
    }
    /** ä¾æ® A:申请单号是否以心电后缀结尾 */
    public boolean matchApplyNoSuffix(String applyNo) {
        if (!StringUtils.hasText(applyNo)) {
            return false;
        }
        String trimmed = applyNo.trim();
        for (String suffix : ECG_APPLY_NO_SUFFIXES) {
            if (trimmed.endsWith(suffix)) {
                return true;
            }
        }
        return false;
    }
    /**
     * ä¾æ® B-1:HIS æ£€æŸ¥é¡¹ç›®ä»£ç æ˜¯å¦å±žå¿ƒç”µã€‚
     * <p>
     * HIS æŠ¥æ–‡ä¸­è¯¥å€¼å½¢å¦‚ {@code Y1202065},而 {@link HisCheckCodeEnum} çš„ code ä¸º
     * åŽ»æŽ‰å‰ç¼€ {@code Y} åŽçš„æ•°å­—({@code 1202065})。原代码使用
     * {@code checkType.substring(1)} åšæˆªæ–­ï¼Œè¿™é‡ŒåšåŒæ ·å…¼å®¹å¹¶è¡¥é•¿åº¦æ ¡éªŒï¼Œ
     * åŒæ—¶å®¹å¿æŠ¥æ–‡æœ¬èº«å°±å¸¦æˆ–不带 {@code Y} å‰ç¼€ã€‚
     */
    public boolean matchCheckCode(String hisCheckCode) {
        if (!StringUtils.hasText(hisCheckCode)) {
            return false;
        }
        String raw = hisCheckCode.trim().replaceAll("\\s+", "");
        // åŽŸé€»è¾‘ï¼šsubstring(1) åŽ»æŽ‰é¦–å­—ç¬¦ã€‚æ­¤å¤„åŒæ—¶å°è¯•åŽŸå€¼ä¸ŽåŽ»é¦–å­—ç¬¦ï¼Œå…¼å®¹ä¸¤ç§æŠ¥æ–‡
        String[] candidates = raw.length() > 1
                ? new String[]{raw, raw.substring(1)}
                : new String[]{raw};
        for (String candidate : candidates) {
            if (HisCheckCodeEnum.getByCode(candidate) != null) {
                return true;
            }
        }
        return false;
    }
    /**
     * ä¾æ® B-2:HIS æ£€æŸ¥é¡¹ç›®åç§°æ˜¯å¦å±žå¿ƒç”µã€‚
     *
     * <p>判定顺序(**先白名单、后关键字、再用排除词挡住**):
     * <ol>
     *   <li><b>名称白名单精确匹配</b>({@link ECG_NAME_WHITELIST} æˆ–
     *       {@link HisCheckNameEnum})—— è¦†ç›–"动态血压""动脉硬化检测"
     *       "食道电生理""心室晚电位"等**不含"心电"**的心电项目;</li>
     *   <li><b>含"心电"关键字</b> â€”— è¦†ç›–白名单未收录的名称变体
     *       ï¼ˆå¦‚"十二导联心电检查");</li>
     *   <li><b>命中非心电排除词</b>({@link NON_ECG_EXCLUDE_KEYWORDS})——
     *       æ˜Žç¡®åˆ¤ä¸ºå…¶ä»–科室检查,直接返回 false。
     *       è¿™ä¸€æ­¥å¾ˆå…³é”®ï¼šè‹¥æŸåç§°åŒæ—¶å‘½ä¸­"心电"与排除词(异常情况),
     *       è¯´æ˜ŽæŠ¥æ–‡æœ‰æ­§ä¹‰ï¼Œå®å¯äº¤ç»™æŽ’除词判否,也不误收。</li>
     * </ol>
     *
     * <p><b>为什么不只用"含心电"</b>:本院 {@code check_type} è¡¨çš„ 15 ä¸ªå¿ƒç”µç±»åž‹ä¸­ï¼Œ
     * æœ‰ 4 ä¸ªä¸å«"心电"(300 åŠ¨æ€è¡€åŽ‹ã€500 åŠ¨è„‰ç¡¬åŒ–æ£€æµ‹ã€600 é£Ÿé“电生理、
     * 900 å¿ƒå®¤æ™šç”µä½ï¼‰ï¼Œåº“中合计 2 ä¸‡ä½™æ¡çœŸå®žè®°å½•。仅靠关键字会**丢弃这些真实心电单**。
     */
    public boolean matchCheckName(String hisCheckName) {
        if (!StringUtils.hasText(hisCheckName)) {
            return false;
        }
        String normalized = hisCheckName.trim().replaceAll("\\s+", "");
        // 1) éžå¿ƒç”µæŽ’除词优先判否(防止歧义名称被后面的关键字误收)
        for (String kw : NON_ECG_EXCLUDE_KEYWORDS) {
            if (normalized.contains(kw)) {
                return false;
            }
        }
        // 2) åç§°ç™½åå•匹配
        //    é‡‡ç”¨**前缀匹配**而非全等:HIS çš„名称常带前后缀变体
        //    ï¼ˆå¦‚"24小时动态血压监测"= å‰ç¼€"动态血压" + "监测";
        //      "动态心电图+血压"以"动态心电"开头),前缀匹配可覆盖长尾变体。
        //    å®‰å…¨å‰æï¼šæŽ’除词已在第 1 æ­¥å…ˆè¡Œæ‹¦æˆªï¼Œä¸ä¼šå‡ºçް"彩超…"被前缀误收。
        if (HisCheckNameEnum.getByName(normalized) != null) {
            return true;
        }
        for (String name : ECG_NAME_WHITELIST) {
            if (normalized.startsWith(name)) {
                return true;
            }
        }
        // 3) å…³é”®å­—兜底(白名单之外的长尾名称变体)
        return normalized.contains(ECG_NAME_KEYWORD);
    }
    /**
     * ä¾æ® B(多值版):报文中的检查项目代码/名称可能是**逗号分隔的多个项目**。
     * <p>
     * å®žæµ‹ {@code S050401}/{@code S050501} çš„ {@code ZDY.jcxmcode}/{@code ZDY.jcxmname}
     * å½¢å¦‚:
     * <pre>
     *   jcxmcode=1393,1506              jcxmname=右肘正侧位,左肩关节正侧位
     *   jcxmcode=35672306,35672504,...   jcxmname=彩超右下肢动脉,彩超右下肢静脉,...
     * </pre>
     * åªè¦**任一**项目属心电即判定为心电单。
     *
     * @param hisCheckCodes é€—号分隔的检查项目代码,可为空
     * @param hisCheckNames é€—号分隔的检查项目名称,可为空
     * @return æ˜¯å¦å‘½ä¸­å¿ƒç”µæ£€æŸ¥é¡¹ç›®
     */
    public boolean matchCheckItems(String hisCheckCodes, String hisCheckNames) {
        return matchAny(hisCheckCodes, this::matchCheckCode)
                || matchAny(hisCheckNames, this::matchCheckName);
    }
    /** å°†åˆ†éš”符拆开的值逐个交给判定函数,任一命中即返回 true */
    private boolean matchAny(String joined, java.util.function.Predicate<String> matcher) {
        if (!StringUtils.hasText(joined)) {
            return false;
        }
        for (String part : joined.split("[,,;;]")) {
            if (matcher.test(part.trim())) {
                return true;
            }
        }
        return false;
    }
    /**
     * ä¾æ® B(S050401/S050501 ä¸“用):从 {@code ZDY.jcxmcode} / {@code ZDY.jcxmname}
     * ç»¼åˆåˆ¤å®šæ˜¯å¦å±žå¿ƒç”µæ£€æŸ¥ã€‚
     */
    public boolean matchZdyCheckItems(Object zdy) {
        if (!(zdy instanceof java.util.Map)) {
            return false;
        }
        java.util.Map<?, ?> m = (java.util.Map<?, ?>) zdy;
        Object code = m.get("jcxmcode");
        Object name = m.get("jcxmname");
        return matchCheckItems(code == null ? null : code.toString(),
                name == null ? null : name.toString());
    }
    /** ä¾æ® C:执行科室代码是否在白名单内(白名单为空时视为不参与判定) */
    public boolean matchDeptCode(String hisDeptCode) {
        if (ecgDeptCodes.isEmpty() || !StringUtils.hasText(hisDeptCode)) {
            return false;
        }
        String trimmed = hisDeptCode.trim();
        return ecgDeptCodes.stream().anyMatch(c -> c.equalsIgnoreCase(trimmed));
    }
    /**
     * ä¾¿æ·æ–¹æ³•:仅判定,并在非心电单时输出可排查的 DEBUG æ—¥å¿—。
     *
     * @param source è°ƒç”¨æ¥æºæ ‡è¯†ï¼Œç”¨äºŽæ—¥å¿—定位(如 {@code S0405})
     */
    public boolean isEcgOrder(String source, String applyNo, String hisCheckCode,
                              String hisCheckName, String hisDeptCode) {
        Result r = judge(applyNo, hisCheckCode, hisCheckName, hisDeptCode);
        if (!r.isEcg()) {
            // ç”¨ INFO çº§ï¼šç”Ÿäº§æ—¥å¿—级别为 INFO,若用 DEBUG åˆ™è·³è¿‡è®°å½•完全不可见,
            // æ›¾å› æ­¤æ¼æŽ‰äº† S050401/S050501 æ··å…¥éžå¿ƒç”µå•的问题(无法从日志发现)。
            log.info("[{}][非心电业务单,跳过处理] applyNo={} æ£€æŸ¥ä»£ç ={} é¡¹ç›®åç§°={} æ‰§è¡Œç§‘室={} åŽŸå› ={}",
                    source, applyNo, hisCheckCode, hisCheckName, hisDeptCode, r.getReason());
        }
        return r.isEcg();
    }
    /**
     * ä¾¿æ·æ–¹æ³•(含 ZDY æ£€æŸ¥é¡¹ç›®ï¼‰ï¼šç”¨äºŽ {@code S050401}/{@code S050501}。
     * <p>
     * éžå¿ƒç”µå•时输出 INFO çº§æ—¥å¿—(这两个服务此前**未做任何过滤**,导致放射/超声单
     * è¢«å½“作心电申请单采集:实测 S050401 æœ‰ 109/256、S050501 æœ‰ 4/19 å±žéžå¿ƒç”µï¼‰ã€‚
     *
     * @param zdy æŠ¥æ–‡ä¸­çš„ {@code ZDY} èŠ‚ç‚¹
     */
    public boolean isEcgOrderWithZdy(String source, String applyNo, String hisDeptCode, Object zdy) {
        Result r = judgeWithZdy(applyNo, hisDeptCode, zdy);
        if (!r.isEcg()) {
            Object name = zdy instanceof java.util.Map ? ((java.util.Map<?, ?>) zdy).get("jcxmname") : null;
            Object room = zdy instanceof java.util.Map ? ((java.util.Map<?, ?>) zdy).get("roomname") : null;
            log.info("[{}][非心电业务单,跳过处理] applyNo={} æ£€æŸ¥é¡¹ç›®={} æˆ¿é—´={} æ‰§è¡Œç§‘室={} åŽŸå› ={}",
                    source, applyNo, name, room, hisDeptCode, r.getReason());
        }
        return r.isEcg();
    }
    /** ä»Žå½¢å¦‚ {@code component2={observationRequest={...code={code=Y1202065,...}}}} çš„结构中取检查项目代码 */
    @SuppressWarnings("unchecked")
    public static String extractCheckCode(Object component2) {
        return Optional.ofNullable(component2)
                .filter(v -> v instanceof java.util.Map)
                .map(v -> (java.util.Map<String, Object>) v)
                .map(m -> (java.util.Map<String, Object>) m.get("observationRequest"))
                .map(or -> (java.util.Map<String, Object>) or.get("code"))
                .map(code -> {
                    Object c = code.get("code");
                    return c == null ? null : c.toString();
                })
                .orElse(null);
    }
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/jobrecord/JobRecordServiceImpl.java
@@ -19,6 +19,9 @@
import cn.lihu.jh.framework.common.util.object.BeanUtils;
import cn.lihu.jh.module.ecg.dal.mysql.jobrecord.JobRecordMapper;
import cn.lihu.jh.module.ecg.dal.mysql.queue.QueueMapper;
import cn.lihu.jh.module.ecg.enums.QueueStatusEnum;
import lombok.extern.slf4j.Slf4j;
import javax.annotation.Resource;
@@ -33,10 +36,15 @@
 */
@Service
@Validated
@Slf4j
public class JobRecordServiceImpl implements JobRecordService {
    @Resource
    private JobRecordMapper jobRecordMapper;
    /** ã€æ’¤é”€æ ‡è¯†ã€‘读当日队列状态,判断"完成是否被撤销过" */
    @Resource
    private QueueMapper queueMapper;
    @Resource
    private DevRentMapper devRentMapper;
@@ -98,11 +106,56 @@
            }
            // éœ€æ±‚1:回填患者信息(批量,避免 N+1)
            fillPatientInfo(jobRecordDOList);
            // ã€æ’¤é”€æ ‡è¯†ã€‘标记"完成被撤销过、还没重新完成"的记录
            fillUndoneFlag(jobRecordDOList);
        }
        return  pageResult;
    }
    /**
     * ã€æ’¤é”€æ ‡è¯†ã€‘ç»™"已完成记录"补上 {@code undone}。
     *
     * <p>撤销「检查完成」只是把队列行 40(已就诊) æ”¹å›ž 20(准备中),**不碰 job_record**;
     * å› æ­¤åˆ¤æ–­è§„则是:该患者**今日**对应检查项目的队列行存在、且状态 â‰  40 â†’ å·²è¢«æ’¤é”€ã€‚
     * æ‚£è€…再次完成后队列行会回到 40 â†’ è‡ªåŠ¨æ¢å¤æˆ"已完成",无需写库、也不会漏清标记。
     *
     * <p>一次批量查询解决整页(按 patId å–当日队列状态,再在内存里按 patId+checkType åŒ¹é…ï¼‰ã€‚
     */
    private void fillUndoneFlag(List<JobRecordDO> jobRecordDOList) {
        List<String> patIdList = jobRecordDOList.stream()
                .map(JobRecordDO::getPatId)
                .filter(Objects::nonNull)
                .distinct()
                .toList();
        if (patIdList.isEmpty()) {
            return;
        }
        Map<String, Integer> statusMap = new HashMap<>();
        try {
            for (Map<String, Object> row : queueMapper.selectTodayStatusByPatIds(patIdList)) {
                Object patId = row.get("patId");
                Object checkType = row.get("checkType");
                Object status = row.get("status");
                if (null != patId && null != checkType && status instanceof Number) {
                    statusMap.put(patId + "#" + checkType, ((Number) status).intValue());
                }
            }
        } catch (Exception e) {
            log.warn("[fillUndoneFlag] è¯»å–当日队列状态失败,撤销标识按【未撤销】处理", e);
            return;
        }
        for (JobRecordDO row : jobRecordDOList) {
            if (null == row.getPatId() || null == row.getCheckType()) {
                row.setUndone(false);
                continue;
            }
            Integer status = statusMap.get(row.getPatId() + "#" + row.getCheckType());
            // é˜Ÿåˆ—行不存在(历史记录/已清理)→ è§†ä¸ºæœªæ’¤é”€ï¼›å­˜åœ¨ä¸”不是 40 â†’ å·²æ’¤é”€
            row.setUndone(null != status && QueueStatusEnum.FINISH.getStatus() != status);
        }
    }
    // ==================== éœ€æ±‚1:回填患者信息 18 é¡¹ ====================
    // å›žå¡«é“¾è·¯ï¼ˆä¸‰çº§ï¼‰ï¼š
    //   job_record.rentId â†’ dev_rent.applyNo â†’ appointment
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/queue/BedDoctorOnCallable.java
@@ -12,8 +12,10 @@
    private String bedNo;
    private Long docId;
    private String docName;
    /** å·¥ä½ä¸»é”®ï¼ˆå¯é€‰ï¼‰ï¼šä¼˜å…ˆæŒ‰å®ƒå®šä½ï¼Œé¿å… bed_no ä¸Žè¯·æ±‚值有肉眼难辨差异时更新 0 è¡Œ */
    private Integer bedId;
    public BedDoctorOnCallable(QueueServiceTxFunctions queueService, Long roomId, String bedNo, Long docId, String docName)
    public BedDoctorOnCallable(QueueServiceTxFunctions queueService, Long roomId, String bedNo, Long docId, String docName, Integer bedId)
    {
        super();
        this.queueService = queueService;
@@ -21,10 +23,11 @@
        this.bedNo = bedNo;
        this.docId = docId;
        this.docName = docName;
        this.bedId = bedId;
    }
    public ErrorCode call() throws Exception {
        ErrorCode ec = queueService.bedDoctorOn(roomId, bedNo, docId, docName);
        ErrorCode ec = queueService.bedDoctorOn(roomId, bedNo, docId, docName, bedId);
        if (GlobalErrorCodeConstants.SUCCESS.equals(ec)) {
            queueService.hurryupOneBed(roomId, bedNo);
        }
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/queue/QueueService.java
@@ -57,6 +57,14 @@
     * ä¸€ä¸ªå·¥ä½ å¯èƒ½æ”¯æŒå¤šä¸ªæ£€æŸ¥ç±»åž‹ï¼Œ
     */
    void startHurryUpOneBed(Long roomId, String bedNo);
    /**
     * ç«‹å³è¡¥äººï¼ˆçŽ°åœºè‡ªæ•‘ + è‡ªåŠ©è¯Šæ–­ï¼‰ï¼šæ‰‹åŠ¨è§¦å‘ä¸€æ¬¡è¯¥å·¥ä½çš„è‡ªåŠ¨åˆ†æµï¼Œ
     * å¹¶è¿”回「系统开诊状态 / æ˜¯å¦åœæ­¢å€™è¯Š / å€™è¯Šä¸Šé™ / è¡¥äººå‰åŽäººæ•° / ç»“论」。
     *
     * <p>调用方必须保证"当前登录人是该工位主人"(Controller ä¸Šç”¨ {@code @RequireBedOwner})。
     */
    java.util.Map<String, Object> hurryUpNow(Long roomId, String bedNo);
    void startHurryUpOneCheckType(Integer checkType);
    void startBedReload();
@@ -67,8 +75,35 @@
    ErrorCode startBedClose(Long roomId, String bedNo);
    ErrorCode startBedDoctorPause(Long roomId, String bedNo, Long docId, String docName);
    ErrorCode startBedDoctorResume(Long roomId, String bedNo, Long docId, String docName);
    ErrorCode startBedDoctorOn(Long roomId, String bedNo, Long docId, String docName);
    /**
     * åŒ»ç”Ÿå…¥åº§ï¼ˆæŠŠå·¥ä½ä¸»äººè®¾ä¸ºè¯¥åŒ»ç”Ÿï¼‰ã€‚
     *
     * @param bedId å·¥ä½ä¸»é”®ï¼ˆå¯é€‰ï¼‰ï¼šä¼ å…¥æ—¶**优先按它定位**,避免 bed_no ä¸Žè¯·æ±‚值存在
     *              è‚‰çœ¼éš¾è¾¨å·®å¼‚(结尾空格/大小写/全角)导致"明明空闲却入座失败"
     */
    ErrorCode startBedDoctorOn(Long roomId, String bedNo, Long docId, String docName, Integer bedId);
    /**
     * ã€Œæœ¬è¯Šå®¤æŽ’队中(未分配工位)」的患者列表(工作台"本诊室排队中(未分配)"分区用)。
     *
     * <p>候诊列表按 {@code room_id + bed_no} æŸ¥ï¼Œåªå«**已分流到本工位**的患者;
     * æŽ’队中(10) çš„æ‚£è€… {@code bed_no} ä¸ºç©ºï¼ˆå¤§å±ä¼šæ˜¾ç¤ºã€å·¥ä½œå°çœ‹ä¸åˆ°ï¼‰ï¼Œè¿™é‡ŒæŒ‰æœ¬å·¥ä½
     * å¼€å±•的检查项目单独查出来,供医生手工拉入。
     */
    List<cn.lihu.jh.module.ecg.dal.dataobject.queue.QueueDO> getUnassignedWaitingPatient(Long roomId, String bedNo);
    /**
     * æ‰‹å·¥æŠŠæŸä½ã€ŒæŽ’队中」患者拉入本工位(→ å‡†å¤‡ä¸­ï¼‰ã€‚
     *
     * <p>调用方必须保证"当前登录人是该工位主人"(Controller ä¸Šç”¨ {@code @RequireBedOwner})。
     */
    ErrorCode pullWaitingPatient(Long roomId, String bedNo, Integer queueId);
    ErrorCode startBedDoctorOff(Long roomId, String bedNo, Long docId, String docName);
    /**
     * ã€æœ¬æ¬¡éœ€æ±‚】管理员强制清空工位(不要求是本人)—— åŒ»ç”Ÿä¸‹ç­å¿˜ç¦»åº§ / æ¢äººä½¿ç”¨åŒä¸€å·¥ä½æ—¶ç”¨ã€‚
     */
    ErrorCode startBedDoctorOffByAdmin(Long roomId, String bedNo);
    ErrorCode startNextPatient(Long roomId, String bedNo);
    ErrorCode startNextInstallPatient(Long roomId, String bedNo);
@@ -96,7 +131,11 @@
     */
    void queue(QueueSaveReqVO queueSaveReqVO);
    List<QueueDO> selectBookSeqNumByAppointIdList(List<Long> appointIdList);
    /**
     * æŒ‰é¢„约单 id æ‰¹é‡å– queue è¡Œï¼ˆé¢„约签到前回填「已排队 + æŽ’队号 seq_num」用)。
     * <p>原方法名为 {@code selectBookSeqNumByAppointIdList},号统一到 seq_num åŽæ”¹åã€‚
     */
    List<QueueDO> selectQueueByAppointIdList(List<Long> appointIdList);
    /**
     * å¸¸è§„检查 åŒ»ç”Ÿå«ä¸‹ä¸€ä½æ‚£è€…
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;
    }
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);
    }
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/queuesequence/QueueSequenceService.java
@@ -86,5 +86,20 @@
    Integer distributeSeqNoByRule(Integer checkType, Integer timeslot,
                                 Integer existingSeqNo, boolean urgent);
    /**
     * ã€æœ¬éœ€æ±‚】同上,但增加「是否 VIP / ä¸´æ—¶åŠ å·ã€æ ‡è®°ã€‚
     *
     * <p>按时段预约(规则 2)下的两条通道:
     * <ul>
     *   <li>VIP / åŠ å·æ‚£è€…ï¼š**直接取本段预留号**;本段预留也满 â†’ æ€»é¢„留池({@code time_slot=0});</li>
     *   <li>普通患者:本段普通号 â†’ æœ¬æ®µé¢„留号 â†’ æ€»é¢„留池。</li>
     * </ul>
     * ä¸¤æ¡é€šé“最终都能落到「总预留池」,而总预留池**不设上限** â‡’ ä¸ä¼šå‡ºçŽ°ç­¾åˆ°å¤±è´¥ã€‚
     *
     * @param vip æ˜¯å¦ VIP / ä¸´æ—¶åŠ å·ï¼ˆ{@code appointment.is_vip=1} æˆ– HIS åŠ æ€¥ {@code jjbz=J})
     */
    Integer distributeSeqNoByRule(Integer checkType, Integer timeslot,
                                 Integer existingSeqNo, boolean urgent, boolean vip);
    void initNumber();
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/queuesequence/QueueSequenceServiceImpl.java
@@ -100,7 +100,16 @@
        validateTimeslotNotDuplicated(createReqVO.getCheckType(), createReqVO.getTimeSlot(), null);
        // æ’å…¥
        QueueSequenceDO queueSequence = BeanUtils.toBean(createReqVO, QueueSequenceDO.class);
        if (null == queueSequence.getTimeSlot() || 0 != queueSequence.getTimeSlot()) {
            // ã€æœ¬éœ€æ±‚】号段起点 / é¢„留起点由系统计算,不接受前端随意传值(否则号段会重叠)
            queueSequence.setQueueSet(null);
            queueSequence.setQueueVipSet(null);
        }
        queueSequenceMapper.insert(queueSequence);
        // ã€æœ¬éœ€æ±‚】新增/编辑后统一重排该检查类型的号段(保证"段与段首尾相接")
        if (null != createReqVO.getCheckType()) {
            recalcSegmentBounds(createReqVO.getCheckType());
        }
        // è¿”回
        return queueSequence.getId();
    }
@@ -114,7 +123,104 @@
                updateReqVO.getId());
        // æ›´æ–°
        QueueSequenceDO updateObj = BeanUtils.toBean(updateReqVO, QueueSequenceDO.class);
        // ã€æœ¬éœ€æ±‚】起始序号由系统重排,忽略前端传值(前端已置为只读)
        updateObj.setQueueSet(null);
        updateObj.setQueueVipSet(null);
        queueSequenceMapper.updateById(updateObj);
        if (null != updateReqVO.getCheckType()) {
            recalcSegmentBounds(updateReqVO.getCheckType());
        }
    }
    /**
     * ã€æœ¬éœ€æ±‚】按「时段升序」重排某检查类型的号段:
     * <pre>
     *   æ®µ i:queue_set = ä¸Šä¸€æ®µçš„ queue_vip_full(首段 0)
     *         queue_full = queue_set + P
     *         queue_vip_set = queue_full
     *         queue_vip_full = queue_vip_set + V
     *   å…¶ä¸­ P / V = è¯¥è¡Œå½“前的(queue_full - queue_set)、(queue_vip_full - queue_full),
     *   å³å„段的"额度"保持不变,只把起点串起来。
     * </pre>
     * è¿™æ ·æ— è®ºæ–°å¢ž/删除/编辑时段,都不会出现号段重叠或空洞错位。
     * <p>总预留池(time_slot = 0)不参与重排,只把起点对齐到最后一段之后。
     */
    private void recalcSegmentBounds(Integer checkType) {
        if (null == checkType) {
            return;
        }
        List<QueueSequenceDO> all = queueSequenceMapper.selectTimeslotByCheckType(checkType);
        if (null == all || all.isEmpty()) {
            return;
        }
        all.sort(Comparator.comparingInt(r -> null == r.getTimeSlot() ? 0 : r.getTimeSlot()));
        CheckTypeDO ct = checkTypeMapper.getCheckTypeByValue(checkType);
        int cursor = 0;
        int slotCount = 0;
        for (QueueSequenceDO row : all) {
            int slot = null == row.getTimeSlot() ? 0 : row.getTimeSlot();
            if (0 == slot) {
                continue;   // æ€»é¢„留池最后单独处理
            }
            slotCount++;
            // é¢åº¦ï¼ˆå®½ï¼‰ï¼šä¼˜å…ˆæ²¿ç”¨è¯¥è¡ŒçŽ°æœ‰å·æ®µå®½åº¦ï¼›æœªçŸ¥/非正时回退到检查类型配置
            int p = width(row.getQueueSet(), row.getQueueFull());
            if (p <= 0) {
                p = (null == ct || null == ct.getTimeslotBookNum()) ? 0 : ct.getTimeslotBookNum();
            }
            int v = width(row.getQueueFull(), row.getQueueVipFull());
            if (v < 0) {
                v = (null == ct || null == ct.getTimeslotReservedNum()) ? 0 : ct.getTimeslotReservedNum();
            }
            QueueSequenceDO update = new QueueSequenceDO();
            update.setId(row.getId());
            update.setQueueSet(cursor);
            update.setQueueFull(cursor + p);
            update.setQueueVipSet(cursor + p);
            update.setQueueVipFull(cursor + p + v);
            // æ¸¸æ ‡åª**上抬**到号段起点,绝不**下调**:
            // ä¸‹è°ƒä¼šè®©å·²å‘出的号被再次发出(重号)。若游标已超过上限,说明该段已超发,
            // å–号时自然会落到「预留号 â†’ æ€»é¢„留池」。
            update.setQueueNo(raiseAtLeast(row.getQueueNo(), cursor));
            update.setQueueVipNo(raiseAtLeast(row.getQueueVipNo(), cursor + p));
            queueSequenceMapper.updateById(update);
            cursor = cursor + p + v;
        }
        // æ€»é¢„留池:起点对齐到最后一段之后
        QueueSequenceDO pool = queueSequenceMapper.selectTotalPool(checkType);
        if (null != pool) {
            int poolFull = Math.max(null == pool.getQueueVipFull() ? 0 : pool.getQueueVipFull(), cursor);
            QueueSequenceDO update = new QueueSequenceDO();
            update.setId(pool.getId());
            update.setQueueVipSet(cursor);
            update.setQueueVipFull(poolFull);
            update.setQueueVipNo(raiseAtLeast(pool.getQueueVipNo(), cursor));
            queueSequenceMapper.updateById(update);
        }
        log.info("[recalcSegmentBounds] å·²é‡æŽ’号段. checkType={} æ®µæ•°={} æœ«å°¾æœ€å¤§å·={}",
                checkType, slotCount, cursor);
    }
    /** å·æ®µå®½åº¦ï¼ˆç»ˆæ­¢ - èµ·å§‹ï¼‰ï¼Œä»»ä¸€ä¸º null è¿”回 -1 è¡¨ç¤º"未知" */
    private int width(Integer from, Integer to) {
        if (null == from || null == to) {
            return -1;
        }
        return to - from;
    }
    /**
     * æ¸¸æ ‡åªä¸ŠæŠ¬ã€ä¸ä¸‹è°ƒï¼ˆä¸‹è°ƒä¼šå¯¼è‡´å·²å‘出的号被再次发出 â‡’ é‡å·ï¼‰ã€‚
     */
    private Integer raiseAtLeast(Integer value, int min) {
        int v = null == value ? min : value;
        return Math.max(v, min);
    }
    /**
@@ -140,9 +246,14 @@
    @Override
    public void deleteQueueSequence(Integer id) {
        // æ ¡éªŒå­˜åœ¨
        validateQueueSequenceExists(id);
        QueueSequenceDO exist = queueSequenceMapper.selectById(id);
        if (null == exist) {
            throw exception(QUEUE_SEQUENCE_NOT_EXISTS);
        }
        // åˆ é™¤
        queueSequenceMapper.deleteById(id);
        // ã€æœ¬éœ€æ±‚】删除后重排号段(否则后续时段的起点会留空洞)
        recalcSegmentBounds(exist.getCheckType());
    }
    private void validateQueueSequenceExists(Integer id) {
@@ -199,64 +310,118 @@
    @Override
    @Transactional
    public Integer distributeSeqNo(Integer checkType, Integer timeslot) {
        // ã€æœ¬éœ€æ±‚:按时段预约】普通患者三级取号,**不再跨段借号**:
        //   â‘  æœ¬æ—¶æ®µæ™®é€šå·ï¼ˆqueue_no   < queue_full)
        //   â‘¡ æœ¬æ—¶æ®µé¢„留号(queue_vip_no < queue_vip_full)
        //   â‘¢ æ€»é¢„留池(time_slot=0,**不设上限**)
        // ä»»ä½•一级成功即返回;③ æ°¸ä¸å¤±è´¥ â‡’ ä¸å­˜åœ¨"签不上"。
        // æ— é¢„约时段(timeslot ä¸º null/0/字典外的值)时 â‘ â‘¡ è‡ªç„¶æŸ¥ä¸åˆ°è¡Œï¼Œç›´æŽ¥è½åˆ° â‘¢ã€‚
        // â‘  æœ¬æ®µæ™®é€šå·
        Integer curSeqNo = queueSequenceMapper.selectQueueNoForUpdate(checkType, timeslot);
        if (null == curSeqNo) {
            // ã€ä¿®å¤ã€Œæ—¶æ®µä¸å…¨/已满导致没法签到」】
            // åŽŸé€»è¾‘ï¼šåªå–"字典里的紧邻下一个时段",且要求该时段在 queue_sequence ä¸­å­˜åœ¨ã€‚
            // çŽ°å®žé—®é¢˜ï¼ˆå®žæµ‹ï¼‰ï¼š
            //   Â· å­—å…¸ ecg_book_timeslot æœ‰ 21 ä¸ªæ—¶æ®µï¼Œä½† queue_sequence ä¸­ checkType=400
            //     åªæœ‰ 13 ä¸ªã€checkType=100 åªæœ‰ 17 ä¸ª â€”— éƒ¨åˆ†æ—¶æ®µæ ¹æœ¬æ²¡æœ‰å·æ± è¡Œï¼›
            //   Â· HIS ä¼šä¸‹å‘ 60 åˆ†é’Ÿç²’度的 bookTimeslot(如 10001100),字典里没有该值,
            //     getNextTimeSlot ç›´æŽ¥ return null;
            //   Â· ä¸ªåˆ«æ—¶æ®µå®¹é‡å¾ˆå°ï¼ˆå¹³æ¿è¿åЍ timeslot_book_num=3),很快销完。
            // ä¸‰è€…任一命中 â†’ æŠ› QUEUE_SEQUENCE_TIMESLOT_FULL â†’ **患者没法签到**。
            //
            // çŽ°åœ¨æ”¹ä¸ºï¼š**向后最多找 N ä¸ªæ—¶æ®µï¼Œå–第一个仍有余量的**(下面 findAvailableTimeslot å†…实现),
            // å¤§å¹…降低"因个别时段缺失/已满就签到失败"的概率;确实都满了才抛错。
            Integer nextTimeslot = findAvailableTimeslot(checkType, timeslot, false);
            if (null == nextTimeslot) {
                log.error("[distributeSeqNo] è¯¥æ£€æŸ¥ç±»åž‹å‘后 {} ä¸ªæ—¶æ®µå†…均无可取号时段. checkType={} timeslot={}",
                        MAX_TIMESLOT_LOOKAHEAD, checkType, timeslot);
                throw exception(QUEUE_SEQUENCE_TIMESLOT_FULL);
        if (null != curSeqNo) {
            Integer updateNum = queueSequenceMapper.updateGivenCheckTypeTimeslotSeqNo(checkType, timeslot, curSeqNo);
            if (null != updateNum && updateNum > 0) {
                return curSeqNo + 1;
            }
            curSeqNo = queueSequenceMapper.selectQueueNoForUpdate(checkType, nextTimeslot);
            if (null == curSeqNo) {
                // æŽ¢æµ‹ä¸Žå–号之间被并发抢空,保守报错(由前端提示重新签到)
                log.warn("[distributeSeqNo] æŽ¢æµ‹åˆ°çš„æ—¶æ®µéšå³è¢«å æ»¡. checkType={} timeslot={}",
                        checkType, nextTimeslot);
                throw exception(QUEUE_SEQUENCE_TIMESLOT_FULL);
            }
            Integer updateRowNum = queueSequenceMapper.updateGivenCheckTypeTimeslotSeqNo(checkType, nextTimeslot, curSeqNo);
            if (null == updateRowNum || updateRowNum == 0) {
                throw exception(QUEUE_SEQUENCE_TIMESLOT_EXCEPTION);
            }
        } else {
            Integer updateRowNum = queueSequenceMapper.updateGivenCheckTypeTimeslotSeqNo(checkType, timeslot, curSeqNo);
            if (null == updateRowNum || updateRowNum == 0) {
                throw exception(QUEUE_SEQUENCE_TIMESLOT_EXCEPTION);
            }
            // å¹¶å‘被抢空 â†’ è½åˆ° â‘¡
            log.info("[distributeSeqNo] æœ¬æ®µæ™®é€šå·å¹¶å‘被抢空,降级到预留号. checkType={} timeslot={}", checkType, timeslot);
        }
        return curSeqNo + 1;
    }
        // â‘¡ æœ¬æ®µé¢„留号
        Integer curVipNo = queueSequenceMapper.selectQueueVipNoForUpdate(checkType, timeslot);
        if (null != curVipNo) {
            Integer updateNum = queueSequenceMapper.updateGivenCheckTypeTimeslotVipSeqNo(checkType, timeslot, curVipNo);
            if (null != updateNum && updateNum > 0) {
                log.info("[distributeSeqNo] æ™®é€šå·å·²æ»¡ï¼Œæ”¹å‘本段预留号. checkType={} timeslot={} seqNo={}",
                        checkType, timeslot, curVipNo + 1);
                return curVipNo + 1;
            }
            log.info("[distributeSeqNo] æœ¬æ®µé¢„留号并发被抢空,降级到总预留池. checkType={} timeslot={}", checkType, timeslot);
        }
        // â‘¢ æ€»é¢„留池(不设上限,保证一定能签到)
        return nextTotalPoolSeqNo(checkType, timeslot);
    }
    /**
     * ã€æœ¬éœ€æ±‚】VIP / ä¸´æ—¶åŠ å·æ‚£è€…å–å·ï¼š**直接取本段预留号**;本段预留也满 â†’ æ€»é¢„留池。
     * <p>
     * ä¸Žæ™®é€šé€šé“的差别只在于"跳过本段普通号",其余(预留号、总池兜底)完全一致。
     */
    @Override
    @Transactional
    public Integer distributeVipSeqNo(Integer checkType, Integer timeslot) {
        Integer curVipSeqNo = queueSequenceMapper.selectQueueVipNoForUpdate(checkType, timeslot);
        if (null == curVipSeqNo) {
            throw exception(QUEUE_SEQUENCE_TIMESLOT_FULL);
        if (null != curVipSeqNo) {
            Integer updateRowNum = queueSequenceMapper.updateGivenCheckTypeTimeslotVipSeqNo(checkType, timeslot, curVipSeqNo);
            if (null != updateRowNum && updateRowNum > 0) {
                return curVipSeqNo + 1;
            }
            log.info("[distributeVipSeqNo] æœ¬æ®µé¢„留号并发被抢空,降级到总预留池. checkType={} timeslot={}", checkType, timeslot);
        }
        return nextTotalPoolSeqNo(checkType, timeslot);
    }
    /**
     * ã€æœ¬éœ€æ±‚】总预留池取号({@code time_slot = 0} é‚£ä¸€è¡Œï¼‰â€”— **永不失败**。
     *
     * <h3>为什么不会满</h3>
     * æ€»æ± çš„ {@code queue_vip_full} åœ¨å‘号时同步"顶高"(见 Mapper çš„
     * {@code advanceTotalPoolVipNo}),因此 {@code queue_vip_no} æ°¸è¿œå¯ä»¥ç»§ç»­ +1。
     * è¿™æ˜¯"绝不出现签不上"的最后一道兜底。
     *
     * <h3>总池行缺失时自动补</h3>
     * å·æ®µæ”¾åœ¨å½“天所有时段的最大号之后({@code max(queue_vip_full)}),
     * ä¿è¯æ€»æ± å· **大于所有时段号** â‡’ æŽ’序天然排在全天最后。
     */
    private Integer nextTotalPoolSeqNo(Integer checkType, Integer timeslot) {
        Integer curVipNo = null;
        try {
            curVipNo = queueSequenceMapper.selectTotalPoolVipNo(checkType);
        } catch (Exception e) {
            log.warn("[nextTotalPoolSeqNo] è¯»å–总预留池异常,按缺失处理. checkType={}", checkType, e);
        }
        Integer updateRowNum = queueSequenceMapper.updateGivenCheckTypeTimeslotVipSeqNo(checkType, timeslot, curVipSeqNo);
        if (null == updateRowNum || updateRowNum == 0) {
        if (null == curVipNo) {
            // çŽ°åœºå…œåº•ï¼šè‡ªåŠ¨è¡¥ä¸€è¡Œæ€»é¢„ç•™æ± ï¼ˆå¹‚ç­‰ï¼šä»¥ æ£€æŸ¥ç±»åž‹ + time_slot=0 å”¯ä¸€ï¼‰
            Integer base = queueSequenceMapper.selectMaxVipFullOfCheckType(checkType);
            if (null == base) {
                base = 0;
            }
            QueueSequenceDO pool = new QueueSequenceDO();
            pool.setCheckType(checkType);
            pool.setTimeSlot(0);
            pool.setQueueSet(0);
            pool.setQueueNo(0);
            pool.setQueueFull(0);
            pool.setQueueVipSet(base);
            pool.setQueueVipNo(base);
            pool.setQueueVipFull(base);
            try {
                queueSequenceMapper.insert(pool);
            } catch (Exception e) {
                // å¹¶å‘下可能已被别的线程插入,忽略后继续读
                log.warn("[nextTotalPoolSeqNo] è¡¥æ€»é¢„留池行失败(可能并发已插入),继续取号. checkType={}", checkType, e);
            }
            if (null == queueSequenceMapper.selectTotalPoolVipNo(checkType)) {
                // æ’入后仍读不到(极端情况)→ ç”¨å½“天最大号兜底,保证能签到
                Integer maxFull = queueSequenceMapper.selectMaxVipFullOfCheckType(checkType);
                log.error("[nextTotalPoolSeqNo] æ€»é¢„留池不可用,使用最大号兜底. checkType={} maxFull={}", checkType, maxFull);
                return (null == maxFull ? 0 : maxFull) + 1;
            }
        }
        Integer updateNum = queueSequenceMapper.advanceTotalPoolVipNo(checkType);
        if (null == updateNum || updateNum == 0) {
            throw exception(QUEUE_SEQUENCE_TIMESLOT_EXCEPTION);
        }
        return curVipSeqNo + 1;
        Integer after = queueSequenceMapper.selectTotalPoolVipNo(checkType);
        if (null == after) {
            throw exception(QUEUE_SEQUENCE_TIMESLOT_EXCEPTION);
        }
        log.info("[nextTotalPoolSeqNo] å·²ä»Žæ€»é¢„留池取号(本段已满或没有时段). checkType={} timeslot={} seqNo={}",
                checkType, timeslot, after);
        return after;
    }
    /**
@@ -283,6 +448,21 @@
    @Transactional
    public Integer distributeSeqNoByRule(Integer checkType, Integer timeslot,
                                        Integer existingSeqNo, boolean urgent) {
        // å…¼å®¹æ—§è°ƒç”¨æ–¹ï¼šæœªä¼  VIP æ ‡è®°æ—¶æŒ‰"非 VIP"处理
        return distributeSeqNoByRule(checkType, timeslot, existingSeqNo, urgent, false);
    }
    /**
     * ã€æœ¬éœ€æ±‚】带 VIP/加号标记的取号入口。
     * <p>
     * ä¸Žå››å‚版本的唯一差别:按时段预约(TIMESLOT)下,VIP/加号患者**直接取本段预留号**
     * ï¼ˆè§ {@link #distributeVipSeqNo}),而不是先取普通号;签到序号(CHECKIN)下
     * VIP/加号同样可占用预留号(每 9 ç•™ 1 çš„预留位)。
     */
    @Override
    @Transactional
    public Integer distributeSeqNoByRule(Integer checkType, Integer timeslot,
                                        Integer existingSeqNo, boolean urgent, boolean vip) {
        SeqRuleEnum rule = resolveRule(checkType);
        switch (rule) {
@@ -295,15 +475,18 @@
                }
                log.warn("[distributeSeqNoByRule] è§„则=预约号,但预约系统未下发号,回退到签到序号. checkType={}",
                        checkType);
                return nextCheckInSeqNo(checkType, urgent);
                return nextCheckInSeqNo(checkType, urgent || vip);
            case CHECKIN:
                // â‘¡ ç­¾åˆ°åºå·ï¼šå½“天同检查类型连续递增,每 9 ä¸ªé¢„ç•™ 1 ä¸ª
                return nextCheckInSeqNo(checkType, urgent);
                return nextCheckInSeqNo(checkType, urgent || vip);
            case TIMESLOT:
            default:
                // â‘¢ æ—¶æ®µå·ï¼šä¿æŒæ”¹é€ å‰çš„行为(含时段容量控制)
                // â‘¢ æ—¶æ®µå·ï¼šVIP/加号走「本段预留号 â†’ æ€»é¢„留池」;普通走「普通 â†’ é¢„ç•™ â†’ æ€»æ± ã€
                if (vip) {
                    return distributeVipSeqNo(checkType, timeslot);
                }
                return distributeSeqNo(checkType, timeslot);
        }
    }
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/room/RoomService.java
@@ -6,6 +6,8 @@
import cn.lihu.jh.module.ecg.controller.admin.room.vo.MonitorInfoVO;
import cn.lihu.jh.module.ecg.controller.admin.room.vo.RoomPageReqVO;
import cn.lihu.jh.module.ecg.controller.admin.room.vo.RoomSaveReqVO;
import cn.lihu.jh.module.ecg.controller.admin.room.vo.RoomSeatRespVO;
import cn.lihu.jh.module.ecg.controller.admin.room.vo.RoomSelectBedRespVO;
import cn.lihu.jh.module.ecg.dal.dataobject.room.RoomDO;
import cn.lihu.jh.module.ecg.dal.dataobject.room.RoomProfile;
@@ -50,6 +52,137 @@
    RoomProfile getRoomByIP(String ip, Long roomId);
    /**
     * ã€Œé€‰æ‹©å·¥ä½æ¨¡å¼ã€- æŒ‰æœ¬æœºIP匹配工位:返回该IP下的候选工位(本人已占用 â†’ ç©ºé—² â†’ è¢«ä»–人占用)。
     * <p>仅查询、不改变任何状态;自动入座由前端拿到结果后沿用原有的 bedDoctorOn æµç¨‹å®Œæˆã€‚</p>
     *
     * @param ip    æ¥è®¿IP
     * @param docId å½“前登录人ID(可能为空)
     */
    List<RoomSelectBedRespVO.RoomSelectBedItem> matchBedsByIp(String ip, Long docId);
    /**
     * ã€Œé€‰æ‹©å·¥ä½æ¨¡å¼ã€- æŠŠæœ¬æœºIP写到指定工位(只改 clinic_room.ip ä¸€ä¸ªå­—段)。
     *
     * @param id å·¥ä½ä¸»é”®
     * @param ip æ¥è®¿IP
     * @return å—影响行数
     */
    Integer bindIpToBed(Integer id, String ip);
    /**
     * è¯¥IP是否已经绑在别的工位上(冲突检测,供前端弹确认后再覆盖)。
     *
     * @param ip        å¾…绑定IP
     * @param excludeId æŽ’除的工位主键,可为空
     */
    List<RoomDO> listIpBindings(String ip, Integer excludeId);
    /**
     * ã€Œé€‰æ‹©å·¥ä½æ¨¡å¼ã€- ä¸€é”®è‡ªåŠ¨å…¥åº§ï¼šæŒ‰æ¥è®¿ IP åŒ¹é…å·¥ä½å¹¶**直接落座**(模式 1/3 ç”¨ï¼‰ã€‚
     *
     * <p>为什么要有这个接口:前端原来把「{@code get-ip-bed} åŒ¹é… â†’ {@code bind-ip} å†™ IP â†’
     * {@code bed-doctor-on} å…¥åº§ã€æ‹†æˆä¸‰ä¸ªè¯·æ±‚自己拼,任一步失败都只能回落到"手选工位",
     * çŽ°åœºè¡¨çŽ°å°±æ˜¯"登录后没有自动入座"且看不出原因。这里收敛成一次调用,并返回**识别到的 IP**
     * ä¸Ž**未入座原因**(ip_invalid / nomatch / multi / occupied / error)。
     *
     * <p>策略与原有前端逻辑保持一致:
     * â‘  å·²æ˜¯æˆ‘的工位 â†’ å¹‚等恢复;② **恰好一个**空闲工位 â†’ å ç”¨ï¼›â‘¢ å¤šä¸ªç©ºé—² â†’ ä¸æ“…自入座(multi);
     * â‘£ å…¨è¢«ä»–人占用 â†’ ä¸æŒ¤æŽ‰ï¼ˆoccupied + å ç”¨äººå§“名)。
     *
     * @param ip      æ¥è®¿ IP(调用方已用 {@code IpUtils.normalize} è§„范化)
     * @param docId   å½“前登录人
     * @param docName å½“前登录人姓名
     */
    RoomSeatRespVO autoSeatByIp(String ip, Long docId, String docName);
    /**
     * æŸ¥è¯¢å½“前登录人**已入座**的工位(给系统顶栏显示"当前工位"用)。
     *
     * @param docId å½“前登录人
     * @return æœªå…¥åº§æ—¶è¿”回 {@code seated=false} çš„空结果
     */
    RoomSeatRespVO getMySeat(Long docId);
    /**
     * ã€æœ¬éœ€æ±‚·一人多工位】查询当前登录人**占据的全部工位**(节假日值班多岗位)。
     *
     * <p>返回「医生在座 / åŒ»ç”Ÿæš‚停」状态的工位,按 roomId + bedNo å‡åºï¼›
     * æœªå ä»»ä½•工位时返回空列表(前端据此显示"未入座")。
     *
     * @param docId å½“前登录人
     */
    List<RoomSeatRespVO> getMySeats(Long docId);
    /**
     * ã€æœ¬éœ€æ±‚·接班自助接管】把指定工位从"别人占用"改为"当前登录人占用"。
     *
     * <p>使用场景:上一班医生下班未点「离座」,下一班医生在同一台电脑/同一工位接班。
     * è¡Œä¸ºï¼š
     * <ol>
     *   <li>工位不存在 â†’ æŠ¥é”™ï¼›</li>
     *   <li>工位空闲,或本来就属于当前登录人 â†’ **幂等成功**(不报错);</li>
     *   <li>被他人占用 â†’ è®°å½•审计日志(工位、原占用人、操作人)→ å¼ºåˆ¶æ¸…空原占用人 â†’ **立即让当前登录人入座**。</li>
     * </ol>
     *
     * @param roomId è¯Šå®¤ç¼–号
     * @param bedNo  å·¥ä½ç¼–号
     */
    void takeOverBed(Long roomId, String bedNo);
    /**
     * æ ¡éªŒã€Œå½“前登录人必须是该工位的主人」,否则抛
     * {@link cn.lihu.jh.module.ecg.enums.ErrorCodeConstants#ROOM_NOT_OWNER}。
     *
     * <p>用于"主/围观"模型的写操作鉴权:围观人只能看,不能改数据。
     * {@code @RequireBedOwner} åˆ‡é¢ä¼šè°ƒç”¨å®ƒï¼›åªå¸¦ rentId ä¹‹ç±»çš„æŽ¥å£éœ€è¦è‡ªè¡Œè°ƒç”¨ã€‚
     *
     * @param roomId è¯Šå®¤ç¼–号
     * @param bedNo  å·¥ä½å·
     */
    void assertBedOwner(Long roomId, String bedNo);
    /**
     * åŒä¸Šï¼Œä½†**按患者反查工位**:用于只带患者信息、没有 roomId/bedNo çš„写接口
     * ï¼ˆæ”¾å¼ƒæ£€æŸ¥ / æ”¾å¼ƒé¢†ç”¨ / æ”¾å¼ƒè£…机)。
     *
     * <p>反查依据:{@code queue} è¡¨å½“前该患者 + æ£€æŸ¥é¡¹ç›®çš„那一行上的 room_id / bed_no。
     * æŸ¥ä¸åˆ°å·¥ä½æ—¶**拒绝**(宁可拒绝也不放行)。
     */
    void assertBedOwnerByPatient(String patId, Integer checkType);
    /**
     * åŒä¸Šï¼Œä½†æŸ¥ä¸åˆ°å·¥ä½æ—¶**记警告放行**。
     *
     * <p>用于确费/取消确费:这两步常在流程末端调用,患者可能已经不在工位队列上;
     * ä¸€å¾‹æ‹’绝会把正常业务挡掉,故这里只记警告(前端在围观模式下已禁用按钮)。
     */
    void assertBedOwnerByPatientSoft(String patId, Integer checkType);
    /**
     * æ ¡éªŒã€Œè¯¥å·¥ä½å½“前的主人就是 ownerDocId」。
     *
     * <p>用于围观人查看**工位主人的已完成检查记录**:不要求调用者入座(围观本来就是不入座也能看),
     * ä½†å¿…须确认这个 docId ç¡®å®žæ˜¯è¯¥å·¥ä½çŽ°åœ¨çš„ä¸»äººï¼Œé¿å…è¢«å½“æˆ"任意查别人记录"的入口。
     *
     * @param roomId     è¯Šå®¤ç¼–号
     * @param bedNo      å·¥ä½å·
     * @param ownerDocId æœŸæœ›çš„工位主人
     */
    void assertBedOwnerIs(Long roomId, String bedNo, Long ownerDocId);
    /**
     * å·¥ä½çŠ¶æ€è¯Šæ–­ï¼ˆæŽ’æŸ¥"入座报状态异常/找不到工位")。
     *
     * <p>把库里的**原始行**摊开:`id / room_id / bed_no(含长度与 HEX,用于发现结尾空格/全角) /
     * status / doc_id / doc_name / deleted / update_time`,并给出一句结论。
     *
     * @param id     å·¥ä½ä¸»é”®ï¼ˆä¼˜å…ˆï¼Œå«å·²åˆ é™¤è¡Œï¼‰
     * @param roomId è¯Šå®¤ç¼–号(id ä¸ºç©ºæ—¶ç”¨ï¼‰
     * @param bedNo  å·¥ä½å·ï¼ˆid ä¸ºç©ºæ—¶ç”¨ï¼ŒæŒ‰ TRIM å®½æ¾åŒ¹é…ï¼‰
     */
    Map<String, Object> diagnoseBed(Integer id, Long roomId, String bedNo);
    /**
     * èŽ·å¾—è¯Šå®¤å’Œè¯Šç–—åºŠåˆ†é¡µ
     *
     * @param pageReqVO åˆ†é¡µæŸ¥è¯¢
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/room/RoomServiceImpl.java
@@ -3,9 +3,14 @@
import java.util.*;
import javax.annotation.Resource;
import cn.lihu.jh.framework.common.exception.ErrorCode;
import cn.lihu.jh.framework.common.exception.enums.GlobalErrorCodeConstants;
import cn.lihu.jh.framework.common.pojo.PageResult;
import cn.lihu.jh.module.ecg.utils.IpUtils;
import cn.lihu.jh.module.ecg.controller.admin.room.vo.MonitorInfoVO;
import cn.lihu.jh.module.ecg.controller.admin.room.vo.RoomSeatRespVO;
import cn.lihu.jh.module.ecg.dal.dataobject.room.RoomDO;
import cn.lihu.jh.module.ecg.dal.dataobject.queue.QueueDO;
import cn.lihu.jh.module.ecg.dal.dataobject.room.RoomProfile;
import cn.lihu.jh.module.ecg.enums.RoomCallingScreenEnum;
import cn.lihu.jh.module.ecg.service.queue.QueueService;
@@ -15,12 +20,16 @@
import cn.lihu.jh.module.ecg.controller.admin.room.vo.RoomPageReqVO;
import cn.lihu.jh.module.ecg.controller.admin.room.vo.RoomSaveReqVO;
import cn.lihu.jh.module.ecg.controller.admin.room.vo.RoomSelectBedRespVO;
import cn.lihu.jh.module.ecg.dal.mysql.room.RoomMapper;
import cn.lihu.jh.module.ecg.enums.BedStatusEnum;
import cn.lihu.jh.framework.common.util.object.BeanUtils;
import lombok.extern.slf4j.Slf4j;
import static cn.lihu.jh.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.lihu.jh.module.ecg.enums.ErrorCodeConstants.ROOM_INVALID_STATUS;
import static cn.lihu.jh.module.ecg.enums.ErrorCodeConstants.ROOM_NOT_EXISTS;
import static cn.lihu.jh.module.ecg.enums.ErrorCodeConstants.ROOM_NOT_OWNER;
/**
 * è¯Šå®¤å’Œè¯Šç–—床 Service å®žçŽ°ç±»
@@ -29,6 +38,7 @@
 */
@Service
@Validated
@Slf4j
public class RoomServiceImpl implements RoomService {
    @Resource
@@ -39,6 +49,10 @@
    @Resource
    QueueService queueService;
    /** ã€ä¸»/围观】按患者反查工位做主人校验时需要读 queue è¡Œ */
    @Resource
    private cn.lihu.jh.module.ecg.dal.mysql.queue.QueueMapper queueMapper;
    @Override
    public Integer createRoom(RoomSaveReqVO createReqVO) {
@@ -141,6 +155,395 @@
        return roomMapper.selectPage(pageReqVO);
    }
    /**
     * ã€Œé€‰æ‹©å·¥ä½æ¨¡å¼ã€- æŒ‰æœ¬æœºIP匹配工位。
     * <p>排序(决定自动入座选哪一个):① æœ¬äººå·²å ç”¨ï¼ˆåˆ·æ–°/重进直接恢复)→ â‘¡ ç©ºé—²ä¸”工位号最小
     * â†’ â‘¢ è¢«ä»–人占用(前端会提示"该IP对应工位已被XX占用",让人工处理)。</p>
     */
    @Override
    public List<RoomSelectBedRespVO.RoomSelectBedItem> matchBedsByIp(String ip, Long docId) {
        String normalized = IpUtils.normalize(ip);
        if (!IpUtils.isBindableIp(normalized)) {
            return Collections.emptyList();
        }
        List<RoomDO> list = roomMapper.queueByIp(normalized);
        if (list == null || list.isEmpty()) {
            return Collections.emptyList();
        }
        List<RoomSelectBedRespVO.RoomSelectBedItem> items = new ArrayList<>(list.size());
        for (RoomDO roomDO : list) {
            RoomSelectBedRespVO.RoomSelectBedItem item = new RoomSelectBedRespVO.RoomSelectBedItem();
            item.setId(roomDO.getId());
            item.setRoomId(roomDO.getRoomId());
            item.setRoomName(roomDO.getRoomName());
            item.setBedNo(roomDO.getBedNo());
            item.setIp(roomDO.getIp());
            item.setDocId(roomDO.getDocId());
            item.setDocName(roomDO.getDocName());
            item.setOpType(roomDO.getOpType());
            item.setCheckTypes(roomDO.getCheckTypes());
            item.setStatus(roomDO.getStatus());
            item.setOccupied(roomDO.getDocId() != null);
            item.setMine(docId != null && docId.equals(roomDO.getDocId()));
            items.add(item);
        }
        // æŽ’序:本人已占用(0) â†’ ç©ºé—²(1) â†’ è¢«ä»–人占用(2);同档按工位号
        items.sort(Comparator
                .comparingInt((RoomSelectBedRespVO.RoomSelectBedItem i) -> {
                    if (Boolean.TRUE.equals(i.getMine())) return 0;
                    if (!Boolean.TRUE.equals(i.getOccupied())) return 1;
                    return 2;
                })
                .thenComparing(RoomSelectBedRespVO.RoomSelectBedItem::getBedNo,
                        Comparator.nullsLast(Comparator.naturalOrder())));
        return items;
    }
    @Override
    public Integer bindIpToBed(Integer id, String ip) {
        // å›žçޝ/未指定/非法地址绑上去没有意义(127.x、::1、0.0.0.0),还会让后续匹配失效
        if (!IpUtils.isBindableIp(ip)) {
            return 0;
        }
        Integer rows = roomMapper.updateIpById(id, IpUtils.normalize(ip));
        log.info("[bindIpToBed][把本机IP绑定到工位] id={} ip={} å½±å“è¡Œæ•°={}", id, ip, rows);
        return rows;
    }
    @Override
    public List<RoomDO> listIpBindings(String ip, Integer excludeId) {
        if (!IpUtils.isBindableIp(ip)) {
            return Collections.emptyList();
        }
        List<RoomDO> list = roomMapper.listByIpExcludeId(IpUtils.normalize(ip), excludeId);
        return list == null ? Collections.emptyList() : list;
    }
    @Override
    public RoomSeatRespVO autoSeatByIp(String ip, Long docId, String docName) {
        RoomSeatRespVO resp = new RoomSeatRespVO();
        String clientIp = IpUtils.normalize(ip);
        resp.setClientIp(clientIp);
        // â‘  æœ¬æœºIP不可用(回环 / IPv6 / æœªæŒ‡å®š / éžæ³• / **服务器自身地址**)→ ä¸èƒ½ä½œä¸ºå·¥ä½æ ‡è¯†
        if (!IpUtils.isBindableIp(clientIp)) {
            // åŒºåˆ†"识别到的是服务器自身地址"与其它不可用情况:前者现场最常见(在服务器上/远程桌面里访问),
            // æç¤ºè¯­è¦èƒ½ç›´æŽ¥æŒ‡å‘解决办法,而不是让排查的人以为是代码坏了。
            resp.setReason(IpUtils.isLocalAddress(clientIp) ? "ip_is_server" : "ip_invalid");
            log.info("[autoSeatByIp] æœ¬æœºIP不可用,不自动入座. clientIp={} docId={} æ˜¯æœåŠ¡å™¨è‡ªèº«åœ°å€={}",
                    clientIp, docId, IpUtils.isLocalAddress(clientIp));
            return resp;
        }
        // å·²æŒ‰ã€Œæœ¬äººå·²å ç”¨ â†’ ç©ºé—² â†’ è¢«ä»–人占用」排序
        List<RoomSelectBedRespVO.RoomSelectBedItem> beds = matchBedsByIp(clientIp, docId);
        if (beds.isEmpty()) {
            // è¯¥ IP æ²¡æœ‰å¯¹åº”任何工位:工位没录 IP(现场多为 0.0.0.0),或工位已关闭
            resp.setReason("nomatch");
            log.info("[autoSeatByIp] è¯¥IP没有匹配到工位(工位未录IP / å·²å…³é—­ï¼‰. clientIp={} docId={}", clientIp, docId);
            return resp;
        }
        // â‘¡ å·²ç»æ˜¯æˆ‘的工位 â†’ å¹‚等恢复(刷新/重进/上次没正常离座都走这条)
        RoomSelectBedRespVO.RoomSelectBedItem mine = beds.stream()
                .filter(b -> Boolean.TRUE.equals(b.getMine())).findFirst().orElse(null);
        if (null != mine) {
            return doSeat(mine.getId(), mine.getRoomId(), mine.getBedNo(), docId, docName, resp, "mine");
        }
        // â‘¢ ç©ºé—²å·¥ä½ï¼š**恰好一个**才自动入座
        List<RoomSelectBedRespVO.RoomSelectBedItem> free = beds.stream()
                .filter(b -> !Boolean.TRUE.equals(b.getOccupied())).toList();
        if (free.size() == 1) {
            return doSeat(free.get(0).getId(), free.get(0).getRoomId(), free.get(0).getBedNo(),
                    docId, docName, resp, "free");
        }
        if (free.size() > 1) {
            // è¿™å°æœºå™¨å¯¹åº”多个空闲工位 â†’ åé”™çš„风险大于便利,交人工点选
            resp.setReason("multi");
            log.info("[autoSeatByIp] è¯¥IP对应 {} ä¸ªç©ºé—²å·¥ä½ï¼Œæ— æ³•确定是哪一个,交人工选择. clientIp={}", free.size(), clientIp);
            return resp;
        }
        // â‘£ å…¨è¢«ä»–人占用 â†’ **不自动挤掉**,但把候选工位与占用人带回去,
        //    ä¾›å‰ç«¯å¼¹ã€Œè¯¥å·¥ä½ç”± xxx å ç”¨ï¼Œæ˜¯å¦æŽ¥ç®¡ï¼Ÿã€ï¼ˆæœ¬éœ€æ±‚:接班医生自助接管)。
        RoomSelectBedRespVO.RoomSelectBedItem occupied = beds.get(0);
        resp.setReason("occupied");
        resp.setOwnerName(occupied.getDocName());
        // ã€æœ¬éœ€æ±‚】补齐候选工位信息:前端凭这些字段调 /clinic/room/take-over-bed
        resp.setId(occupied.getId());
        resp.setRoomId(occupied.getRoomId());
        resp.setRoomName(occupied.getRoomName());
        resp.setBedNo(occupied.getBedNo());
        resp.setIp(occupied.getIp());
        resp.setDocId(occupied.getDocId());
        resp.setDocName(occupied.getDocName());
        resp.setCheckTypes(occupied.getCheckTypes());
        resp.setOpType(occupied.getOpType());
        log.info("[autoSeatByIp] è¯¥IP对应的工位已被他人占用,等待前端确认是否接管. clientIp={} å·¥ä½={}/{} å ç”¨äºº={}({})",
                clientIp, occupied.getRoomId(), occupied.getBedNo(), occupied.getDocName(), occupied.getDocId());
        return resp;
    }
    /** æ‰§è¡Œå…¥åº§å¹¶å›žå¡«ç»“果(内部方法,供 autoSeatByIp å„分支复用) */
    private RoomSeatRespVO doSeat(Integer bedId, Long roomId, String bedNo, Long docId, String docName,
                                  RoomSeatRespVO resp, String branch) {
        // ä¼  bedId:入座优先按主键定位,避免 bed_no ä¸Žåº“内值有肉眼难辨差异时更新 0 è¡Œ
        ErrorCode ec = queueService.startBedDoctorOn(roomId, bedNo, docId, docName, bedId);
        if (!GlobalErrorCodeConstants.SUCCESS.equals(ec)) {
            resp.setReason("error");
            log.warn("[autoSeatByIp] å…¥åº§æœªç”Ÿæ•ˆï¼ˆ{}). roomId={} bedNo={} docId={}", branch, roomId, bedNo, docId);
            return resp;
        }
        fillSeatInfo(resp, roomMapper.getRoom(roomId, bedNo));
        resp.setSeated(true);
        log.info("[autoSeatByIp] è‡ªåŠ¨å…¥åº§æˆåŠŸï¼ˆ{}). docId={} roomId={} bedNo={}", branch, docId, roomId, bedNo);
        return resp;
    }
    @Override
    public RoomSeatRespVO getMySeat(Long docId) {
        RoomSeatRespVO resp = new RoomSeatRespVO();
        if (null == docId) {
            return resp;
        }
        List<RoomDO> beds = roomMapper.getRoomByDocId(docId);
        if (null == beds || beds.isEmpty()) {
            return resp;
        }
        if (beds.size() > 1) {
            // ã€æœ¬éœ€æ±‚·一人多工位】值班医生可同时占多个工位,这里不再是异常场景;
            // æœ¬æŽ¥å£åªä¸ºå…¼å®¹æ—§å‰ç«¯/其它调用(返回第一个),完整列表用 getMySeats()。
            log.info("[getMySeat] è¯¥åŒ»ç”Ÿå ç”¨äº† {} ä¸ªå·¥ä½ï¼Œæœ¬æŽ¥å£è¿”回第一个(完整列表见 /my-seats). docId={}",
                    beds.size(), docId);
        }
        fillSeatInfo(resp, beds.get(0));
        resp.setSeated(true);
        return resp;
    }
    @Override
    public List<RoomSeatRespVO> getMySeats(Long docId) {
        List<RoomSeatRespVO> result = new ArrayList<>();
        if (null == docId) {
            return result;
        }
        // åªå–「医生在座 / åŒ»ç”Ÿæš‚停」且未删除的工位(排除历史残留)
        List<Integer> statusList = new ArrayList<>();
        statusList.add(BedStatusEnum.DOCTOR_ON.getStatus());
        statusList.add(BedStatusEnum.DOCTOR_PAUSE.getStatus());
        List<RoomDO> beds = roomMapper.getMyActiveBeds(docId, statusList);
        if (null == beds || beds.isEmpty()) {
            return result;
        }
        for (RoomDO bed : beds) {
            RoomSeatRespVO resp = new RoomSeatRespVO();
            fillSeatInfo(resp, bed);
            resp.setSeated(true);
            result.add(resp);
        }
        return result;
    }
    /**
     * ã€æœ¬éœ€æ±‚·接班自助接管】把工位从"别人占用"改为"当前登录人占用"。
     *
     * <p>这是"登出不释放工位"场景下,接班医生立即用上工位的唯一入口(不需要管理员)。
     * åŽŸå ç”¨äººé‚£å°ç”µè„‘ä¼šåœ¨ä¸‹ä¸€æ¬¡ 5s è½®è¯¢æ”¶åˆ°ã€Œè¯·é‡æ–°å°±åã€å¹¶è½¬åªè¯»ï¼ˆçŽ°æœ‰é€»è¾‘ï¼‰ã€‚
     *
     * <p>「强制清空 â†’ æˆ‘入座」在同一事务内完成,避免中间态被第三方抢到。
     */
    @Override
    @org.springframework.transaction.annotation.Transactional(rollbackFor = Exception.class)
    public void takeOverBed(Long roomId, String bedNo) {
        Long userId = cn.lihu.jh.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId();
        String userNickname = cn.lihu.jh.framework.security.core.util.SecurityFrameworkUtils.getLoginUserNickname();
        if (null == userId) {
            throw exception(ROOM_NOT_OWNER);
        }
        if (null == roomId || null == bedNo || bedNo.isEmpty()) {
            throw exception(ROOM_NOT_EXISTS);
        }
        RoomDO bed = roomMapper.getRoom(roomId, bedNo);
        if (null == bed) {
            throw exception(ROOM_NOT_EXISTS);
        }
        // â‘  æœ¬æ¥å°±å±žäºŽæˆ‘ â†’ å¹‚等成功
        if (null != bed.getDocId() && bed.getDocId().equals(userId)) {
            log.info("[takeOverBed] å·¥ä½æœ¬å°±å±žäºŽå½“前登录人,无需接管. å·¥ä½={}/{} docId={}", roomId, bedNo, userId);
            return;
        }
        // â‘¡ ç©ºé—² â†’ ç›´æŽ¥å…¥åº§ï¼ˆ"接管"等价于"坐上去",省掉前端再点一次入座)
        if (null == bed.getDocId()) {
            ErrorCode onRet = queueService.startBedDoctorOn(roomId, bedNo, userId, userNickname, bed.getId());
            if (!GlobalErrorCodeConstants.SUCCESS.equals(onRet)) {
                log.warn("[takeOverBed] ç©ºé—²å·¥ä½å…¥åº§å¤±è´¥. å·¥ä½={}/{} ret={}", roomId, bedNo, onRet);
                throw exception(ROOM_INVALID_STATUS);
            }
            return;
        }
        // â‘¢ è¢«ä»–人占用 â†’ å®¡è®¡æ—¥å¿— + å¼ºåˆ¶æ¸…空 + ç«‹å³å…¥åº§
        log.warn("[takeOverBed][接班接管] å·¥ä½={}/{} åŽŸå ç”¨äºº={}({}) â†’ æŽ¥ç®¡äºº={}({})",
                roomId, bedNo, bed.getDocName(), bed.getDocId(), userNickname, userId);
        ErrorCode offRet = queueService.startBedDoctorOffByAdmin(roomId, bedNo);
        if (!GlobalErrorCodeConstants.SUCCESS.equals(offRet)) {
            log.warn("[takeOverBed] æŽ¥ç®¡æ—¶æ¸…空原占用人失败. å·¥ä½={}/{} ret={}", roomId, bedNo, offRet);
            throw exception(ROOM_INVALID_STATUS);
        }
        ErrorCode onRet = queueService.startBedDoctorOn(roomId, bedNo, userId, userNickname, bed.getId());
        if (!GlobalErrorCodeConstants.SUCCESS.equals(onRet)) {
            log.warn("[takeOverBed] æŽ¥ç®¡åŽå…¥åº§å¤±è´¥. å·¥ä½={}/{} ret={}", roomId, bedNo, onRet);
            throw exception(ROOM_INVALID_STATUS);
        }
        log.info("[takeOverBed][接班接管] å®Œæˆ. å·¥ä½={}/{} æ–°å ç”¨äºº={}({})", roomId, bedNo, userNickname, userId);
    }
    @Override
    public void assertBedOwner(Long roomId, String bedNo) {
        Long userId = cn.lihu.jh.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId();
        RoomDO bed = (null == roomId || null == bedNo || bedNo.isEmpty())
                ? null : roomMapper.getRoom(roomId, bedNo);
        if (null == bed) {
            log.warn("[assertBedOwner] å·¥ä½ä¸å­˜åœ¨ï¼Œæ‹’绝写操作. roomId={} bedNo={} docId={}", roomId, bedNo, userId);
            throw exception(ROOM_NOT_OWNER);
        }
        if (null == bed.getDocId() || !bed.getDocId().equals(userId)) {
            log.warn("[assertBedOwner] éžå·¥ä½ä¸»äººï¼Œæ‹’绝写操作(围观模式). roomId={} bedNo={} æˆ‘={} ä¸»äºº={}({})",
                    roomId, bedNo, userId, bed.getDocName(), bed.getDocId());
            throw exception(ROOM_NOT_OWNER);
        }
    }
    @Override
    public void assertBedOwnerByPatient(String patId, Integer checkType) {
        QueueDO queue = resolvePatientBedQueue(patId, checkType);
        if (null == queue) {
            log.warn("[assertBedOwnerByPatient] æ‰¾ä¸åˆ°è¯¥æ‚£è€…当前所在工位,拒绝写操作. patId={} checkType={}",
                    patId, checkType);
            throw exception(ROOM_NOT_OWNER);
        }
        assertBedOwner(queue.getRoomId(), queue.getBedNo());
    }
    @Override
    public void assertBedOwnerByPatientSoft(String patId, Integer checkType) {
        QueueDO queue = resolvePatientBedQueue(patId, checkType);
        if (null == queue) {
            // ç¡®è´¹/取消确费常在流程末端调用,患者可能已不在工位队列上;
            // ä¸€å¾‹æ‹’绝会误伤正常业务,这里只记警告放行(前端围观模式下按钮已禁用)。
            log.warn("[assertBedOwnerByPatientSoft] æ‰¾ä¸åˆ°è¯¥æ‚£è€…当前所在工位,放行(仅记警告). patId={} checkType={}",
                    patId, checkType);
            return;
        }
        assertBedOwner(queue.getRoomId(), queue.getBedNo());
    }
    @Override
    public void assertBedOwnerIs(Long roomId, String bedNo, Long ownerDocId) {
        RoomDO bed = (null == roomId || null == bedNo || bedNo.isEmpty())
                ? null : roomMapper.getRoom(roomId, bedNo);
        if (null == bed || null == bed.getDocId() || !bed.getDocId().equals(ownerDocId)) {
            log.warn("[assertBedOwnerIs] å·¥ä½å½“前主人与请求的 ownerDocId ä¸ä¸€è‡´ï¼Œæ‹’绝. roomId={} bedNo={} è¯·æ±‚={} å®žé™…={}",
                    roomId, bedNo, ownerDocId, null == bed ? null : bed.getDocId());
            throw exception(ROOM_NOT_OWNER);
        }
    }
    /** æŒ‰ã€Œæ‚£è€… + æ£€æŸ¥é¡¹ç›®ã€æ‰¾å½“前所在工位的那条队列行;找不到工位返回 null */
    private QueueDO resolvePatientBedQueue(String patId, Integer checkType) {
        if (null == patId) {
            return null;
        }
        QueueDO queue = queueMapper.getQueueByPatId(patId, checkType);
        if (null == queue || null == queue.getRoomId() || null == queue.getBedNo()
                || queue.getBedNo().trim().isEmpty()) {
            return null;
        }
        return queue;
    }
    @Override
    public Map<String, Object> diagnoseBed(Integer id, Long roomId, String bedNo) {
        Map<String, Object> data = new LinkedHashMap<>();
        String condition = String.format("id=%s, roomId=%s, bedNo=[%s](len=%s)",
                id, roomId, bedNo, null == bedNo ? null : bedNo.length());
        data.put("查询条件", condition);
        List<Map<String, Object>> rows = roomMapper.selectBedRawState(id, roomId, bedNo);
        data.put("匹配到的行数", rows.size());
        data.put("行明细", rows);
        String verdict;
        if (rows.isEmpty()) {
            verdict = "库里【查不到】这个工位(按 id ä¸Ž room_id+TRIM(bed_no) éƒ½æ²¡åŒ¹é…åˆ°ï¼‰â†’ "
                    + "按 roomId+bedNo å…¥åº§å¿…ç„¶æ›´æ–° 0 è¡Œã€æŠ¥â€œè¯Šå®¤å’Œè¯Šç–—床状态异常”。"
                    + "新版已支持按 id å…¥åº§ï¼›è‹¥ä»å¤±è´¥ï¼Œè¯·æŠŠæœ¬ç»“果发回。";
        } else {
            Map<String, Object> row = rows.get(0);
            Integer rowStatus = toInt(row.get("status"));
            Integer rowDeleted = toInt(row.get("deleted"));
            Integer bedNoLen = toInt(row.get("bed_no_len"));
            Object rowDocId = row.get("doc_id");
            if (null != rowDeleted && 0 != rowDeleted) {
                verdict = "该工位在库里是【已删除】(deleted=1) â†’ å…¥åº§ä¸ä¼šå‘½ä¸­ï¼Œè¯·æ¢å¤æ•°æ®æˆ–重建工位。";
            } else if (null != bedNoLen && null != bedNo && bedNoLen != bedNo.length()) {
                verdict = String.format("库里 bed_no çš„长度(%d)与请求值(%d)不一致 â†’ è¯·æ±‚里的工位编号与库内不同"
                                + "(常见是结尾空格/全角),HEX=%s。新版已改为按 id å…¥åº§ï¼Œå¯ç»•开该问题。",
                        bedNoLen, bedNo.length(), row.get("bed_no_hex"));
            } else if (null == rowStatus || (10 != rowStatus && 20 != rowStatus && 30 != rowStatus)) {
                verdict = "该工位状态=" + rowStatus + "(0=已关闭);不在可入座状态(可入座的为 10/20/30),"
                        + "请先在开诊设置/监控页把它开通。";
            } else if (null != rowDocId) {
                verdict = "该工位当前的主人是【" + row.get("doc_name") + "】(doc_id=" + rowDocId + "):"
                        + "若这就是你本人,说明上次没正常离座 â€”— æ–°ç‰ˆå·²æ”¯æŒå¹‚等恢复入座;"
                        + "若是别人,则不能入座(不挤掉正在操作的人)。";
            } else {
                verdict = "该工位当前【可入座】。若之前仍报“状态异常”,请把本结果连同那次请求参数一起发回。";
            }
        }
        data.put("诊断结论", verdict);
        log.info("[diagnoseBed][工位状态诊断] {} â†’ {}", condition, verdict);
        return data;
    }
    /** å…¼å®¹ bit(1)/Boolean/Number çš„取值(诊断接口用) */
    private Integer toInt(Object v) {
        if (null == v) {
            return null;
        }
        if (v instanceof Boolean) {
            return ((Boolean) v) ? 1 : 0;
        }
        if (v instanceof Number) {
            return ((Number) v).intValue();
        }
        try {
            return Integer.valueOf(String.valueOf(v).trim());
        } catch (Exception e) {
            return null;
        }
    }
    /** æŠŠå·¥ä½å­—段拷进响应体 */
    private void fillSeatInfo(RoomSeatRespVO resp, RoomDO bed) {
        if (null == bed) {
            return;
        }
        resp.setId(bed.getId());
        resp.setRoomId(bed.getRoomId());
        resp.setRoomName(bed.getRoomName());
        resp.setBedNo(bed.getBedNo());
        resp.setIp(bed.getIp());
        resp.setStatus(bed.getStatus());
        resp.setDocId(bed.getDocId());
        resp.setDocName(bed.getDocName());
        resp.setCheckTypes(bed.getCheckTypes());
        resp.setOpType(bed.getOpType());
    }
    @Override
    public List<RoomDO> simpleRoomList() {
        List<BedStatusEnum> bedStatusEnumList = new ArrayList<BedStatusEnum>();
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/utils/IpUtils.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,258 @@
package cn.lihu.jh.module.ecg.utils;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.util.Enumeration;
import java.util.LinkedHashSet;
import java.util.Set;
/**
 * è®¿é—®æ–¹ IP çš„规范化与校验(供「选择工位模式」使用)。
 *
 * <h3>地址族策略:只采用 IPv4</h3>
 * æœ¬ç³»ç»ŸçŽ°åœºå·¥ä½ç»‘å®šç»Ÿä¸€ä½¿ç”¨ IPv4(`clinic_room.ip` é‡Œå­˜çš„就是 `32.88.65.x` è¿™ç±»åœ°å€ï¼‰ã€‚
 * å› æ­¤ï¼š
 * <ul>
 *   <li>{@link #isBindableIp(String)} **只接受 IPv4**;IPv6(含回环 {@code ::1})一律不参与匹配与绑定,
 *       åªç”¨äºŽè¯Šæ–­æç¤ºï¼›</li>
 *   <li>为兼容"浏览器 â†” æœåŠ¡å™¨åŒæœºè®¿é—®"与"容器/网关把 IPv4 è¡¨ç¤ºæˆ IPv4-mapped IPv6"这两种情况,
 *       è§„范化时会做两件事:把 {@code ::ffff:32.88.65.208} **还原成 {@code 32.88.65.208}**(还原后可用),
 *       æŠŠ {@code ::1} / {@code 0:0:0:0:0:0:0:1} è¯†åˆ«ä¸º<b>回环</b>(不可用,但提示语要说清"本机访问");
 *   <li>去掉 IPv6 çš„æ–¹æ‹¬å·ä¸ŽåŒºåŸŸå·ï¼ˆ{@code %eth0})、统一小写、代理转发链取第一个。</li>
 * </ul>
 *
 * <h3>「服务器自身地址」不作为工位标识(本次新增)</h3>
 * çŽ°åœºåé¦ˆè¿‡"把登录系统的网址上的 IP å½“成了本机 IP"。根因通常是:**在服务器上 / é€šè¿‡è¿œç¨‹æ¡Œé¢è®¿é—®ç³»ç»Ÿ**,
 * æˆ–前置代理没透传客户端地址 â€”— æ­¤æ—¶åŽç«¯çœ‹åˆ°çš„"客户端"其实就是服务器自己。
 * è¿™ç§åœ°å€å¿…须拒绝(否则会把服务器 IP å†™è¿› `clinic_room.ip`,那个工位以后永远匹配不到医生电脑),
 * å¹¶ç”± {@link #isLocalAddress(String)} è¯†åˆ«ã€ç»™å‡ºå¯æ“ä½œæç¤ºã€‚
 *
 * <h3>运维须知:如何避免客户端走 IPv6</h3>
 * è‹¥æœåŠ¡å™¨æ‰€åœ¨æœºå™¨åŒæ—¶ç›‘å¬ IPv6,客户端可能优先用 IPv6 è®¿é—®ï¼ˆæ­¤æ—¶åŽç«¯çœ‹åˆ°çš„就是 IPv6 åœ°å€ï¼Œ
 * æ— æ³•通过代码"退化成"IPv4,因为连接本身就是 IPv6 å»ºç«‹çš„)。解决办法是让服务只监听 IPv4:
 * <pre>
 *   # æ–¹å¼ä¸€ï¼šJVM å‚数(推荐,jarboot/启动脚本里加)
 *   -Djava.net.preferIPv4Stack=true
 *   # æ–¹å¼äºŒï¼šTomcat åªç»‘ IPv4 åœ°å€
 *   server.address=0.0.0.0  + ç³»ç»Ÿå…³é—­ IPv6,或直接绑本机 IPv4 åœ°å€
 * </pre>
 * è‹¥ä¸Šè¿°éƒ½ä¸ä¾¿è°ƒæ•´ï¼Œè¯·ç¡®ä¿åŒ»ç”Ÿç«¯ç”¨æœåŠ¡å™¨çš„ **IPv4 åœ°å€** è®¿é—®ï¼ˆä¸è¦ç”¨ IPv6 åœ°å€æˆ–域名解析到 IPv6)。
 *
 * <p>注意:包名用 {@code utils}(小写)—— æ¨¡å—里已有 {@code cn.lihu.jh.module.ecg.Utils} è¿™ä¸ªç±»ï¼Œ
 * è‹¥å»ºåŒåçš„ {@code Utils} åŒ…会与类名冲突(实测编译报"类与程序包冲突")。
 * å¦æ³¨æ„ Windows è·¯å¾„大小写不敏感,删除 {@code Utils} ç›®å½•会连带删掉 {@code utils}。</p>
 */
public final class IpUtils {
    /** æ˜¯å¦åªé‡‡ç”¨ IPv4(现场约定)。改为 false å³å¯æ”¾å¼€ IPv6,无需改动调用方。 */
    public static final boolean IPV4_ONLY = true;
    /** æœåŠ¡å™¨æœ¬æœº IPv4 ç½‘卡地址的缓存(见 {@link #localIpv4Addresses()}) */
    private static volatile Set<String> localIpv4Cache = null;
    private IpUtils() {
    }
    /**
     * è§„范化来访 IP。
     *
     * @return è§„范化后的字符串;入参为空时返回 null
     */
    public static String normalize(String ip) {
        if (ip == null) {
            return null;
        }
        String v = ip.trim();
        if (v.isEmpty()) {
            return null;
        }
        // ServletUtil å¯èƒ½è¿”回逗号分隔的转发链,取第一个(最靠近客户端的地址)
        if (v.contains(",")) {
            v = v.split(",")[0].trim();
        }
        // åŽ»æŽ‰ IPv6 çš„æ–¹æ‹¬å·ä¸ŽåŒºåŸŸå·ï¼Œä¾‹å¦‚ [fe80::1%eth0] â†’ fe80::1
        if (v.startsWith("[")) {
            v = v.substring(1);
        }
        if (v.endsWith("]")) {
            v = v.substring(0, v.length() - 1);
        }
        int zoneIdx = v.indexOf('%');
        if (zoneIdx > 0) {
            v = v.substring(0, zoneIdx);
        }
        v = v.toLowerCase();
        // IPv4-mapped IPv6:::ffff:32.88.65.208 â†’ 32.88.65.208
        if (v.startsWith("::ffff:") && isIpv4(v.substring(7))) {
            return v.substring(7);
        }
        return v;
    }
    /**
     * æ˜¯å¦æ˜¯å¯ç”¨äºŽç»‘定工位的 IP(**只采用 IPv4**):
     * å¿…须是合法 IPv4,且不是回环(127.x)或未指定(0.0.0.0)。
     *
     * <p>IPv6 ä¸€å¾‹è¿”回 false â€”— åŒ…括回环 {@code ::1} ä¸ŽçœŸå®ž IPv6 åœ°å€ã€‚
     * æ³¨æ„ï¼š{@code ::ffff:32.88.65.208} è¿™ç±» IPv4-mapped åœ°å€ä¼šå…ˆè¢«è§„范化还原成
     * {@code 32.88.65.208},因此**仍然可用**(这是 IPv4 å®¢æˆ·ç«¯åœ¨åŒæ ˆçŽ¯å¢ƒä¸‹æœ€å®¹æ˜“è¢«è¯¯åˆ¤çš„æƒ…å†µï¼‰ã€‚</p>
     */
    public static boolean isBindableIp(String ip) {
        String v = normalize(ip);
        if (v == null || v.isEmpty()) {
            return false;
        }
        if (isLoopback(v) || isUnspecified(v)) {
            return false;
        }
        // ã€æœ¬æ¬¡æ–°å¢žã€‘服务器自身地址不作为工位标识:现场若在服务器上(或远程桌面里)访问系统,
        // åŽç«¯çœ‹åˆ°çš„"客户端"就是服务器自己,写进 clinic_room.ip åŽè¯¥å·¥ä½æ°¸è¿œåŒ¹é…ä¸åˆ°åŒ»ç”Ÿç”µè„‘。
        if (isLocalAddress(v)) {
            return false;
        }
        boolean usable = isIpv4(v);
        if (IPV4_ONLY && isIpv6(v)) {
            usable = false; // åªé‡‡ç”¨ IPv4:IPv6 ä¸å‚与绑定与匹配
        }
        return usable;
    }
    /**
     * æœåŠ¡å™¨æœ¬æœºæ‰€æœ‰ IPv4 ç½‘卡地址(首次调用后缓存)。
     *
     * <p>取不到网卡信息时返回**空集合**,此时 {@link #isLocalAddress} æ’为 false â€”—
     * å®å¯å°‘拦,也不要因为环境取不到网卡就误判所有地址都是服务器的。
     */
    public static Set<String> localIpv4Addresses() {
        Set<String> cached = localIpv4Cache;
        if (null != cached) {
            return cached;
        }
        Set<String> result = new LinkedHashSet<>();
        try {
            Enumeration<NetworkInterface> nis = NetworkInterface.getNetworkInterfaces();
            while (null != nis && nis.hasMoreElements()) {
                NetworkInterface ni = nis.nextElement();
                Enumeration<InetAddress> addrs = ni.getInetAddresses();
                while (addrs.hasMoreElements()) {
                    InetAddress addr = addrs.nextElement();
                    if (addr instanceof Inet4Address) {
                        String ip = normalize(addr.getHostAddress());
                        if (null != ip && !ip.isEmpty()) {
                            result.add(ip);
                        }
                    }
                }
            }
        } catch (Exception e) {
            // å¿½ç•¥ï¼šæ‹¿ä¸åˆ°ç½‘卡信息时不做"服务器地址"判定(返回空集合)
        }
        localIpv4Cache = result;
        return result;
    }
    /**
     * è¯¥åœ°å€æ˜¯å¦æ˜¯**服务器自身**的 IPv4 åœ°å€ã€‚
     *
     * <p>用途:识别"在服务器上/远程桌面里访问系统"这种访问方式(此时后端看到的客户端就是服务器自己),
     * å¹¶ç»™å‡ºå¯æ“ä½œæç¤ºï¼Œè€Œä¸æ˜¯æŠŠå®ƒå½“成医生的本机 IP。
     * <p>注意:回环 {@code 127.0.0.1} é€šå¸¸ä¹Ÿåœ¨ç½‘卡列表里,但调用方一般先用 {@link #isLoopback} åˆ¤å®šï¼Œ
     * æç¤ºè¯­ä¼šæ›´è´´åˆ‡ï¼ˆ"本机访问")。
     */
    public static boolean isLocalAddress(String ip) {
        String v = normalize(ip);
        return null != v && !v.isEmpty() && localIpv4Addresses().contains(v);
    }
    /** è¯¥åœ°å€æ˜¯å¦ä¸º IPv6(规范化之后判断;IPv4-mapped å·²è¿˜åŽŸæˆ IPv4,不会命中这里) */
    public static boolean isIpv6Address(String ip) {
        return isIpv6(normalize(ip));
    }
    /** å›žçŽ¯åœ°å€ï¼š127.0.0.0/8、::1 */
    public static boolean isLoopback(String ip) {
        String v = normalize(ip);
        if (v == null) {
            return false;
        }
        if (v.startsWith("127.")) {
            return true;
        }
        return "::1".equals(v) || "0:0:0:0:0:0:0:1".equals(v);
    }
    /** æœªæŒ‡å®šåœ°å€ï¼š0.0.0.0、::(含全零写法) */
    public static boolean isUnspecified(String ip) {
        String v = normalize(ip);
        if (v == null) {
            return false;
        }
        if ("0.0.0.0".equals(v) || "::".equals(v)) {
            return true;
        }
        // 0:0:0:0:0:0:0:0
        return v.matches("^0(:0){2,7}$");
    }
    /** IPv4 ç‚¹åˆ†åè¿›åˆ¶ */
    public static boolean isIpv4(String ip) {
        String v = normalize(ip);
        if (v == null || !v.matches("^(\\d{1,3}\\.){3}\\d{1,3}$")) {
            return false;
        }
        for (String seg : v.split("\\.")) {
            int n;
            try {
                n = Integer.parseInt(seg);
            } catch (NumberFormatException e) {
                return false;
            }
            if (n > 255) {
                return false;
            }
        }
        return true;
    }
    /**
     * IPv6(含压缩写法)。这里做"结构级"校验:必须是十六进制分段 + å†’号,
     * å…è®¸åŽ‹ç¼© {@code ::},允许结尾 IPv4-mapped({@code ::ffff:1.2.3.4})。
     * ä¸åšä¸¥æ ¼çš„全展开校验(工业现场地址形态多,宽松校验更实用)。
     */
    public static boolean isIpv6(String ip) {
        String v = normalize(ip);
        if (v == null || v.isEmpty() || !v.contains(":")) {
            return false;
        }
        if (v.contains(":::")) {
            return false;
        }
        int doubleColon = v.indexOf("::");
        if (doubleColon >= 0 && v.indexOf("::", doubleColon + 1) >= 0) {
            return false; // åªèƒ½å‡ºçŽ°ä¸€æ¬¡ "::"
        }
        String body = v.endsWith("::") ? v.substring(0, v.length() - 2) : v;
        if (body.isEmpty()) {
            return true; // å°±æ˜¯ "::"
        }
        for (String seg : body.split("::", -1)) {
            if (seg.isEmpty()) {
                continue;
            }
            for (String part : seg.split(":", -1)) {
                if (part.isEmpty()) {
                    return false;
                }
                if (part.contains(".")) {
                    if (!isIpv4(part)) {
                        return false;
                    }
                } else if (!part.matches("[0-9a-f]{1,4}")) {
                    return false;
                }
            }
        }
        return true;
    }
}
jh-module-ecg/jh-module-ecg-biz/src/test/java/cn/lihu/jh/module/ecg/dal/mysql/queuesequence/SeqCounterInterceptorIgnoreTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,76 @@
package cn.lihu.jh.module.ecg.dal.mysql.queuesequence;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import org.apache.ibatis.annotations.Update;
import org.junit.Test;
import java.lang.reflect.Method;
import java.time.LocalDate;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
/**
 * ã€å›žå½’测试】防止 {@code seq_counter} çš„原子推进语句再次触发线上故障。
 *
 * <h4>背景(真实故障,2026-10-01)</h4>
 * é¢„约确认接口 500,日志:
 * <pre>
 * org.mybatis.spring.MyBatisSystemException: ... UnsupportedOperationException
 *   at JsqlParserSupport.processInsert(JsqlParserSupport.java:107)
 *   at DataPermissionInterceptor.beforePrepare(DataPermissionInterceptor.java:86)
 * ### The error may involve QueueSequenceMapper.advanceNormalSeq
 * </pre>
 * <b>根因</b>:MyBatis-Plus çš„ {@code DataPermissionInterceptor} ä¼šç”¨ JSqlParser è§£æžæ¯ä¸€æ¡ SQL,
 * è€Œ JSqlParser çš„ {@code processInsert} <b>不支持 MySQL çš„
 * {@code INSERT ... ON DUPLICATE KEY UPDATE}</b> è¯­æ³•,直接抛 {@code UnsupportedOperationException}。
 *
 * <b>修复</b>:在这两个方法上标注 {@code @InterceptorIgnore(dataPermission = "true")}
 * ï¼ˆ{@code seq_counter} è¡¨æ²¡æœ‰ dept_id/user_id,数据权限对它本无意义)。
 *
 * <h4>本测试防的是什么</h4>
 * è‹¥æœ‰äººåŽç»­"清理注解"或改写 SQL æ—¶æŠŠè¿™ä¸ªæ³¨è§£åˆ æŽ‰ï¼Œ
 * <b>编译与常规单测都不会报错</b>(只有真实走 MyBatis æ‹¦æˆªå™¨é“¾æ‰ä¼šè§¦å‘),
 * ç›´åˆ°çº¿ä¸Šç­¾åˆ°æ—¶æ‰ç‚¸ã€‚故在此<b>显式断言注解存在</b>。
 */
public class SeqCounterInterceptorIgnoreTest {
    private static final String IGNORE_MSG =
            "seq_counter çš„原子推进语句使用 MySQL ä¸“有的 INSERT ... ON DUPLICATE KEY UPDATE è¯­æ³•,"
          + "JSqlParser æ— æ³•解析。必须保留 @InterceptorIgnore(dataPermission=\"true\"),"
          + "否则 DataPermissionInterceptor ä¼šæŠ› UnsupportedOperationException,"
          + "导致预约确认(签到)直接 500。详见该注解上方的故障记录。";
    @Test
    public void advanceNormalSeq_mustIgnoreDataPermission() throws Exception {
        assertIgnorePresent("advanceNormalSeq");
    }
    @Test
    public void advanceReservedSeq_mustIgnoreDataPermission() throws Exception {
        assertIgnorePresent("advanceReservedSeq");
    }
    @Test
    public void advanceSql_shouldStillUseOnDuplicateKeyUpdate() throws Exception {
        for (String name : new String[]{"advanceNormalSeq", "advanceReservedSeq"}) {
            Method m = QueueSequenceMapper.class.getMethod(name, Integer.class, LocalDate.class);
            Update up = m.getAnnotation(Update.class);
            assertNotNull(name + " åº”保留 @Update æ³¨è§£", up);
            String sql = String.join(" ", up.value()).toUpperCase();
            assertTrue(name + " åº”使用 ON DUPLICATE KEY UPDATE å®žçŽ°åŽŸå­æŽ¨è¿›ï¼ˆå½“å‰ SQL=" + sql + ")",
                    sql.contains("ON DUPLICATE KEY UPDATE"));
            assertTrue(name + " åº”作用于 seq_counter è¡¨ï¼ˆå½“前 SQL=" + sql + ")",
                    sql.contains("SEQ_COUNTER"));
        }
    }
    private void assertIgnorePresent(String name) throws Exception {
        Method m = QueueSequenceMapper.class.getMethod(name, Integer.class, LocalDate.class);
        InterceptorIgnore ignore = m.getAnnotation(InterceptorIgnore.class);
        assertNotNull(name + " ç¼ºå°‘ @InterceptorIgnore。\n" + IGNORE_MSG, ignore);
        assertEquals(name + " çš„ @InterceptorIgnore.dataPermission å¿…须为 \"true\"。\n" + IGNORE_MSG,
                "true", ignore.dataPermission());
    }
}
jh-module-ecg/jh-module-ecg-biz/src/test/java/cn/lihu/jh/module/ecg/enums/HisCheckStatusEnumTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,108 @@
package cn.lihu.jh.module.ecg.enums;
import org.junit.Test;
import static org.junit.Assert.*;
/**
 * æ£€æŸ¥çŠ¶æ€ç¼–ç å½’ä¸€åŒ–ä¸Žå¿ƒç”µä¸šåŠ¡å•åˆ¤å®šçš„å›žå½’æµ‹è¯•ã€‚
 *
 * <p>测试数据取自 2026-09-17 ç”Ÿäº§æ—¥å¿—中的真实报文值,用于锁定本次修复的行为:
 * <ul>
 *   <li>{@code S0405} çš„ {@code processStep.code} åœ¨æŠ¥æ–‡ä¸­å­˜åœ¨ {@code "3"}/{@code "03"}/
 *       {@code "7"}/{@code "08"}/{@code "10"} ç­‰ä¸åŒå†™æ³•,必须归一到同一编码;</li>
 *   <li>病理专用状态 {@code 21}/{@code 22}/{@code 23} ä¸å±žäºŽå¿ƒç”µç¼–码表,应被拒绝;</li>
 *   <li>{@code AppointmentTypeEnum} åŽŸ type å€¼å«ç©ºæ ¼/制表符({@code "   1"}、{@code "  3\t"}),
 *       å¯¼è‡´ {@code getByType("3")} åŒ¹é…å¤±è´¥ï¼Œæ­¤å¤„固化修复结果;</li>
 *   <li>心电业务单判定需正确识别心电单与放射/超声单。</li>
 * </ul>
 *
 * @author å¿ƒç”µæŽ’队装机系统
 */
public class HisCheckStatusEnumTest {
    // ==================== HisCheckStatusEnum.normalize ====================
    @Test
    public void testNormalize_shouldStripLeadingZero() {
        // æ—¥å¿—实测:"03" ä¸Ž "3" éƒ½å‡ºçŽ°è¿‡ï¼Œå¿…é¡»å½’ä¸€ä¸ºåŒä¸€ä¸ªå€¼
        assertEquals("3", HisCheckStatusEnum.normalize("3"));
        assertEquals("3", HisCheckStatusEnum.normalize("03"));
        assertEquals("8", HisCheckStatusEnum.normalize("08"));
        assertEquals("1", HisCheckStatusEnum.normalize("01"));
    }
    @Test
    public void testNormalize_shouldTrimWhitespace() {
        assertEquals("7", HisCheckStatusEnum.normalize(" 7 "));
        assertEquals("10", HisCheckStatusEnum.normalize("\t10"));
    }
    @Test
    public void testNormalize_shouldKeepTwoDigitCodes() {
        // char(2) åˆ—可容纳 "10"
        assertEquals("10", HisCheckStatusEnum.normalize("10"));
        assertEquals("9", HisCheckStatusEnum.normalize("9"));
    }
    @Test
    public void testNormalize_shouldRejectPathologyCodes() {
        // 21 æ ‡æœ¬é€æ£€ / 22 å–消标本送检 / 23 å·²ç¼´è´¹ å±žç—…理专用,心电不涉及
        assertNull(HisCheckStatusEnum.normalize("21"));
        assertNull(HisCheckStatusEnum.normalize("22"));
        assertNull(HisCheckStatusEnum.normalize("23"));
    }
    @Test
    public void testNormalize_shouldRejectInvalidInput() {
        assertNull(HisCheckStatusEnum.normalize(null));
        assertNull(HisCheckStatusEnum.normalize(""));
        assertNull(HisCheckStatusEnum.normalize("   "));
        assertNull(HisCheckStatusEnum.normalize("abc"));
        assertNull(HisCheckStatusEnum.normalize("0"));   // ä¸åœ¨ç¼–码表内
        assertNull(HisCheckStatusEnum.normalize("99"));
    }
    @Test
    public void testContains() {
        assertTrue(HisCheckStatusEnum.contains(1));
        assertTrue(HisCheckStatusEnum.contains(10));
        assertFalse(HisCheckStatusEnum.contains(0));
        assertFalse(HisCheckStatusEnum.contains(21));
        assertFalse(HisCheckStatusEnum.contains(null));
    }
    @Test
    public void testGetNameByCode() {
        assertEquals("申请单开立", HisCheckStatusEnum.getNameByCode(1));
        assertEquals("检查登记", HisCheckStatusEnum.getNameByCode(3));
        assertEquals("报告审核", HisCheckStatusEnum.getNameByCode(10));
        assertNull(HisCheckStatusEnum.getNameByCode(21));
    }
    // ==================== AppointmentTypeEnum(修复空格污染后) ====================
    @Test
    public void testAppointmentTypeEnum_shouldMatchWithoutWhitespaceIssue() {
        // åŽŸå®žçŽ° type å€¼ä¸º "   1" / "  3\t",getByType("1") ä¼šè¿”回 null。修复后必须能匹配。
        assertEquals("申请单开立", AppointmentTypeEnum.getByType("1"));
        assertEquals("检查登记", AppointmentTypeEnum.getByType("3"));
        assertEquals("检查结束", AppointmentTypeEnum.getByType("7"));
        assertEquals("报告审核", AppointmentTypeEnum.getByType("10"));
    }
    @Test
    public void testAppointmentTypeEnum_shouldTolerateLeadingZeroAndSpace() {
        assertEquals("检查登记", AppointmentTypeEnum.getByType("03"));
        assertEquals("检查登记", AppointmentTypeEnum.getByType(" 3 "));
        assertEquals("报告审核", AppointmentTypeEnum.getByType("010"));
    }
    @Test
    public void testAppointmentTypeEnum_shouldRejectPathologyAndInvalid() {
        assertNull(AppointmentTypeEnum.getByType("21"));
        assertNull(AppointmentTypeEnum.getByType("23"));
        assertNull(AppointmentTypeEnum.getByType(null));
        assertNull(AppointmentTypeEnum.getByType("abc"));
    }
}
jh-module-ecg/jh-module-ecg-biz/src/test/java/cn/lihu/jh/module/ecg/enums/SeqRuleEnumTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,232 @@
package cn.lihu.jh.module.ecg.enums;
import org.junit.Test;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static org.junit.Assert.*;
/**
 * å›žå½’测试:排队序号规则枚举 {@link SeqRuleEnum}。
 *
 * <h3>背景</h3>
 * ã€Œç­¾åˆ°æ—¶å¦‚何给患者排队序号」按检查项目配置,三种规则:
 * <ol>
 *   <li>预约号 â€”— ç”¨é¢„约系统下发的号</li>
 *   <li>签到序号 â€”— å½“天同检查类型内连续递增,<b>每 9 ä¸ªå·é¢„ç•™ 1 ä¸ªç»™åŠ æ€¥</b></li>
 *   <li>时段号 â€”— æŒ‰é¢„约时间段给号(改造前的原有行为,也是未配置时的默认)</li>
 * </ol>
 *
 * <h3>本测试锁定的关键行为</h3>
 * <ul>
 *   <li>{@code parse(null)} å›žé€€åˆ° {@code TIMESLOT} â€”— å­˜é‡æ•°æ® {@code seq_rule} ä¸º NULL æ—¶
 *       å‡çº§åŽ**行为与改造前一致**;</li>
 *   <li>普通号序列跳过 10 çš„倍数(预留号),且**不跳号、不重号**;</li>
 *   <li>预留号序列 = 10 çš„倍数;</li>
 *   <li>两类号集合**不相交**(这是"两个独立计数器"设计的目的)。</li>
 * </ul>
 *
 * <h3>曾捕获的真实缺陷(保留记录)</h3>
 * åˆç‰ˆæŠŠã€Œåˆ†é…å·ã€å†™æˆ {@code nextNormalSeq(++counter)} å¹¶æœŸæœ›å®ƒåœ¨ 10 æ—¶è·³åˆ° 11:
 * <pre>
 *   counter: 9 â†’ åˆ†é… 9
 *   counter: 10 â†’ nextNormalSeq(10) æœŸæœ› 11?(错)
 *   counter: 11 â†’ åˆ†é… 11            â† ä¸Žä¸Šä¸€è¡Œæ’žè½¦
 * </pre>
 * æ­£ç¡®è¯­ä¹‰æ˜¯ï¼š**分配号 = {@code nextNormalSeq(counter)},且计数器在"下一个号是预留号"时额外 +1**,
 * å³ {@code normal_next = normal_next + 1 + (normal_next + 1 æ˜¯ 10 çš„倍数 ? 1 : 0)}。
 */
public class SeqRuleEnumTest {
    // ---------------------------------------------------------------- è§£æž
    @Test
    public void testParse_null_shouldFallbackToTimeslot() {
        assertEquals(SeqRuleEnum.TIMESLOT, SeqRuleEnum.parse(null));
    }
    @Test
    public void testParse_unknownValue_shouldFallbackToTimeslot() {
        assertEquals(SeqRuleEnum.TIMESLOT, SeqRuleEnum.parse(99));
        assertEquals(SeqRuleEnum.TIMESLOT, SeqRuleEnum.parse(-1));
    }
    @Test
    public void testParse_knownValues() {
        assertEquals(SeqRuleEnum.APPOINT, SeqRuleEnum.parse(0));
        assertEquals(SeqRuleEnum.CHECKIN, SeqRuleEnum.parse(1));
        assertEquals(SeqRuleEnum.TIMESLOT, SeqRuleEnum.parse(2));
    }
    // ------------------------------------------------------- å®¹é‡è¯­ä¹‰å·®å¼‚
    @Test
    public void testIsCapacityLimited_onlyTimeslot() {
        assertFalse(SeqRuleEnum.APPOINT.isCapacityLimited());
        assertFalse(SeqRuleEnum.CHECKIN.isCapacityLimited());
        assertTrue(SeqRuleEnum.TIMESLOT.isCapacityLimited());
    }
    // ---------------------------------------------------------- é¢„留号判定
    @Test
    public void testIsReservedSeq() {
        assertTrue(SeqRuleEnum.isReservedSeq(10));
        assertTrue(SeqRuleEnum.isReservedSeq(20));
        assertTrue(SeqRuleEnum.isReservedSeq(100));
        assertFalse(SeqRuleEnum.isReservedSeq(1));
        assertFalse(SeqRuleEnum.isReservedSeq(9));
        assertFalse(SeqRuleEnum.isReservedSeq(11));
        assertFalse(SeqRuleEnum.isReservedSeq(0));
        assertFalse(SeqRuleEnum.isReservedSeq(-10));
    }
    @Test
    public void testRoundSize_matchesReservedRule() {
        assertEquals(10, SeqRuleEnum.ROUND_SIZE);
        assertEquals(1, SeqRuleEnum.RESERVED_PER_ROUND);
        // ã€Œæ¯ 9 ä¸ªå·é¢„ç•™ 1 ä¸ªã€= 9 æ™®é€š + 1 é¢„ç•™ = ä¸€è½® 10 ä¸ª
        assertEquals(SeqRuleEnum.ROUND_SIZE, 9 + SeqRuleEnum.RESERVED_PER_ROUND);
    }
    // -------------------------------------------------------- æ™®é€šå·åºåˆ—
    @Test
    public void testNextNormalSeq_shouldReturnNonReservedValue() {
        // ç›´æŽ¥è¯­ä¹‰ï¼šè¿”回 from èµ·ç¬¬ä¸€ä¸ªéžé¢„留号(10 æœ¬èº«æ˜¯é¢„留号 â†’ è¿”回 11)
        assertEquals(1, SeqRuleEnum.nextNormalSeq(1));
        assertEquals(9, SeqRuleEnum.nextNormalSeq(9));
        assertEquals("10 æ˜¯é¢„留号,应跳到 11", 11, SeqRuleEnum.nextNormalSeq(10));
        assertEquals(11, SeqRuleEnum.nextNormalSeq(11));
        assertEquals(19, SeqRuleEnum.nextNormalSeq(19));
        assertEquals(21, SeqRuleEnum.nextNormalSeq(20));
        assertEquals("99 æœ¬èº«ä¸æ˜¯é¢„留号,原样返回", 99, SeqRuleEnum.nextNormalSeq(99));
        assertEquals("100 æ˜¯é¢„留号,跳到 101", 101, SeqRuleEnum.nextNormalSeq(100));
    }
    /**
     * ç«¯åˆ°ç«¯æ¨¡æ‹Ÿ Mapper çš„æŽ¨è¿›å…¬å¼ï¼ŒéªŒè¯**连续 30 æ¬¡æ™®é€šå–号不重号、不跳号**。
     *
     * <p>公式与 {@code QueueSequenceMapper#advanceNormalSeq} å®Œå…¨ä¸€è‡´ï¼š
     * <pre>
     *   INSERT æ—¶ normal_next = 1
     *   UPDATE æ—¶ normal_next = normal_next + 1 + IF((normal_next + 1) % 10 = 0, 1, 0)
     *   åˆ†é…å· = nextNormalSeq(normal_next - ä¸Šæ¬¡æŽ¨è¿›é‡)? â€”— ä¸å¯¹ï¼Œ
     *   å®žé™…是:分配号由「读回后的 normal_next」经 nextNormalSeq å¾—到
     * </pre>
     * è¿™é‡Œç”¨æœ€ç›´ç™½çš„æ–¹å¼è¿˜åŽŸï¼šç»´æŠ¤ {@code lastAssigned},每次
     * {@code next = lastAssigned + 1},若是预留号则 {@code next++}(跳过),
     * ç„¶åŽ {@code lastAssigned = next}。
     */
    @Test
    public void testNormalSequence_isContiguousAndSkipsReserved() {
        List<Integer> assigned = new ArrayList<>();
        int lastAssigned = 0;
        for (int i = 0; i < 30; i++) {
            int next = lastAssigned + 1;
            next = SeqRuleEnum.nextNormalSeq(next);   // è·³è¿‡é¢„留号
            assigned.add(next);
            lastAssigned = next;
        }
        // æœŸæœ›ï¼š1..9, 11..19, 21..29, 31..39 çš„前 30 ä¸ª
        int[] expected = {
                1, 2, 3, 4, 5, 6, 7, 8, 9,
                11, 12, 13, 14, 15, 16, 17, 18, 19,
                21, 22, 23, 24, 25, 26, 27, 28, 29,
                31, 32, 33
        };
        for (int i = 0; i < expected.length; i++) {
            assertEquals("第 " + (i + 1) + " ä¸ªæ™®é€šå·", expected[i], (int) assigned.get(i));
        }
        // æ— é‡å·
        Set<Integer> uniq = new HashSet<>(assigned);
        assertEquals("普通号不得重复", assigned.size(), uniq.size());
        // æ—  10 çš„倍数
        for (Integer v : assigned) {
            assertFalse("普通号不应落在预留号上: " + v, SeqRuleEnum.isReservedSeq(v));
        }
    }
    @Test
    public void testNextNormalSeq_withZeroOrNegative_shouldStartFromOne() {
        assertEquals(1, SeqRuleEnum.nextNormalSeq(0));
        assertEquals(1, SeqRuleEnum.nextNormalSeq(-5));
    }
    // -------------------------------------------------------- é¢„留号序列
    @Test
    public void testNextReservedSeq_shouldLandOnMultiplesOfTen() {
        assertEquals(10, SeqRuleEnum.nextReservedSeq(1));
        assertEquals(10, SeqRuleEnum.nextReservedSeq(9));
        assertEquals("已是预留号则原样返回", 10, SeqRuleEnum.nextReservedSeq(10));
        assertEquals(20, SeqRuleEnum.nextReservedSeq(11));
        assertEquals(20, SeqRuleEnum.nextReservedSeq(19));
        assertEquals(20, SeqRuleEnum.nextReservedSeq(20));
        assertEquals(30, SeqRuleEnum.nextReservedSeq(21));
    }
    @Test
    public void testNextReservedSeq_withZeroOrNegative() {
        assertEquals(10, SeqRuleEnum.nextReservedSeq(0));
        assertEquals(10, SeqRuleEnum.nextReservedSeq(-3));
    }
    // ------------------------------------------------- ä¸¤ç±»å·ä¸ç›¸äº¤ï¼ˆæ ¸å¿ƒï¼‰
    @Test
    public void testNormalAndReservedSequences_shouldNotIntersect() {
        // æ¨¡æ‹Ÿï¼š100 æ¬¡æ™®é€š + 10 æ¬¡åŠ æ€¥
        Set<Integer> normalSet = new HashSet<>();
        int last = 0;
        for (int i = 0; i < 100; i++) {
            last = SeqRuleEnum.nextNormalSeq(last + 1);
            normalSet.add(last);
        }
        Set<Integer> reservedSet = new HashSet<>();
        int reservedNext = 10;
        for (int i = 0; i < 10; i++) {
            reservedSet.add(SeqRuleEnum.nextReservedSeq(reservedNext));
            reservedNext += SeqRuleEnum.ROUND_SIZE;
        }
        assertEquals("普通号不得重复", 100, normalSet.size());
        assertEquals("预留号不得重复", 10, reservedSet.size());
        Set<Integer> intersection = new HashSet<>(normalSet);
        intersection.retainAll(reservedSet);
        assertTrue("普通号与预留号集合不得有交集,实际交集=" + intersection, intersection.isEmpty());
    }
    /**
     * åè¯ï¼šä¸ºä»€ä¹ˆå¿…é¡»"两个独立计数器"。
     *
     * <p>若只用一个递增计数器、并把"跳过预留号"后的值直接当号码用,
     * ä¼šåœ¨ counter=10 ä¸Ž counter=11 ä¸¤æ¬¡åˆ†é…ä¸­å¾—到同一个号 11。
     */
    @Test
    public void testSingleCounterNaiveApproach_wouldCollide_documentingWhyTwoCounters() {
        assertEquals("counter=10 æ—¶è·³è¿‡é¢„留号得到 11", 11, SeqRuleEnum.nextNormalSeq(10));
        assertEquals("counter=11 æ—¶ç›´æŽ¥å¾—到 11", 11, SeqRuleEnum.nextNormalSeq(11));
        assertEquals("两者相同 â†’ æœ´ç´ åšæ³•必然重号",
                SeqRuleEnum.nextNormalSeq(10), SeqRuleEnum.nextNormalSeq(11));
    }
    // ------------------------------------------------------------- æ•°ç»„
    @Test
    public void testArrays_containsAllRules() {
        int[] arrays = SeqRuleEnum.ARRAYS;
        assertEquals(3, arrays.length);
        assertEquals(0, arrays[0]);
        assertEquals(1, arrays[1]);
        assertEquals(2, arrays[2]);
    }
}
jh-module-ecg/jh-module-ecg-biz/src/test/java/cn/lihu/jh/module/ecg/service/appointment/HisCheckAppointmentCheckTypeTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,110 @@
package cn.lihu.jh.module.ecg.service.appointment;
import org.junit.Test;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
import static org.junit.Assert.*;
/**
 * é”å®š S050401/S050501(检查预约)报文中检查类型的解析行为。
 *
 * <h3>背景(2026-10 æŽ’查结论)</h3>
 * ä½“检/门诊的 S050401 å•è‹¥**先于** S0201ECG åˆ°è¾¾ï¼Œä¼šèµ°
 * {@code insertAppointmentFromCheckAppointment} å®¹é”™æ–°å¢žï¼›
 * è€Œè¯¥è·¯å¾„原先**完全不解析检查类型**,导致 {@code appointment.book_check_type} æ°¸è¿œä¸ºç©ºã€‚
 * <p>实测:{@code id=213466 / apply_no=8826092420371106}(2026-10-08 07:15 å®¹é”™æ–°å¢žï¼‰ï¼Œ
 * æŠ¥æ–‡ {@code ZDY} é‡Œæ˜Žæ˜Žæœ‰ {@code jcxmcode=201652, jcxmname=平板运动试验},
 * åº”映射为本地 {@code book_check_type=400}(平板运动心电)。
 * å…¨åº“同类记录 279 æ¡ï¼ˆ2026-09-29 ~ 10-09)。
 *
 * <p>本次修复新增 {@code resolveBookCheckTypeFromZdy(ZDY, applyNo)},并接入两条路径:
 * <ol>
 *   <li>容错新增({@code insertAppointmentFromCheckAppointment});</li>
 *   <li>更新已有记录({@code handleCheckAppointmentUpdate})。</li>
 * </ol>
 * ä¸¤æ¡è·¯å¾„均遵循**仅在为空时补**(不覆盖已有值)。
 */
public class HisCheckAppointmentCheckTypeTest {
    private Integer invokeResolve(Map<String, Object> zdy) throws Exception {
        AppointmentServiceImpl svc = new AppointmentServiceImpl();
        Method m = AppointmentServiceImpl.class.getDeclaredMethod(
                "resolveBookCheckTypeFromZdy", Map.class, String.class);
        m.setAccessible(true);
        return (Integer) m.invoke(svc, zdy, "TEST-APPLY-NO");
    }
    private Map<String, Object> zdy(String name, String code) {
        Map<String, Object> m = new HashMap<>();
        if (name != null) {
            m.put("jcxmname", name);
        }
        if (code != null) {
            m.put("jcxmcode", code);
        }
        return m;
    }
    /** çœŸå®žæ•…障样本:平板运动试验 â†’ 400(走名称映射) */
    @Test
    public void testRealCase_pingBanYunDong_shouldMapTo400() throws Exception {
        Integer v = invokeResolve(zdy("平板运动试验", "201652"));
        assertNotNull("真实故障单的检查类型必须能解析出来", v);
        assertEquals("平板运动试验 åº”映射为 400(平板运动心电)", Integer.valueOf(400), v);
        System.out.println("[真实样本] jcxmname=平板运动试验 jcxmcode=201652 -> " + v);
    }
    /** HIS æ”¹ååœºæ™¯ï¼šä»…给代码 201652 ä¹Ÿè¦èƒ½æ˜ å°„(名称映射不到时按代码兜底) */
    @Test
    public void testNameUnmappable_shouldFallbackToCode() throws Exception {
        Integer v = invokeResolve(zdy("某个HIS新叫法未收录", "201652"));
        assertEquals("名称映射不到时应按代码兜底为 400", Integer.valueOf(400), v);
        System.out.println("[代码兜底] jcxmname=未收录 jcxmcode=201652 -> " + v);
    }
    /** ä»£ç å¸¦ Y å‰ç¼€ï¼ˆS0201ECG å½¢æ€ï¼‰ä¹Ÿè¦å…¼å®¹ */
    @Test
    public void testCodeWithYPrefix_shouldBeCompatible() throws Exception {
        assertEquals(Integer.valueOf(400), invokeResolve(zdy(null, "Y201652")));
        System.out.println("[前缀兼容] jcxmcode=Y201652 -> 400");
    }
    /** å¤šå€¼ï¼šå–第一个能映射成功的(实测 S0405 å­˜åœ¨ jcxmcode=1080,3016,3014 å½¢æ€ï¼‰ */
    @Test
    public void testMultiValue_shouldPickFirstMappable() throws Exception {
        // ç¬¬ä¸€ä¸ªæ˜¯æ— å…³ä»£ç ï¼Œç¬¬äºŒä¸ªæ‰æ˜¯å¿ƒç”µé¡¹ç›®
        Integer v = invokeResolve(zdy(null, "999999,201652"));
        assertEquals("多值应取第一个能映射的", Integer.valueOf(400), v);
        System.out.println("[多值] jcxmcode=999999,201652 -> " + v);
    }
    /** åç§°å¤šå€¼ï¼ˆä¸­æ–‡é€—号)也要支持 */
    @Test
    public void testMultiValueName_shouldPickFirstMappable() throws Exception {
        Integer v = invokeResolve(zdy("胸部CT平扫,平板运动试验", null));
        assertEquals("名称多值应取第一个能映射的", Integer.valueOf(400), v);
        System.out.println("[多值名称] jcxmname=胸部CT平扫,平板运动试验 -> " + v);
    }
    /** å…¶å®ƒå¿ƒç”µé¡¹ç›®ä¹Ÿåº”正确映射(防只看一个样本) */
    @Test
    public void testOtherCheckTypes_shouldMapCorrectly() throws Exception {
        assertEquals(Integer.valueOf(100), invokeResolve(zdy("常规心电图", "691133607")));
        assertEquals(Integer.valueOf(200), invokeResolve(zdy("24小时动态心电图", "201605")));
        assertEquals(Integer.valueOf(300), invokeResolve(zdy("动态血压", "200327")));
        assertEquals(Integer.valueOf(1100), invokeResolve(zdy("常规心电图-心电向量图", "559542128")));
        System.out.println("[多项目] 100/200/300/1100 å‡æ˜ å°„正确");
    }
    /** æ— æ³•映射时返回 null(调用方保持原值,不写坏数据)且不抛异常 */
    @Test
    public void testUnmappable_shouldReturnNullQuietly() throws Exception {
        assertNull("非心电项目应返回 null", invokeResolve(zdy("胸部CT平扫", "1080")));
        assertNull("空 ZDY åº”返回 null", invokeResolve(new HashMap<>()));
        assertNull("null ZDY åº”返回 null ä¸”不抛异常", invokeResolve(null));
        System.out.println("[健壮性] æ— æ³•映射/空/null å‡è¿”回 null ä¸”不抛异常");
    }
}
jh-module-ecg/jh-module-ecg-biz/src/test/java/cn/lihu/jh/module/ecg/service/appointment/HisDoctorTimeParseTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,93 @@
package cn.lihu.jh.module.ecg.service.appointment;
import org.junit.Test;
import java.lang.reflect.Method;
import java.text.SimpleDateFormat;
import static org.junit.Assert.*;
/**
 * é”å®š HIS「开单时间」({@code author/time/value}) çš„解析行为。
 *
 * <h3>背景(2026-10 æŽ’查结论)</h3>
 * åº“中约 61% çš„ {@code appointment.doctor_time} ä¸º {@code yyyy-MM-dd 00:00:00}。
 * ç»æ ¸æŸ¥**不是**本项目的格式化缺陷(列是 datetime、写入 java.util.Date、
 * çœŸå®žæŠ¥æ–‡æ ·ä¾‹å¸¦æ—¶åˆ†ç§’),而是 **HIS ä¾§æŠŠè¯¥å­—段的时分秒发成了 {@code 000000}**。
 * è¯æ®ï¼šæœ‰çœŸå®žæ—¶åˆ»çš„记录呈正常工作时段分布(06:00→08:00 é«˜å³°ï¼‰ï¼Œ
 * è½åœ¨å¼€è¯Šå‰ 00:00-05:59 çš„ä»… 15 æ¡ï¼›é—¨è¯Š(book_src=1) 99.6%、住院(book_src=2) 90.5% ä¸º 0 ç‚¹ã€‚
 *
 * <p>因此本次修复**不改变写入值**(保留 HIS åŽŸå€¼ï¼Œæ—¥æœŸä»æœ‰æ„ä¹‰ï¼‰ï¼Œåªåšä¸¤ä»¶äº‹ï¼š
 * <ol>
 *   <li>把原先「静默丢弃」的解析失败改为带<b>报文原值 + ç”³è¯·å•号</b>的 WARN æ—¥å¿—ï¼›</li>
 *   <li>识别「日期值」形态并打 WARN,便于向 HIS ä¾§åé¦ˆã€‚</li>
 * </ol>
 *
 * <p>本测试通过反射直接验证私有解析方法,保证上述行为不被回退。
 */
public class HisDoctorTimeParseTest {
    /** åå°„调用私有方法 parseHisDoctorTime(String, String) */
    private Object invokeParse(String raw) throws Exception {
        AppointmentServiceImpl svc = new AppointmentServiceImpl();
        Method m = AppointmentServiceImpl.class.getDeclaredMethod(
                "parseHisDoctorTime", String.class, String.class);
        m.setAccessible(true);
        return m.invoke(svc, raw, "TEST-APPLY-NO");
    }
    /** åå°„调用私有方法 isHisTimeOnlyDate(String) */
    private boolean invokeIsDateOnly(String raw) throws Exception {
        AppointmentServiceImpl svc = new AppointmentServiceImpl();
        Method m = AppointmentServiceImpl.class.getDeclaredMethod("isHisTimeOnlyDate", String.class);
        m.setAccessible(true);
        return (Boolean) m.invoke(svc, raw);
    }
    /** æ­£å¸¸å½¢æ€ï¼š14 ä½ä¸”带时分秒 â€”— å¿…须原样解析出真实时刻 */
    @Test
    public void testNormalTime_shouldParseExactMoment() throws Exception {
        Object parsed = invokeParse("20260917005255");
        assertNotNull("14 ä½å¸¦æ—¶åˆ†ç§’的值必须解析成功", parsed);
        String s = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(parsed);
        assertEquals("2026-09-17 00:52:55", s);
        System.out.println("[正常形态] 20260917005255 -> " + s);
    }
    /** ã€Œæ—¥æœŸå€¼ã€å½¢æ€ï¼šæ—¶åˆ†ç§’=000000 â€”— ä»è¦å†™å…¥ï¼ˆä¿ç•™æ—¥æœŸï¼‰ï¼Œåªæ˜¯ä¼šæ‰“ WARN */
    @Test
    public void testDateOnlyValue_shouldStillParseAndKeepDate() throws Exception {
        Object parsed = invokeParse("20261001000000");
        assertNotNull("日期值形态仍应解析成功(日期有意义,不能丢)", parsed);
        String s = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(parsed);
        assertEquals("2026-10-01 00:00:00", s);
        assertTrue("应被识别为日期值", invokeIsDateOnly("20261001000000"));
        System.out.println("[日期值形态] 20261001000000 -> " + s + "(日期保留,时刻为 HIS æ‰€å‘原值)");
    }
    /** çœŸå®žæ—¶åˆ»ä¸åº”被误判为日期值 */
    @Test
    public void testRealTime_shouldNotBeTreatedAsDateOnly() throws Exception {
        assertFalse(invokeIsDateOnly("20260917005255"));
        assertFalse(invokeIsDateOnly("20261001120000"));
        assertFalse(invokeIsDateOnly("20261001000001"));
        System.out.println("[误判防护] å¸¦çœŸå®žæ—¶åˆ†ç§’的值均未被判定为日期值");
    }
    /** éž 14 ä½ï¼ˆHIS æ–‡æ¡£æ ·ä¾‹ä¸­å‡ºçŽ°è¿‡ 12 ä½ yyyyMMddHHmm)—— è¿”回 null,不写库 */
    @Test
    public void testNonFourteenDigit_shouldReturnNull() throws Exception {
        assertNull("12 ä½ä¸åº”被解析(原实现会静默丢弃,现改为 WARN)", invokeParse("201205061000"));
        assertNull("空串不应被解析", invokeParse(""));
        assertNull("null ä¸åº”被解析", invokeParse(null));
        System.out.println("[非14位] 12 ä½/空/null å‡è¿”回 null(并打 WARN),不会写坏数据");
    }
    /** éžæ³•格式不应抛异常(避免影响整条报文落库) */
    @Test
    public void testInvalidFormat_shouldNotThrow() throws Exception {
        assertNull(invokeParse("abc"));
        assertNull(invokeParse("20261301000000")); // 13 æœˆ
        System.out.println("[健壮性] éžæ³•格式返回 null ä¸”不抛异常");
    }
}
jh-module-ecg/jh-module-ecg-biz/src/test/java/cn/lihu/jh/module/ecg/service/appointment/HisParseSafetyTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,388 @@
package cn.lihu.jh.module.ecg.service.appointment;
import org.junit.Test;
import java.lang.reflect.Method;
import java.util.*;
import static org.junit.Assert.*;
/**
 * å›žå½’测试:HIS æŠ¥æ–‡è§£æžçš„**类型安全导航**。
 *
 * <h3>背景</h3>
 * 2026-09-28 ç”Ÿäº§æ—¥å¿—出现 **287 æ¬¡** {@code ClassCastException:
 * class java.lang.String cannot be cast to class java.util.Map},
 * é›†ä¸­åœ¨ {@code extractCheckCodeFromComponent2} ç­‰æ–¹æ³•的链式强制转换上。
 *
 * <p>原因是 HIS æŠ¥æ–‡ç» Jackson ååºåˆ—化后**同一字段类型并不稳定**:
 * {@code observationRequest.code} åœ¨éƒ¨åˆ†æŠ¥æ–‡ä¸­æ˜¯ {@code Map}
 * ï¼ˆ{@code code={code=7,...}}),在另一些报文中是**空字符串**({@code code=})。
 * é“¾å¼ {@code .map(m -> (Map<String,Object>) m.get("code"))} é‡åˆ°å­—符串即抛异常,
 * **导致整条消息处理中断**(预约时间、房间等字段写不进库)。
 *
 * <p>本测试锁定修复后的行为:遇到任意类型混合都返回 null è€Œ**不抛异常**。
 */
public class HisParseSafetyTest {
    /** æž„造典型报文片段:{@code code=} ä¸ºç©ºå­—符串 */
    private static Map<String, Object> payloadWithEmptyStringCode() {
        Map<String, Object> codeLeaf = new LinkedHashMap<>();
        codeLeaf.put("code", "");              // â† å…³é”®ï¼šç©ºå­—符串而非 Map
        codeLeaf.put("displayName", mapOf("value", "检查结束"));
        Map<String, Object> obs = new LinkedHashMap<>();
        obs.put("id", mapOf("item", mapOf("extension", "2126092800574")));
        obs.put("code", "");                   // â† å…³é”®ï¼šobservationRequest.code æ˜¯ç©ºå­—符串
        obs.put("component1", mapOf("processStep", mapOf("code", codeLeaf)));
        obs.put("component2", mapOf("observationRequest", mapOf("code", "")));
        return mapOf("controlActProcess",
                mapOf("subject", mapOf("placerGroup", mapOf("component2", obs))));
    }
    /** æ­£å¸¸å½¢æ€ï¼š{@code code} æ˜¯ Map(按 S0201ECG çœŸå®žç»“构构造) */
    private static Map<String, Object> payloadWithMapCode() {
        // æ£€æŸ¥çŠ¶æ€ï¼šcomponent2.observationRequest.component1.processStep.code.code
        Map<String, Object> statusCode = mapOf(
                "code", "7",
                "codeSystem", "2.16.156.10011.2.5.1.12",
                "displayName", mapOf("value", "检查结束"));
        Map<String, Object> processStep = mapOf("code", statusCode);
        Map<String, Object> component1 = mapOf("processStep", processStep);
        // æ£€æŸ¥é¡¹ç›®ï¼šcomponent2.observationRequest.code.code
        Map<String, Object> checkCode = mapOf(
                "code", "Y1202065",
                "displayName", mapOf("value", "床边心电图(常规+频谱)"));
        Map<String, Object> innerObs = mapOf(
                "id", mapOf("item", mapOf("root", "2.16.156.10011.1.28", "extension", "113034157")),
                "code", checkCode,
                "component1", component1);
        Map<String, Object> component2 = mapOf("observationRequest", innerObs);
        Map<String, Object> outerObs = mapOf("component2", component2);
        return mapOf("controlActProcess",
                mapOf("subject",
                    mapOf("observationRequest", outerObs)));
    }
    private static Map<String, Object> mapOf(Object... kv) {
        Map<String, Object> m = new LinkedHashMap<>();
        for (int i = 0; i + 1 < kv.length; i += 2) {
            m.put((String) kv[i], kv[i + 1]);
        }
        return m;
    }
    /** åå°„调用私有的 dig æ–¹æ³• */
    private static Map<String, Object> invokeDig(Map<String, Object> root, String... path) throws Exception {
        Method m = AppointmentServiceImpl.class.getDeclaredMethod("dig", Map.class, String[].class);
        m.setAccessible(true);
        @SuppressWarnings("unchecked")
        Map<String, Object> r = (Map<String, Object>) m.invoke(null, root, path);
        return r;
    }
    /** åå°„调用私有的 digText æ–¹æ³• */
    private static String invokeDigText(Map<String, Object> root, String... path) throws Exception {
        Method m = AppointmentServiceImpl.class.getDeclaredMethod("digText", Map.class, String[].class);
        m.setAccessible(true);
        return (String) m.invoke(null, root, path);
    }
    // ==================== æ ¸å¿ƒï¼šä¸å¾—抛 ClassCastException ====================
    @Test
    public void testDigText_emptyStringInPath_shouldReturnNullNotThrow() throws Exception {
        Map<String, Object> payload = payloadWithEmptyStringCode();
        // code æ˜¯ç©ºå­—符串 â†’ åº”安全返回 null,而不是抛 ClassCastException
        String v = invokeDigText(payload, "controlActProcess", "subject", "placerGroup",
                "component2", "observationRequest", "code", "code");
        assertNull(v);
    }
    @Test
    public void testDigText_stringWhereMapExpected_shouldNotThrow() throws Exception {
        // æ•´æ¡è·¯å¾„上放字符串,模拟最恶劣的形态
        Map<String, Object> payload = mapOf("controlActProcess", "I-AM-A-STRING");
        assertNull(invokeDigText(payload, "controlActProcess", "subject", "placerGroup"));
    }
    @Test
    public void testDig_emptyStringInPath_shouldReturnNullNotThrow() throws Exception {
        Map<String, Object> payload = payloadWithEmptyStringCode();
        assertNull(invokeDig(payload, "controlActProcess", "subject", "placerGroup",
                "component2", "observationRequest", "component1", "processStep", "code", "ZDY"));
    }
    @Test
    public void testDig_nullInPath_shouldReturnNull() throws Exception {
        assertNull(invokeDig(mapOf("a", null), "a", "b"));
        assertNull(invokeDig(new LinkedHashMap<>(), "notExist"));
    }
    @Test
    public void testDigText_listInPath_shouldTakeFirstElement() throws Exception {
        // item=[{code=X}] å½¢æ€ï¼šdigText åº”自动取首元素继续下钻
        Map<String, Object> payload = mapOf("methodCode",
                mapOf("item", Collections.singletonList(mapOf("code", "M001"))));
        assertEquals("M001", invokeDigText(payload, "methodCode", "item", "code"));
    }
    @Test
    public void testDigText_emptyListInPath_shouldReturnNull() throws Exception {
        Map<String, Object> payload = mapOf("methodCode", mapOf("item", Collections.emptyList()));
        assertNull(invokeDigText(payload, "methodCode", "item", "code"));
    }
    @Test
    public void testDigText_shouldStillReadNormalValues() throws Exception {
        Map<String, Object> payload = payloadWithMapCode();
        // æ­£å¸¸å½¢æ€å¿…须照常读出,不能因加了防御而失效
        // ä¸Žç”Ÿäº§ä»£ç  extractCheckCodeFromComponent2 ä½¿ç”¨çš„æ˜¯**同样的路径写法**
        assertEquals("Y1202065", invokeDigText(payload, "controlActProcess", "subject",
                "observationRequest", "component2", "observationRequest", "code", "code"));
    }
    @Test
    public void testDigText_shouldReadProcessStepStatus() throws Exception {
        // æ£€æŸ¥çŠ¶æ€ç ï¼šcontrolActProcess.subject.observationRequest.component2
        //            .observationRequest.component1.processStep.code.code
        Map<String, Object> payload = payloadWithMapCode();
        assertEquals("7", invokeDigText(payload, "controlActProcess", "subject",
                "observationRequest", "component2", "observationRequest",
                "component1", "processStep", "code", "code"));
    }
    @Test
    public void testDigText_basicTraversal() throws Exception {
        // æœ€åŸºç¡€ï¼šä¸‰å±‚ Map å–值
        Map<String, Object> payload = mapOf("a", mapOf("b", mapOf("c", "VALUE")));
        assertEquals("VALUE", invokeDigText(payload, "a", "b", "c"));
        // ä¸¤å±‚
        assertEquals("X", invokeDigText(mapOf("a", mapOf("b", "X")), "a", "b"));
        // ä¸€å±‚
        assertEquals("Y", invokeDigText(mapOf("a", "Y"), "a"));
        // Map æœ«ç«¯å– value
        assertEquals("Z", invokeDigText(mapOf("a", mapOf("b", mapOf("value", "Z"))), "a", "b"));
    }
    @Test
    public void testDigText_blankString_shouldReturnNull() throws Exception {
        // ç©º/纯空白字符串应视为"无值",便于上层用 isEmpty åˆ¤æ–­
        Map<String, Object> payload = mapOf("k", "   ");
        assertNull(invokeDigText(payload, "k"));
        assertNull(invokeDigText(mapOf("k", ""), "k"));
    }
    // ==================== åŒ»å˜±å·æå–(findExtensionByRoot) ====================
    //
    // ä¾æ® HIS《交互服务通用接口文档》:
    //   6.1 ç”³è¯·å•新增服务 â†’ observationRequest.id.item[root=2.16.156.10011.1.28] æ³¨é‡Šä¸ºã€ŒåŒ»å˜±ID」
    //   6.4 æ£€æŸ¥é¢„约状态服务 â†’ åŒä¸€ root æ³¨é‡Šä¸ºã€ŒåŒ»å˜±å·ã€
    // å®žæµ‹ S0201ECG 220 æ¡ã€S0202ECG 29 æ¡å‡æºå¸¦è¯¥å€¼ï¼ˆå½¢å¦‚ 112663505)。
    //
    // å…³é”®é£Žé™©ï¼šid.item åœ¨éƒ¨åˆ†æœåŠ¡ä¸­æ˜¯**数组**,同时含
    //   1.24 ç”³è¯·å•号 ä¸Ž 1.28 åŒ»å˜±å·
    // è‹¥ç”¨ digText(遇 List åªå–首元素)会把**申请单号当成医嘱号**。
    // ä¸‹åˆ—用例锁定「按 root åŒ¹é…ã€çš„æ­£ç¡®è¡Œä¸ºã€‚
    private static final String ROOT_ORDER_NO = "2.16.156.10011.1.28";
    private static final String ROOT_APPLY_NO = "2.16.156.10011.1.24";
    /** åå°„调用私有的 findExtensionByRoot æ–¹æ³• */
    private static String invokeFindExtensionByRoot(Object idNode, String root) throws Exception {
        Method m = AppointmentServiceImpl.class.getDeclaredMethod("findExtensionByRoot", Object.class, String.class);
        m.setAccessible(true);
        return (String) m.invoke(null, idNode, root);
    }
    @Test
    public void testFindExtensionByRoot_singleMap_shouldReturnExtension() throws Exception {
        // å®žæµ‹ S0201ECG çš„真实形态:id={item={root=1.28, extension=112663505}}
        Object idNode = mapOf("item", mapOf("root", ROOT_ORDER_NO, "extension", "112663505"));
        assertEquals("112663505", invokeFindExtensionByRoot(idNode, ROOT_ORDER_NO));
    }
    @Test
    public void testFindExtensionByRoot_listWithBothRoots_shouldPickOrderNo() throws Exception {
        // å…³é”®ç”¨ä¾‹ï¼šæ•°ç»„同时含 1.24(申请单号) ä¸Ž 1.28(医嘱号)
        // digText ä¼šå–首元素(申请单号)→ é”™ï¼›findExtensionByRoot å¿…须按 root å‘½ä¸­ 1.28
        List<Object> items = Arrays.asList(
                mapOf("root", ROOT_APPLY_NO, "extension", "2126091700011"),
                mapOf("root", ROOT_ORDER_NO, "extension", "112663505"));
        Object idNode = mapOf("item", items);
        assertEquals("112663505", invokeFindExtensionByRoot(idNode, ROOT_ORDER_NO));
    }
    @Test
    public void testFindExtensionByRoot_listWithBothRoots_reverseOrder_shouldStillPickOrderNo() throws Exception {
        // é¡ºåºé¢ å€’也必须命中(不能依赖"恰好第一个是医嘱号")
        List<Object> items = Arrays.asList(
                mapOf("root", ROOT_ORDER_NO, "extension", "112663700"),
                mapOf("root", ROOT_APPLY_NO, "extension", "2126091700012"));
        assertEquals("112663700", invokeFindExtensionByRoot(mapOf("item", items), ROOT_ORDER_NO));
    }
    @Test
    public void testFindExtensionByRoot_listWithoutOrderNo_shouldReturnNull() throws Exception {
        // åªæœ‰ç”³è¯·å•号时,绝不能把申请单号当医嘱号返回
        List<Object> items = Collections.singletonList(
                mapOf("root", ROOT_APPLY_NO, "extension", "2126091700011"));
        assertNull(invokeFindExtensionByRoot(mapOf("item", items), ROOT_ORDER_NO));
    }
    @Test
    public void testFindExtensionByRoot_emptyOrBlankExtension_shouldReturnNull() throws Exception {
        // è§„范中该节点 extension å¯ä¸ºç©ºï¼ˆ6.1 ç¤ºä¾‹å³ extension="")→ åº”返回 null è€Œéžç©ºä¸²
        assertNull(invokeFindExtensionByRoot(
                mapOf("item", mapOf("root", ROOT_ORDER_NO, "extension", "")), ROOT_ORDER_NO));
        assertNull(invokeFindExtensionByRoot(
                mapOf("item", mapOf("root", ROOT_ORDER_NO, "extension", "   ")), ROOT_ORDER_NO));
        assertNull(invokeFindExtensionByRoot(
                mapOf("item", mapOf("root", ROOT_ORDER_NO)), ROOT_ORDER_NO));
    }
    @Test
    public void testFindExtensionByRoot_nullAndMalformed_shouldReturnNullNotThrow() throws Exception {
        assertNull(invokeFindExtensionByRoot(null, ROOT_ORDER_NO));
        assertNull(invokeFindExtensionByRoot(new LinkedHashMap<>(), ROOT_ORDER_NO));
        assertNull(invokeFindExtensionByRoot(mapOf("item", null), ROOT_ORDER_NO));
        assertNull(invokeFindExtensionByRoot(mapOf("item", Collections.emptyList()), ROOT_ORDER_NO));
        // item æ˜¯å­—符串(类型不稳定)→ ä¸å¾—抛异常
        assertNull(invokeFindExtensionByRoot(mapOf("item", "I-AM-A-STRING"), ROOT_ORDER_NO));
        // root ç¼ºå¤±
        assertNull(invokeFindExtensionByRoot(mapOf("item", mapOf("extension", "X")), ROOT_ORDER_NO));
        // root ä¸º null
        assertNull(invokeFindExtensionByRoot(mapOf("item", mapOf("root", null, "extension", "X")), ROOT_ORDER_NO));
    }
    @Test
    public void testFindExtensionByRoot_listContainingNonMap_shouldSkipGracefully() throws Exception {
        List<Object> items = Arrays.asList(
                "NOT-A-MAP",
                mapOf("root", ROOT_ORDER_NO, "extension", "112663932"));
        assertEquals("112663932", invokeFindExtensionByRoot(mapOf("item", items), ROOT_ORDER_NO));
    }
    @Test
    public void testFindExtensionByRoot_rootWithWhitespace_shouldStillMatch() throws Exception {
        // æŠ¥æ–‡é‡Œ root å¯èƒ½å¸¦ç©ºç™½ï¼Œéœ€ trim åŽæ¯”较
        Object idNode = mapOf("item", mapOf("root", "  " + ROOT_ORDER_NO + "  ", "extension", "112664036"));
        assertEquals("112664036", invokeFindExtensionByRoot(idNode, ROOT_ORDER_NO));
    }
    // ======================================================================
    // éœ€æ±‚2:ISCB「是否床边」解析(zdy è‡ªå®šä¹‰å­—段区)
    // ======================================================================
    /** åå°„调用私有的 findBedsideFlag(dataMap) */
    private static String invokeFindBedsideFlag(Map<String, Object> dataMap) throws Exception {
        Method m = AppointmentServiceImpl.class.getDeclaredMethod("findBedsideFlag", Map.class);
        m.setAccessible(true);
        return (String) m.invoke(null, dataMap);
    }
    /** æŒ‰çœŸå®ž S0201ECG ç»“构构造含 zdy çš„æŠ¥æ–‡ */
    private static Map<String, Object> payloadWithZdy(Map<String, Object> zdyContent) {
        Map<String, Object> obs = new LinkedHashMap<>();
        obs.put("zdy", zdyContent);
        return mapOf("controlActProcess", mapOf("subject", mapOf("observationRequest", obs)));
    }
    @Test
    public void testFindBedsideFlag_presentZero_shouldReturnZero() throws Exception {
        // å®žæµ‹çœŸå®žæŠ¥æ–‡ä¸­ ISCB æ’为 "0"
        Map<String, Object> zdy = new LinkedHashMap<>();
        zdy.put("ISCB", "0");
        zdy.put("ISDN", "0");
        assertEquals("0", invokeFindBedsideFlag(payloadWithZdy(zdy)));
    }
    @Test
    public void testFindBedsideFlag_presentOne_shouldReturnOne() throws Exception {
        Map<String, Object> zdy = new LinkedHashMap<>();
        zdy.put("ISCB", "1");
        assertEquals("1", invokeFindBedsideFlag(payloadWithZdy(zdy)));
    }
    @Test
    public void testFindBedsideFlag_absent_shouldReturnNull() throws Exception {
        // å®˜æ–¹æ–‡æ¡£å®šä¹‰çš„ 19 ä¸ªå­—段里没有 ISCB â€”— è¿™ç±»æŠ¥æ–‡åº”返回 null(不写库)
        Map<String, Object> zdy = new LinkedHashMap<>();
        zdy.put("jjbz", "N");
        zdy.put("babyFlag", "0");
        assertNull(invokeFindBedsideFlag(payloadWithZdy(zdy)));
    }
    @Test
    public void testFindBedsideFlag_emptyString_shouldReturnNull() throws Exception {
        Map<String, Object> zdy = new LinkedHashMap<>();
        zdy.put("ISCB", "");
        assertNull("空字符串应视作未提供", invokeFindBedsideFlag(payloadWithZdy(zdy)));
        Map<String, Object> zdy2 = new LinkedHashMap<>();
        zdy2.put("ISCB", "   ");
        assertNull("纯空白应视作未提供", invokeFindBedsideFlag(payloadWithZdy(zdy2)));
    }
    @Test
    public void testFindBedsideFlag_nonStringValue_shouldToString() throws Exception {
        // Jackson ååºåˆ—化后标量类型可能是 Integer/Boolean,需稳妥 toString
        Map<String, Object> zdy = new LinkedHashMap<>();
        zdy.put("ISCB", 1);
        assertEquals("1", invokeFindBedsideFlag(payloadWithZdy(zdy)));
        Map<String, Object> zdy2 = new LinkedHashMap<>();
        zdy2.put("ISCB", 0);
        assertEquals("0", invokeFindBedsideFlag(payloadWithZdy(zdy2)));
    }
    @Test
    public void testFindBedsideFlag_whitespaceAroundValue_shouldTrim() throws Exception {
        Map<String, Object> zdy = new LinkedHashMap<>();
        zdy.put("ISCB", "  1  ");
        assertEquals("1", invokeFindBedsideFlag(payloadWithZdy(zdy)));
    }
    @Test
    public void testFindBedsideFlag_nullOrMalformedPayload_shouldReturnNullWithoutThrow() throws Exception {
        // ç±»åž‹ä¸ç¨³å®šæ˜¯ HIS æŠ¥æ–‡çš„常态(历史上曾因此抛 ClassCastException ä¸­æ–­æ•´æ¡æ¶ˆæ¯ï¼‰ï¼Œ
        // è¿™é‡Œå¿…é¡»**不抛异常**
        assertNull(invokeFindBedsideFlag(null));
        assertNull(invokeFindBedsideFlag(mapOf()));
        assertNull("controlActProcess æ˜¯å­—符串", invokeFindBedsideFlag(mapOf("controlActProcess", "x")));
        assertNull("subject æ˜¯ç©ºå­—符串", invokeFindBedsideFlag(
                mapOf("controlActProcess", mapOf("subject", ""))));
        assertNull("observationRequest ç¼ºå¤±", invokeFindBedsideFlag(
                mapOf("controlActProcess", mapOf("subject", mapOf()))));
        assertNull("zdy æ˜¯å­—符串而非 Map", invokeFindBedsideFlag(
                mapOf("controlActProcess", mapOf("subject",
                        mapOf("observationRequest", mapOf("zdy", "not-a-map"))))));
    }
    @Test
    public void testFindBedsideFlag_realisticFullZdy_shouldReturnValue() throws Exception {
        // çœŸå®žæŠ¥æ–‡çš„ zdy æœ‰ 26 ä¸ªå­—段(文档只定义 19 ä¸ªï¼‰ï¼ŒæŒ‰æ—¥å¿—原样构造关键部分
        Map<String, Object> zdy = new LinkedHashMap<>();
        zdy.put("ApplyTotalFee", "19");
        zdy.put("Urgency", "");
        zdy.put("PayStatus", "1");
        zdy.put("HospitalCode", "01");
        zdy.put("HospitalName", "金华市人民医院");
        zdy.put("jjbz", "N");
        zdy.put("babyFlag", "0");
        zdy.put("ISCB", "1");            // â† éœ€æ±‚2 å…³æ³¨çš„字段
        zdy.put("ISDN", "0");
        zdy.put("BLLSH", "3054365");
        zdy.put("SWCJBS", "0");
        zdy.put("KDYSSJH", "15158660102");
        zdy.put("KLX", "");
        zdy.put("VTEGWBZ", "0");
        assertEquals("1", invokeFindBedsideFlag(payloadWithZdy(zdy)));
    }
}
jh-module-ecg/jh-module-ecg-biz/src/test/java/cn/lihu/jh/module/ecg/service/appointment/RegisterDateTruncationTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,139 @@
package cn.lihu.jh.module.ecg.service.appointment;
import cn.lihu.jh.framework.common.util.object.BeanUtils;
import cn.lihu.jh.module.ecg.controller.admin.appointment.vo.AppointmentSaveReqVO;
import cn.lihu.jh.module.ecg.dal.dataobject.appointment.AppointmentDO;
import org.junit.Test;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.text.SimpleDateFormat;
import static org.junit.Assert.*;
/**
 * é”å®š {@code register_date}「时间被截断为 00:00:00」的成因与修复。
 *
 * <h3>现象</h3>
 * 2026-09-27 èµ· {@code appointment.register_date} å¤§é‡å˜æˆ {@code yyyy-MM-dd 00:00:00},
 * è€Œ 09-26 ä¹‹å‰æ˜¯ {@code 2026-09-26 16:58:53} è¿™æ ·çš„完整时间。
 *
 * <h3>成因(经实验确定)</h3>
 * {@code java.sql.Date} æ˜¯**只表示日期**的 JDBC ç±»åž‹ï¼›æŠŠå®ƒå†™å…¥ {@code DATETIME} åˆ—时,
 * æ—¶é—´éƒ¨åˆ†å¿…然变成 {@code 00:00:00}。实测(同连接串、同列):
 * <pre>
 *   setObject(java.util.Date)  â†’ 2026-09-26 16:58:53    ä¿ç•™
 *   setObject(java.sql.Date)   â†’ 2026-09-26 00:00:00    ä¸¢å¼ƒ
 * </pre>
 * è€Œ {@code AppointmentSaveReqVO}/{@code AppointmentRespVO}/{@code AppointmentPageReqVO}
 * åŽŸå…ˆéƒ½æŠŠ {@code registerDate}/{@code doctorTime} å£°æ˜Žä¸º {@code java.sql.Date}
 * ï¼ˆç» {@code import java.sql.Date;}),对应的 {@code AppointmentDO} å´æ˜¯
 * {@code java.util.Date} â€”— ç±»åž‹ä¸ä¸€è‡´å¯¼è‡´å¾€è¿”过程中时间丢失。
 *
 * <h3>修复</h3>
 * ä¸‰ä¸ª VO ç»Ÿä¸€æ”¹ä¸º {@code java.util.Date}。本测试锁定「类型一致」这一约束。
 */
public class RegisterDateTruncationTest {
    private static final String SAMPLE = "2026-09-26 16:58:53";
    /** ç”Ÿäº§/测试库连接串(与 application-local.yaml ä¸€è‡´ï¼‰ */
    private static final String JDBC_URL =
            "jdbc:mysql://47.93.127.38:3308/lihu?useSSL=false&serverTimezone=Asia/Shanghai"
          + "&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true";
    /**
     * æ ¸å¿ƒçº¦æŸï¼šVO ä¸Ž DO çš„ registerDate/doctorTime å¿…须是**同一种** Date ç±»åž‹ï¼Œ
     * ä¸”都不是 {@code java.sql.Date}(否则时间会被丢弃)。
     */
    @Test
    public void testRegisterDateTypes_shouldBeConsistentAndNotSqlDate() throws Exception {
        Class<?> voReg = AppointmentSaveReqVO.class.getDeclaredField("registerDate").getType();
        Class<?> voDoc = AppointmentSaveReqVO.class.getDeclaredField("doctorTime").getType();
        Class<?> doReg = AppointmentDO.class.getDeclaredField("registerDate").getType();
        Class<?> doDoc = AppointmentDO.class.getDeclaredField("doctorTime").getType();
        System.out.println("[类型] VO.registerDate = " + voReg.getName());
        System.out.println("[类型] VO.doctorTime  = " + voDoc.getName());
        System.out.println("[类型] DO.registerDate = " + doReg.getName());
        System.out.println("[类型] DO.doctorTime  = " + doDoc.getName());
        assertNotEquals("VO.registerDate ä¸å¾—为 java.sql.Date(会丢弃时分秒)",
                "java.sql.Date", voReg.getName());
        assertNotEquals("VO.doctorTime ä¸å¾—为 java.sql.Date",
                "java.sql.Date", voDoc.getName());
        assertEquals("VO ä¸Ž DO çš„ registerDate ç±»åž‹å¿…须一致",
                doReg.getName(), voReg.getName());
        assertEquals("VO ä¸Ž DO çš„ doctorTime ç±»åž‹å¿…须一致",
                doDoc.getName(), voDoc.getName());
    }
    /** å¾€è¿”转换不得丢失时间 */
    @Test
    public void testDoToVoToDo_keepsTime() throws Exception {
        AppointmentDO source = new AppointmentDO();
        source.setApplyNo("TEST_TRUNC_1");
        source.setRegisterDate(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(SAMPLE));
        source.setDoctorTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(SAMPLE));
        AppointmentSaveReqVO vo = BeanUtils.toBean(source, AppointmentSaveReqVO.class);
        AppointmentDO back = BeanUtils.toBean(vo, AppointmentDO.class);
        String reg = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(back.getRegisterDate());
        String doc = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(back.getDoctorTime());
        System.out.println("[往返] registerDate = " + reg + "  doctorTime = " + doc);
        assertEquals("registerDate ç» VO å¾€è¿”后不应丢失时间", SAMPLE, reg);
        assertEquals("doctorTime ç» VO å¾€è¿”后不应丢失时间", SAMPLE, doc);
    }
    /**
     * å¤çŽ°å®žéªŒï¼ˆéœ€è¦æµ‹è¯•åº“å¯è®¿é—®ï¼‰ï¼šè¯æ˜Žã€Œjava.sql.Date â†’ DATETIME åˆ—」必然截断。
     * <p>若数据库不可达则跳过(不影响其它断言的价值)。
     */
    @Test
    public void testJdbcWrite_sqlDateTruncates_utilDateKeeps() throws Exception {
        Connection c = null;
        try {
            c = DriverManager.getConnection(JDBC_URL, "smartor", "Smartor.2016");
        } catch (Exception e) {
            System.out.println("[跳过] æ•°æ®åº“不可达: " + e.getMessage());
            return;
        }
        try {
            c.setAutoCommit(false);
            try (Statement s = c.createStatement()) {
                s.execute("DROP TABLE IF EXISTS _probe_regdate_test");
                s.execute("CREATE TABLE _probe_regdate_test (id INT PRIMARY KEY AUTO_INCREMENT, "
                        + "d_util DATETIME NULL, d_sql DATETIME NULL)");
            }
            java.util.Date utilDate = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(SAMPLE);
            java.sql.Date sqlDate = new java.sql.Date(utilDate.getTime());
            try (PreparedStatement ps = c.prepareStatement(
                    "INSERT INTO _probe_regdate_test (d_util, d_sql) VALUES (?,?)")) {
                ps.setObject(1, utilDate);
                ps.setObject(2, sqlDate);
                ps.executeUpdate();
            }
            try (Statement s = c.createStatement();
                 ResultSet r = s.executeQuery("SELECT d_util, d_sql FROM _probe_regdate_test")) {
                assertTrue(r.next());
                String util = r.getString(1);
                String sql = r.getString(2);
                System.out.println("[实验] java.util.Date â†’ " + util);
                System.out.println("[实验] java.sql.Date  â†’ " + sql);
                assertEquals("java.util.Date åº”保留时分秒", SAMPLE, util);
                assertEquals("java.sql.Date å¿…然丢成 00:00:00",
                        "2026-09-26 00:00:00", sql);
            }
            try (Statement s = c.createStatement()) { s.execute("DROP TABLE _probe_regdate_test"); }
            c.rollback();
        } finally {
            if (c != null) { try { c.close(); } catch (Exception ignore) { } }
        }
    }
}
jh-module-ecg/jh-module-ecg-biz/src/test/java/cn/lihu/jh/module/ecg/service/his/HisEcgFilterTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,328 @@
package cn.lihu.jh.module.ecg.service.his;
import org.junit.Test;
import java.util.Arrays;
import java.util.Collections;
import static org.junit.Assert.*;
/**
 * å¿ƒç”µä¸šåŠ¡å•åˆ¤å®šï¼ˆ{@link HisEcgFilter})回归测试。
 *
 * <p>全部测试数据取自 2026-09-17 ç”Ÿäº§æ—¥å¿—中的真实报文值:
 * <ul>
 *   <li><b>心电单</b>:{@code S0201ECG} / {@code S0405} ä¸­å±žå¿ƒç”µçš„号与检查项目;</li>
 *   <li><b>非心电单</b>:同一时间窗内 {@code S0405} æŽ¨é€çš„æ”¾å°„/超声单
 *       ï¼ˆå®žæµ‹å  96.5%,是本次过滤要拦掉的主体)。</li>
 * </ul>
 *
 * @author å¿ƒç”µæŽ’队装机系统
 */
public class HisEcgFilterTest {
    private HisEcgFilter filter = new HisEcgFilter();
    // ==================== ä¾æ® A:申请单号后缀 ====================
    @Test
    public void testSuffix_shouldMatchEcgApplyNo() {
        // æ—¥å¿—实测的心电申请单号(S0201ECG åˆ›å»ºï¼‰
        assertTrue(filter.matchApplyNoSuffix("8826091720001101"));
        assertTrue(filter.matchApplyNoSuffix("8826081120716401"));
        assertTrue(filter.matchApplyNoSuffix("8826091520521101"));
        assertTrue(filter.matchApplyNoSuffix("8826091420571101"));
    }
    @Test
    public void testSuffix_shouldNotMatchRadiologyApplyNo() {
        // æ—¥å¿—实测的放射/超声申请单号(S0405 æŽ¨é€ï¼Œçº¯æ•°å­—流水号,无心电后缀)
        assertFalse(filter.matchApplyNoSuffix("2126091602867"));
        assertFalse(filter.matchApplyNoSuffix("2126091700001"));
        assertFalse(filter.matchApplyNoSuffix("2126091700038"));
        assertFalse(filter.matchApplyNoSuffix("2126091700190"));
    }
    @Test
    public void testSuffix_shouldHandleBlank() {
        assertFalse(filter.matchApplyNoSuffix(null));
        assertFalse(filter.matchApplyNoSuffix(""));
        assertFalse(filter.matchApplyNoSuffix("   "));
    }
    // ==================== ä¾æ® B:检查项目代码 / åç§° ====================
    @Test
    public void testCheckCode_shouldMatchWithAndWithoutPrefix() {
        // æ—¥å¿—实测:S0201ECG æŠ¥æ–‡çš„ component2...code.code = "Y1202065"(床边心电图)
        // HisCheckCodeEnum çš„ code ä¸ºåŽ»å‰ç¼€åŽçš„ "1202065"
        assertTrue(filter.matchCheckCode("Y1202065"));
        assertTrue(filter.matchCheckCode("1202065"));
        // å…¶ä»–实心电项目
        assertTrue(filter.matchCheckCode("Y691133607"));
        assertTrue(filter.matchCheckCode("Y559542128"));
    }
    @Test
    public void testCheckCode_shouldNotMatchRadiologyCode() {
        // æ—¥å¿—实测:放射/超声项目的 jcxmcode(不在心电枚举内)
        assertFalse(filter.matchCheckCode("1080"));   // èƒ¸éƒ¨CT平扫
        assertFalse(filter.matchCheckCode("1393"));   // å³è‚˜æ­£ä¾§ä½
        assertFalse(filter.matchCheckCode("1006"));   // ä¸Šä¸‹é¢Œéª¨
        assertFalse(filter.matchCheckCode("35689493")); // å½©è¶…子宫附件
    }
    @Test
    public void testCheckName_shouldMatchEcgNames() {
        // S0201ECG æŠ¥æ–‡ä¸­å‡ºçŽ°çš„å¿ƒç”µæ£€æŸ¥é¡¹ç›®åç§°
        assertTrue(filter.matchCheckName("常规心电图"));
        assertTrue(filter.matchCheckName("24小时动态心电图"));
        assertTrue(filter.matchCheckName("床边心电图(常规+频谱)"));
        assertTrue(filter.matchCheckName("常规心电图-心电向量图"));
    }
    @Test
    public void testCheckName_shouldNotMatchRadiologyNames() {
        // S0405 ä¸­å‡ºçŽ°çš„å…¶ä»–ç§‘å®¤é¡¹ç›®åç§°
        assertFalse(filter.matchCheckName("胸部CT平扫"));
        assertFalse(filter.matchCheckName("颅脑MRI平扫(含DWI)"));
        assertFalse(filter.matchCheckName("彩超肝胆胰脾"));
        assertFalse(filter.matchCheckName("骨密度"));
        assertFalse(filter.matchCheckName("右腕关节正侧位"));
    }
    // ==================== ä¾æ® C:执行科室(可配置白名单) ====================
    @Test
    public void testDeptCode_notConfigured_shouldNotFilter() {
        // æœªé…ç½®ç™½åå•时,依据 C ä¸å‚与判定,避免误判
        assertFalse(filter.matchDeptCode("YJ001"));
        assertFalse(filter.matchDeptCode("YJ002"));
    }
    @Test
    public void testDeptCode_configured_shouldMatch() {
        filter.setEcgDeptCodes(Arrays.asList("YJECG01", "YJECG02"));
        assertTrue(filter.matchDeptCode("YJECG01"));
        assertTrue(filter.matchDeptCode("yjecg02"));   // å¿½ç•¥å¤§å°å†™
        assertFalse(filter.matchDeptCode("YJ001"));    // æ”¾å°„科室不在白名单
    }
    @Test
    public void testDeptCode_shouldHandleEmptyWhitelistAndBlank() {
        filter.setEcgDeptCodes(Collections.emptyList());
        assertFalse(filter.matchDeptCode("YJ001"));
        filter.setEcgDeptCodes(Arrays.asList("YJECG01"));
        assertFalse(filter.matchDeptCode(null));
        assertFalse(filter.matchDeptCode(""));
    }
    // ==================== ç»¼åˆåˆ¤å®š ====================
    @Test
    public void testJudge_ecgOrderBySuffix() {
        HisEcgFilter.Result r = filter.judge("8826091720001101", null, null, null);
        assertTrue(r.isEcg());
        assertTrue("应命中依据 A,实际=" + r.getReason(), r.getReason().startsWith("A:"));
    }
    @Test
    public void testJudge_ecgOrderByCheckCode() {
        // ç”³è¯·å•号不是心电后缀,但检查项目是心电 â†’ ä»åº”判定为心电单
        HisEcgFilter.Result r = filter.judge("2126091700001", "Y1202065", null, null);
        assertTrue(r.isEcg());
        assertTrue("应命中依据 B,实际=" + r.getReason(), r.getReason().startsWith("B:"));
    }
    @Test
    public void testJudge_nonEcgRadiologyOrder() {
        // å…¸åž‹æ”¾å°„单:CT å¹³æ‰« + æ€¥è¯ŠCT室 â†’ ä¸‰é¡¹éƒ½ä¸å‘½ä¸­
        HisEcgFilter.Result r = filter.judge("2126091602867", "1080", "胸部CT平扫", "YJ002");
        assertFalse("放射单不应被判为心电单", r.isEcg());
    }
    @Test
    public void testJudge_nonEcgUltrasoundOrder() {
        // å…¸åž‹è¶…声单
        HisEcgFilter.Result r = filter.judge("2126091700038", "35711720", "彩超宫颈管长度", "YJ001");
        assertFalse(r.isEcg());
    }
    @Test
    public void testIsEcgOrder_convenience() {
        assertTrue(filter.isEcgOrder("S0405", "8826091720001101", null, null, null));
        assertFalse(filter.isEcgOrder("S0405", "2126091602867", "1080", "胸部CT平扫", "YJ002"));
    }
    // ==================== å¤šå€¼æ£€æŸ¥é¡¹ç›®ï¼ˆS050401/S050501 çš„ ZDY.jcxmcode / jcxmname) ====================
    @Test
    public void testMatchCheckItems_shouldSplitCommaSeparated() {
        // å®žæµ‹ S050401:非心电(DR æ­£ä¾§ä½ï¼‰
        assertFalse(filter.matchCheckItems("1393,1506", "右肘正侧位,左肩关节正侧位"));
        // å®žæµ‹ S050501:非心电(彩超)
        assertFalse(filter.matchCheckItems("35672306,35672504,35672544,35672596",
                "彩超右下肢动脉,彩超右下肢静脉,彩超左下肢动脉,彩超左下肢静脉"));
    }
    @Test
    public void testMatchCheckItems_shouldMatchWhenAnyIsEcg() {
        // æ··åˆåœºæ™¯ï¼šåªè¦æœ‰ä¸€ä¸ªæ˜¯å¿ƒç”µå°±ç®—心电单
        assertTrue(filter.matchCheckItems("1393", "右肘正侧位,常规心电图"));
        assertTrue(filter.matchCheckItems("Y1202065", "彩超肝胆胰脾"));
    }
    // ==================== å…³é”®å­—兜底(心电项目名均含「心电」) ====================
    @Test
    public void testCheckName_keywordFallback() {
        // æœªæ”¶å½•进枚举的历史/新增长尾名称,只要含「心电」即认可
        assertTrue(filter.matchCheckName("十二导联心电检查"));
        assertTrue(filter.matchCheckName("心电监护"));
        assertTrue(filter.matchCheckName("运动心电"));
        // æžšä¸¾å·²æ”¶å½•的仍正常
        assertTrue(filter.matchCheckName("常规心电图"));
    }
    /**
     * âš ï¸ å…³é”®å›žå½’:**不含「心电」二字的心电检查必须仍被判为心电**。
     *
     * <p>用户指出的反例。核对 {@code check_type} è¡¨ç¡®è®¤ï¼Œæœ¬é™¢ 15 ä¸ªå¿ƒç”µç±»åž‹ä¸­
     * æœ‰ 4 ä¸ªåç§°ä¸å«ã€Œå¿ƒç”µã€ï¼Œåº“中合计 2 ä¸‡ä½™æ¡çœŸå®žè®°å½•:
     * <pre>
     *   300 åŠ¨æ€è¡€åŽ‹ï¼ˆ9083 æ¡ï¼‰  500 åŠ¨è„‰ç¡¬åŒ–æ£€æµ‹ï¼ˆ11205 æ¡ï¼‰
     *   600 é£Ÿé“电生理(60 æ¡ï¼‰  900 å¿ƒå®¤æ™šç”µä½ï¼ˆ0 æ¡ï¼‰
     * </pre>
     * è‹¥ä»…用"含心电"判定,这些单会被**误判为非心电而丢弃**。
     */
    @Test
    public void testCheckName_ecgWithoutKeyword_shouldStillBeAccepted() {
        // â€”— ç”¨æˆ·ä¸¾çš„反例 â€”—
        assertTrue("动态血压(check_type=300,库中 9083 æ¡ï¼‰ä¸åº”被丢弃",
                filter.matchCheckName("动态血压"));
        assertTrue("动脉硬化(check_type=500,库中 11205 æ¡ï¼‰ä¸åº”被丢弃",
                filter.matchCheckName("动脉硬化"));
        assertTrue("运动试验(check_type=400)不应被丢弃",
                filter.matchCheckName("运动试验"));
        assertTrue("食道电生理(check_type=600)不应被丢弃",
                filter.matchCheckName("食道电生理"));
        // â€”— check_type è¡¨ä¸­çš„准确名称 â€”—
        assertTrue(filter.matchCheckName("动脉硬化检测"));
        assertTrue(filter.matchCheckName("心室晚电位"));
        assertTrue(filter.matchCheckName("平板运动心电"));
        assertTrue(filter.matchCheckName("心电图药物负荷试验"));
        assertTrue(filter.matchCheckName("动态心电图+血压"));
        // â€”— ç”Ÿäº§æ—¥å¿—中的实际名称变体(S050401 å®žæµ‹ï¼‰â€”—
        assertTrue("实测 S050401 ä¸­ '24小时动态血压监测' ä¸å«ã€Œå¿ƒç”µã€ï¼Œé ç™½åå•通过",
                filter.matchCheckName("24小时动态血压监测"));
        assertTrue(filter.matchCheckName("24小时动态心电图"));
        assertTrue(filter.matchCheckName("24小时动态心电监测"));
        assertTrue("实测 S050401 ä¸­ '心电图运动试验' åº”对应 check_type=400",
                filter.matchCheckName("心电图运动试验"));
        // å¹³æ¿è¿åŠ¨çš„å„ç§å†™æ³•
        assertTrue(filter.matchCheckName("平板运动试验"));
        assertTrue(filter.matchCheckName("平板运动心电图"));
        assertTrue(filter.matchCheckName("活动平板试验"));
    }
    /**
     * éžå¿ƒç”µæŽ’除词:名称含「心脏」但属其他科室者,必须判否。
     * ï¼ˆ"彩超胎儿心脏""超声造影立体成像(右心)"均来自实测日志)
     */
    @Test
    public void testCheckName_nonEcgExcludeKeywords() {
        assertFalse("彩超胎儿心脏属超声科,不应被当作心电",
                filter.matchCheckName("彩超胎儿心脏"));
        assertFalse(filter.matchCheckName("超声造影立体成像(右心)"));
        assertFalse(filter.matchCheckName("彩超心脏(常规)"));
        assertFalse(filter.matchCheckName("彩超心脏(14岁以下)"));
        assertFalse(filter.matchCheckName("彩超床旁心包腔"));
    }
    /**
     * ç”¨å®žæµ‹æ—¥å¿—中出现的 111 ä¸ªéžå¿ƒç”µé¡¹ç›®åç§°åšå›žå½’:
     * è¿™äº›æ˜¯ S0405 / S050401 / S050501 é‡ŒæŽ¨é€çš„æ”¾å°„、超声、内镜等项目,
     * å¿…é¡»**全部**判为非心电,否则会被误当作心电申请单采集。
     */
    @Test
    public void testRealNonEcgNames_shouldAllBeRejected() {
        String[] nonEcgNames = {
            "彩超肝胆胰脾", "彩超产检(单胎)", "彩超心脏(常规)", "彩超颈动脉",
            "彩超甲状腺及颈部淋巴结", "彩超早孕(经阴5-11w)", "彩超产检1",
            "彩色多普勒超声检查(弹性成像)", "彩超早孕(经腹5-11w)", "彩超子宫附件(经腹)",
            "彩超子宫附件(经阴道)", "彩超双肾输尿管膀胱", "彩超椎动脉", "彩超肾上腺",
            "彩超肾动脉", "彩超产检(双胎)", "彩超双乳及腋窝淋巴结", "彩超阑尾",
            "彩超宫颈管长度", "超声造影立体成像(右心)", "彩超右下肢动脉", "彩超睾丸",
            "彩超卵泡监测(经阴道)", "彩超床旁胸腔", "彩超床旁左下肢静脉",
            "彩超床旁心包腔", "彩超床旁双肾输尿管膀胱", "彩超腹膜后淋巴结",
            "彩超床旁肝胆胰脾", "彩超胸腔", "彩超胎儿系统性筛查", "彩超盆腔超声(经阴道)",
            "彩超涎腺(颌下腺、腮腺)", "彩超心脏(经食管-卵圆孔)", "彩超子宫动脉(经阴道)",
            "彩超甲状腺", "彩超腹股沟淋巴结(双侧)", "彩超心脏(14岁以下)",
            "彩超体表包块1(请标注1块包块具体位置)",
            "胸部平扫", "胸部CT平扫", "颅脑CT平扫", "上腹部CT平扫", "口腔CT",
            "下腹部CT平扫", "盆腔CT平扫", "胸部高分辨(HRCT)", "胸部CT增强",
            "颅脑MRI平扫(含DWI)", "颅脑MRI平扫", "颅脑平扫", "颅脑平扫+DWI",
            "腰椎平扫", "颈椎平扫", "腰椎+二、三维", "右肩关节平扫", "左膝关节平扫",
            "右膝关节平扫", "右足平扫", "全腹部平扫+增强", "全腹部平扫(平扫三个部位)",
            "上腹部增强", "前列腺增强", "肝脏增强", "乳腺增强", "盆腔增强",
            "冠脉CTA", "脑CTA", "颈CTA", "颅脑TOF MRA",
            "胸部正位", "胸部正侧位", "胸部正斜位(起搏器)", "小儿胸片",
            "右膝关节", "左膝关节正侧位", "右膝关节正侧位", "右股骨全长正侧位",
            "左胫腓骨全长正侧位", "骨盆正位", "腰椎正侧位", "腰椎过屈过伸",
            "右肘正侧位", "右手指正侧位", "右腕关节正侧位", "右腕关节",
            "右踝关节正侧位", "左肩关节正斜位", "右尺桡骨正侧位", "右肱骨正侧位",
            "全下肢正位", "左胫腓骨", "上下颌骨", "乳突水平", "颈椎", "腰椎",
            "腹部平片(卧位)KUB", "双肾输尿管全长", "骨密度",
            "肺通气功能检查", "肺弥散功能检查", "支气管激发试验检查", "支气管舒张试验",
            "一氧化氮呼气测定", "无痛胃镜", "电子胃镜检查", "无痛肠镜",
            "无创肝弹性测定", "心脏功能"
        };
        java.util.List<String> wrong = new java.util.ArrayList<>();
        for (String name : nonEcgNames) {
            if (filter.matchCheckName(name)) {
                wrong.add(name);
            }
        }
        assertTrue("以下非心电项目被误判为心电: " + wrong, wrong.isEmpty());
    }
    // ==================== judgeWithZdy(S050401/S050501 ä¸“用入口) ====================
    @Test
    public void testJudgeWithZdy_shouldRejectRadiology() {
        java.util.Map<String, Object> zdy = new java.util.HashMap<>();
        zdy.put("jcxmcode", "1393,1506");
        zdy.put("jcxmname", "右肘正侧位,左肩关节正侧位");
        zdy.put("roomname", "2号机房(DR)");
        HisEcgFilter.Result r = filter.judgeWithZdy("2126091700001", "ZY012", zdy);
        assertFalse("DR æ­£ä¾§ä½ä¸åº”判为心电单: " + r.getReason(), r.isEcg());
    }
    @Test
    public void testJudgeWithZdy_shouldRejectUltrasound() {
        java.util.Map<String, Object> zdy = new java.util.HashMap<>();
        zdy.put("jcxmcode", "35672306,35672504");
        zdy.put("jcxmname", "彩超右下肢动脉,彩超右下肢静脉");
        zdy.put("roomname", "5号诊室");
        assertFalse(filter.judgeWithZdy("2126091700038", "ZY012", zdy).isEcg());
    }
    @Test
    public void testJudgeWithZdy_shouldAcceptEcgOrder() {
        // å¿ƒç”µå•:名称含「心电」
        java.util.Map<String, Object> zdy = new java.util.HashMap<>();
        zdy.put("jcxmcode", "1202065");
        zdy.put("jcxmname", "床边心电图(常规+频谱)");
        zdy.put("roomname", "心电检查室");
        HisEcgFilter.Result r = filter.judgeWithZdy("2126091700001", "ZY012", zdy);
        assertTrue("心电项目应判为心电单: " + r.getReason(), r.isEcg());
    }
    @Test
    public void testJudgeWithZdy_shouldAcceptByApplyNoSuffix() {
        // å³ä¾¿æ£€æŸ¥é¡¹ç›®å–不到,只要申请单号带心电后缀也认可
        java.util.Map<String, Object> zdy = new java.util.HashMap<>();
        assertTrue(filter.judgeWithZdy("8826091720001101", null, zdy).isEcg());
        assertTrue(filter.judgeWithZdy("8826081120716401", null, null).isEcg());
    }
}
jh-server/src/main/java/cn/lihu/jh/server/ecg/FrontendAuthorizeRequestsCustomizer.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,106 @@
package cn.lihu.jh.server.ecg;
import cn.lihu.jh.framework.security.config.AuthorizeRequestsCustomizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.ExpressionUrlAuthorizationConfigurer;
/**
 * å‰ç«¯é™æ€èµ„源与 SPA é¡µé¢å¤–壳的免登录规则。
 *
 * <h3>为什么需要这个类</h3>
 * æŠŠå‰ç«¯æ‰“进了 jar ä¹‹åŽï¼ŒåŽç«¯å¼€å§‹æ‰¿æ‹…"提供页面与静态资源"的职责,
 * ä½†èŠ‹é“æ¡†æž¶é»˜è®¤çš„é™æ€èµ„æºç™½åå•åªè¦†ç›–è„šæœ¬ä¸Žæ ·å¼ï¼š
 *
 * <pre>
 *   JhWebSecurityConfigurerAdapter#filterChain:
 *   .antMatchers(GET, "/*.html", "/**&#47;*.html", "/**&#47;*.css", "/**&#47;*.js").permitAll()
 * </pre>
 *
 * å› æ­¤ä¸‹åˆ—请求会命中兜底规则 {@code anyRequest().authenticated()} å¹¶è¿”回
 * {@code 401 è´¦å·æœªç™»å½•},表现为**浏览器白屏或掉线**:
 * <ul>
 *   <li>{@code /logo.gif}、{@code /favicon.ico} â€”— ç™»å½•页与加载动画依赖的图片</li>
 *   <li>{@code /assets/*.svg|png|woff} â€”— å›¾æ ‡ä¸Žå­—体(js/css æ°å¥½è¢«ä¸Šé¢è§„则放行,
 *       æ‰€ä»¥æœ€åˆç—‡çŠ¶æ˜¯"接口都通、页面白屏",极具迷惑性)</li>
 *   <li>页面路径 â€”— ç”¨æˆ·åˆ·æ–°æˆ–直接访问时浏览器尚未携带 token,
 *       è‹¥è¦æ±‚认证会直接 401,导致"一刷新就掉线"</li>
 * </ul>
 *
 * <h3>页面路径清单的来源</h3>
 * å–自 {@code ecg_web/src/router/modules/remaining.ts} çš„顶层静态路由
 * ï¼ˆpath ä¸å«åŠ¨æ€å‚æ•°è€…ï¼‰ï¼Œä»¥åŠåŠ¨æ€èœå•ï¼ˆ{@code system_menu} è¡¨ï¼‰å¯èƒ½å‡ºçŽ°çš„
 * ä¸šåŠ¡å‰ç¼€ï¼š
 * <pre>
 *   /  /user  /dict  /codegen  /job  /login  /roomselect
 *   /redirect  /sso  /social-login  /403  /404  /500
 *   /calling-screen-big  /calling-screen-room
 *   /ecg/**  /clinic/**  /system/**  /infra/**
 * </pre>
 *
 * <h3>安全性</h3>
 * æ”¾è¡Œçš„**只是前端页面外壳与静态资源**,不涉及任何数据:
 * <ul>
 *   <li>{@code /admin-api/**} å…¨éƒ¨ä¸šåŠ¡æŽ¥å£ä»èµ° token é‰´æƒï¼ŒæœªæŽˆæƒæ‹¿ä¸åˆ°æ•°æ®</li>
 *   <li>页面外壳本身不含任何患者信息,内容由前端拿到 token åŽå¦è¡Œè¯·æ±‚</li>
 *   <li>{@code /system/**}、{@code /infra/**} ç­‰æ”¾è¡Œçš„æ˜¯**页面**,
 *       å…¶æ•°æ®æŽ¥å£ä½äºŽ {@code /admin-api/system/**}、{@code /admin-api/infra/**},
 *       ä»åœ¨é‰´æƒä¿æŠ¤ä¹‹ä¸‹</li>
 *   <li>这与合并前 nginx æŠŠé™æ€é¡µé¢å…¬å¼€æ‰˜ç®¡çš„行为一致,未降低安全等级</li>
 * </ul>
 * æ³¨æ„ {@code /admin-api/**} ç‰¹æ„ä¸åœ¨æ”¾è¡Œä¹‹åˆ—,避免误伤接口鉴权。
 *
 * @author å¿ƒç”µæŽ’队装机系统
 */
public class FrontendAuthorizeRequestsCustomizer extends AuthorizeRequestsCustomizer {
    /** é™æ€èµ„源。不含 html/css/js â€”— æ¡†æž¶å·²æ”¾è¡Œï¼Œæ­¤å¤„补齐其余类型 */
    private static final String[] STATIC_RESOURCE_PATTERNS = {
            "/favicon.ico", "/logo.gif", "/logo.png", "/logo0.png",
            "/assets/**", "/static/**",
    };
    /**
     * SPA é¡µé¢å¤–壳。
     * å¿…须与 {@link SpaForwardController} çš„æ˜ å°„保持同步,
     * å¦åˆ™ä¼šå‡ºçް"转发能到、但被安全层拦下 401"的不一致现象。
     *
     * <h4>⚠️ ä¸ºä»€ä¹ˆ ECG å¿…须逐条精确枚举,不能用 /ecg/**</h4>
     * ECG çš„后端接口与前端页面共用 {@code /ecg/} å‘½åç©ºé—´ï¼Œä¾‹å¦‚:
     * <pre>
     *   æŽ¥å£: /ecg/screen/big-screen-data、/ecg/queue/page、/ecg/call/next
     *   é¡µé¢: /ecg/queue、/ecg/room、/ecg/check-type
     * </pre>
     * è‹¥æ­¤å¤„放行 {@code /ecg/**},则**所有 ECG æŽ¥å£éƒ½ä¼šå˜æˆå…é‰´æƒ**,
     * å±žäºŽä¸¥é‡çš„安全漏洞(未登录即可读写排队数据)。
     * ç²¾ç¡®è·¯å¾„在 Spring ä¸­ä¸ä¼šåŒ¹é…å¸¦å­è·¯å¾„的接口,因此是安全的。
     */
    private static final String[] SPA_PAGE_PATTERNS = {
            "/", "/index.html",
            // æ¥è‡ª remaining.ts çš„顶层静态路由
            "/user/**", "/dict/**", "/codegen/**", "/job/**",
            "/login", "/roomselect",
            "/redirect/**", "/sso", "/social-login",
            "/403", "/404", "/500",
            "/calling-screen-big", "/calling-screen-room",
            // â€”— ECG ä¸šåŠ¡é¡µï¼šå¿…é¡»ç²¾ç¡®ï¼Œç¦æ­¢ /ecg/** â€”—
            "/ecg/appointment", "/ecg/appointment-confirm", "/ecg/alter",
            "/ecg/queue", "/ecg/roomselect",
            "/ecg/doc/routine", "/ecg/doc/dev-ready", "/ecg/doc/dev-install",
            "/ecg/dev-dismantle", "/ecg/dev-rent",
            "/ecg/doctor/dev-data-entry",
            "/ecg/job-statistic", "/ecg/dev-statistic",
            "/ecg/room", "/ecg/roomstatus", "/ecg/dev-model",
            "/ecg/device", "/ecg/check-type",
            // â€”— è¯Šå®¤é¡µï¼ˆæŽ¥å£ /clinic/room/* å¸¦å­è·¯å¾„,不冲突)——
            "/clinic/room",
            // â€”— ç³»ç»Ÿç®¡ç†ä¸ŽåŸºç¡€è®¾æ–½é¡µï¼ˆå…¶æŽ¥å£å‡ä½äºŽ /admin-api ä¸‹ï¼Œä¸å†²çªï¼‰â€”—
            "/system/**", "/infra/**",
    };
    @Override
    public void customize(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry) {
        registry.antMatchers(STATIC_RESOURCE_PATTERNS).permitAll();
        registry.antMatchers(SPA_PAGE_PATTERNS).permitAll();
    }
}
jh-server/src/main/java/cn/lihu/jh/server/ecg/FrontendResourceConfig.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,109 @@
package cn.lihu.jh.server.ecg;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
 * å‰ç«¯é™æ€èµ„源映射(配合"前端打进 jar"的合并部署方案)。
 *
 * <h3>作用</h3>
 * æŠŠ jar å†… {@code META-INF/resources/frontend/} ä¸‹çš„前端构建产物映射到站点根路径。
 *
 * <pre>
 *   URL                         æ¥æº
 *   /assets/index-xxx.js   ->   classpath:/META-INF/resources/frontend/assets/index-xxx.js
 *   /logo.gif              ->   classpath:/META-INF/resources/frontend/logo.gif
 *   /favicon.ico           ->   classpath:/META-INF/resources/frontend/favicon.ico
 *   /static/jquery.min.js  ->   classpath:/META-INF/resources/frontend/static/jquery.min.js
 * </pre>
 *
 * <h3>⚠ ä¸ºä»€ä¹ˆã€ä¸èƒ½ã€‘用 {@code /**} é€šé…ï¼ˆé‡è¦ï¼‰</h3>
 * åˆç‰ˆæ›¾å†™æˆ {@code registry.addResourceHandler("/**")},这是**错误**的:
 * ä¸€æ—¦æŠŠ {@code /**} æŒ‡å‘前端目录,以下后端自带的路径会被静态资源处理器
 * æŠ¢å…ˆåŒ¹é…ï¼Œå¯¼è‡´é™æ€æ–‡ä»¶æ‰¾ä¸åˆ°è€Œ**直接 404**,控制器不再有机会处理:
 * <ul>
 *   <li>{@code /doc.html}、{@code /swagger-ui/**}、{@code /v3/api-docs/**}
 *       â€”— Knife4j / springdoc æŽ¥å£æ–‡æ¡£</li>
 *   <li>{@code /druid/**} â€”— Druid ç›‘控台(其 Servlet å·²å¯ç”¨ï¼‰</li>
 *   <li>{@code /actuator/**} â€”— Spring Boot ç›‘控端点</li>
 *   <li>{@code /jmreport/**} â€”— ç§¯æœ¨æŠ¥è¡¨ï¼ˆè‹¥å°†æ¥å¯ç”¨ï¼‰</li>
 *   <li>{@code /admin-api/**} â€”— å…¨éƒ¨ä¸šåŠ¡æŽ¥å£</li>
 *   <li>{@code /profile/**} â€”— å¿ƒç”µæ¨¡å—
 *       {@code WebConfig} æ˜ å°„到文件系统上传目录</li>
 * </ul>
 * å› æ­¤è¿™é‡Œæ”¹ä¸º**显式枚举前端真实存在的资源前缀**,把根路径留给后端自己。
 *
 * <h3>前端产物实际包含哪些顶层条目</h3>
 * ä¾æ® {@code dist-prod} çš„实际构建结果:
 * <pre>
 *   index.html, favicon.ico, logo.gif          â† æ–‡ä»¶
 *   bigCall.html, smallCall.html               â† kiosk é¡µï¼ˆå»ºè®®åˆ é™¤ï¼Œè§éƒ¨ç½²æŒ‡å—)
 *   assets/                                    â† å¸¦å†…容哈希的 js/css/字体/图片
 *   static/                                    â† jquery、axios、vue ç­‰ç¬¬ä¸‰æ–¹é™æ€åº“
 * </pre>
 *
 * <h3>SPA æ·±å±‚路由的兜底</h3>
 * æœ¬ç±»**不负责** SPA å…œåº•(前端用 {@code createWebHistory()},刷新深层路由
 * ä¼š 404)。兜底由 {@link SpaForwardController} æ˜¾å¼æžšä¸¾ 5 ç±»é¡µé¢è·¯å¾„完成,
 * è¿™æ ·æ—¢ä¿®å¤åˆ·æ–° 404,又不会把后端真实的 404 åžæˆ index.html。
 *
 * @author å¿ƒç”µæŽ’队装机系统
 */
@Configuration
public class FrontendResourceConfig implements WebMvcConfigurer {
    /** å‰ç«¯äº§ç‰©åœ¨ jar å†…的位置,需与 jh-server/pom.xml ä¸­ targetPath ä¿æŒä¸€è‡´ */
    private static final String FRONTEND_LOCATION = "classpath:/META-INF/resources/frontend/";
    /** å¸¦å†…容哈希的资源可长缓存(30 å¤©ï¼‰ */
    private static final long CACHE_HASHED = 3600L * 24 * 30;
    /** æ— å“ˆå¸Œçš„æ ¹çº§æ–‡ä»¶ï¼ˆlogo.gif ç­‰ï¼‰çŸ­ç¼“存,避免更新后浏览器不刷新(1 å°æ—¶ï¼‰ */
    private static final long CACHE_UNHASHED = 3600L;
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        // Vite äº§å‡ºçš„带哈希资源目录
        registry.addResourceHandler("/assets/**")
                .addResourceLocations(FRONTEND_LOCATION + "assets/")
                .setCachePeriod((int) CACHE_HASHED);
        // public/ ä¸‹åŽŸæ ·æ‹·è´çš„ç¬¬ä¸‰æ–¹é™æ€åº“ï¼ˆjquery、axios、vue、element-ui ç­‰ï¼‰
        // è¿™äº›æ–‡ä»¶åä¸å«å“ˆå¸Œï¼Œç”¨çŸ­ç¼“å­˜
        registry.addResourceHandler("/static/**")
                .addResourceLocations(FRONTEND_LOCATION + "static/")
                .setCachePeriod((int) CACHE_UNHASHED);
        // æ ¹çº§é™æ€æ–‡ä»¶ã€‚逐个列出,避免 /doc.html、/druid/** ç­‰è¢«è¯¯æ‹¦æˆª
        // æ–‡ä»¶åä¸å«å“ˆå¸Œï¼Œç”¨çŸ­ç¼“å­˜
        registry.addResourceHandler(
                        "/favicon.ico",
                        "/logo.gif",
                        "/logo.png",
                        "/logo0.png")
                .addResourceLocations(FRONTEND_LOCATION)
                .setCachePeriod((int) CACHE_UNHASHED);
        // æ³¨æ„ï¼šindex.html ä¸åœ¨æ­¤å¤„映射。
        // å®ƒåªåº”通过 SpaForwardController çš„ forward:/frontend/index.html è®¿é—®ï¼Œ
        // é¿å…å‡ºçް"两个入口都能拿到首页"的歧义。
    }
    /**
     * æ³¨å†Œå‰ç«¯å…ç™»å½•规则。
     * <p>
     * å¿…须显式声明为 Bean,框架才会通过
     * {@code JhWebSecurityConfigurerAdapter} æ”¶é›†å¹¶ç”Ÿæ•ˆã€‚
     * ç¼ºå°‘æ­¤ Bean çš„典型症状:接口都正常,但浏览器打开是白屏
     * ï¼ˆ{@code /logo.gif}、{@code /favicon.ico}、页面路径均返回
     * {@code 401 è´¦å·æœªç™»å½•})。
     *
     * @see FrontendAuthorizeRequestsCustomizer
     */
    @Bean
    public FrontendAuthorizeRequestsCustomizer frontendAuthorizeRequestsCustomizer() {
        return new FrontendAuthorizeRequestsCustomizer();
    }
}
jh-server/src/main/java/cn/lihu/jh/server/ecg/SpaForwardController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,143 @@
package cn.lihu.jh.server.ecg;
import io.swagger.v3.oas.annotations.Hidden;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import javax.annotation.security.PermitAll;
/**
 * å‰ç«¯ SPA è½¬å‘控制器(配合"前端打进 jar"的合并部署方案)
 *
 * <h3>作用</h3>
 * å‰ç«¯æ˜¯ Vue Router å•页应用。用户直接访问或按 F5 åˆ·æ–°ä¸€ä¸ªæ·±å±‚路由
 * ï¼ˆä¾‹å¦‚ {@code /ecg/queue})时,jar é‡Œå¹¶ä¸å­˜åœ¨å¯¹åº”的物理文件,需要把请求
 * è½¬å‘回 {@code index.html},由前端路由接管,否则会 404。
 * <p>
 * åŽŸå…ˆè¿™æ®µé€»è¾‘ç”± nginx çš„ {@code try_files $uri $uri/ /index.html} æ‰¿æ‹…ï¼›
 * å‰ç«¯å¹¶å…¥ jar ä¹‹åŽæ”¹ç”±åŽç«¯è´Ÿè´£ï¼Œnginx åªéœ€ä¿ç•™ {@code /admin-api/} åå‘代理。
 *
 * <h3>为什么显式枚举路径,而不是用通配兜底</h3>
 * åªæ˜ å°„前端实际存在的 5 ç±»é¡µé¢è·¯å¾„(见 {@code src/router/modules/remaining.ts}
 * ä¸ŽåŠ¨æ€èœå•è¡¨ {@code system_menu}):
 * <ul>
 *   <li>{@code /}            é¦–页</li>
 *   <li>{@code /ecg/**}      å¿ƒç”µä¸šåŠ¡ï¼ˆé¢„çº¦ã€æŽ’é˜Ÿã€åŒ»ç”Ÿå·¥ä½œå°ã€è¯Šå®¤ã€è®¾å¤‡ã€è®°å½•â€¦ï¼‰</li>
 *   <li>{@code /clinic/**}   è¯Šå®¤ç›¸å…³</li>
 *   <li>{@code /system/**}   ç³»ç»Ÿç®¡ç†ï¼ˆç”¨æˆ·ã€è§’色、菜单、字典、日志…)</li>
 *   <li>{@code /infra/**}    åŸºç¡€è®¾æ–½ï¼ˆæ–‡ä»¶ã€å®šæ—¶ä»»åŠ¡ã€ä»£ç ç”Ÿæˆã€ç›‘æŽ§â€¦ï¼‰</li>
 * </ul>
 * è¿™æ ·å¯é¿å…"把所有 404 éƒ½åžæˆ index.html",保留后端真实的 404 è¯­ä¹‰
 * ï¼ˆä¾‹å¦‚ {@code /admin-api/typo} ä»ä¼šæ­£ç¡®è¿”回 404)。
 *
 * <h3>安全性说明</h3>
 * {@code @PermitAll} æ˜¯å¿…须的:刷新页面时浏览器尚未携带 token,若要求认证
 * ä¼šç›´æŽ¥è¿”回 401,导致用户"一刷新就掉线"。此处放行的仅是**前端页面外壳**,
 * çœŸå®žæ•°æ®ä»ç”± {@code /admin-api/**} èµ° token é‰´æƒï¼ŒæœªæŽˆæƒæ‹¿ä¸åˆ°ä»»ä½•数据,
 * ä¸ŽåŽŸå…ˆ nginx å…¬å¼€é™æ€é¡µé¢çš„行为一致,未降低安全等级。
 *
 * <h3>与静态资源的优先级</h3>
 * é™æ€èµ„源由 {@link FrontendResourceConfig#addResourceHandlers} æ³¨å†Œï¼Œ
 * å…¶ä¼˜å…ˆçº§é«˜äºŽ Controller æ˜ å°„,因此 {@code /assets/*.js}、{@code /logo.gif}
 * ç­‰çœŸå®žå­˜åœ¨çš„æ–‡ä»¶ä¸ä¼šè¢«æœ¬æŽ§åˆ¶å™¨æ‹¦æˆªã€‚
 *
 * @author å¿ƒç”µæŽ’队装机系统
 */
@Hidden // ä¸åœ¨ Swagger æ–‡æ¡£ä¸­å±•示
@Controller
@PermitAll
public class SpaForwardController {
    /** å‰ç«¯é¡µé¢å¤–壳。位于 jar å†… META-INF/resources/frontend/ ä¸‹ */
    private static final String SPA_INDEX = "forward:/frontend/index.html";
    @GetMapping("/")
    public String index() {
        return SPA_INDEX;
    }
    /**
     * remaining.ts ä¸­çš„顶层单页路由:
     * /login、/roomselect、/sso、/social-login、/403、/404、/500、
     * /calling-screen-big、/calling-screen-room。
     * <p>
     * å¿…须与 {@link FrontendAuthorizeRequestsCustomizer#SPA_PAGE_PATTERNS}
     * ä¿æŒåŒæ­¥ï¼Œå¦åˆ™ä¼šå‡ºçް"控制器有映射、却被安全层拦成 401"的不一致
     * ï¼ˆæ›¾å®žæµ‹æ¼æŽ‰ /roomselect æ—¶å³ä¸ºè¯¥ç—‡çŠ¶ï¼‰ã€‚
     */
    @GetMapping({"/index.html", "/login", "/roomselect",
            "/sso", "/social-login", "/403", "/404", "/500",
            "/calling-screen-big", "/calling-screen-room"})
    public String simplePages() {
        return SPA_INDEX;
    }
    /** æ¡†æž¶è‡ªå¸¦çš„功能页(个人中心、字典、代码生成、定时任务、重定向中转) */
    @GetMapping({"/user/**", "/dict/**", "/codegen/**", "/job/**", "/redirect/**"})
    public String modulePages() {
        return SPA_INDEX;
    }
    /**
     * âš ï¸ ECG ä¸šåŠ¡é¡µé¢ï¼šå¿…é¡»ã€é€æ¡ç²¾ç¡®æžšä¸¾ã€‘ï¼Œç»ä¸èƒ½ç”¨ {@code /ecg/**} é€šé…ï¼
     *
     * <h4>为什么不能通配(这是一个已实测复现的真实故障)</h4>
     * ECG çš„后端接口与前端页面**共用 /ecg/ å‘½åç©ºé—´**:
     * <pre>
     *   æŽ¥å£ï¼ˆjavax @RestController)        å‰ç«¯é¡µé¢ï¼ˆèœå• path)
     *   /ecg/screen/big-screen-data         /ecg/queue
     *   /ecg/queue/page                     /ecg/room
     *   /ecg/call/next                      /ecg/check-type
     *   /ecg/doctor/finish-next-patient     /ecg/device
     *   /ecg/appointment/page               /ecg/appointment
     * </pre>
     * è‹¥ç”¨ {@code /ecg/**} è½¬å‘到 index.html,则大屏/诊间屏请求
     * {@code /ecg/screen/big-screen-data} ä¼šæ‹¿åˆ°**HTML è€Œä¸æ˜¯ JSON**,
     * å«å·å±ç›´æŽ¥å¤±æ•ˆï¼ˆHTTP ä»æ˜¯ 200,极具迷惑性)。
     *
     * <h4>精确路径为何安全</h4>
     * åœ¨ Spring ä¸­ {@code /ecg/queue} ä¸Ž {@code /ecg/queue/page} æ˜¯ä¸¤ä¸ªä¸åŒæ˜ å°„,
     * ç²¾ç¡®è·¯å¾„不会匹配带子路径的接口,因此二者天然区分。
     *
     * <h4>路径来源</h4>
     * å–自 {@code system_menu} è¡¨ä¸­ parent_id ä¸º ECG æ ¹(1254)或系统管理(1)
     * çš„页面 path,以及 {@code ecg_web/src/router} ä¸­çš„静态路由。
     */
    @GetMapping({
            // â€”— ECG æ ¹(1254) ä¸‹çš„业务页 â€”—
            "/ecg/appointment", "/ecg/appointment-confirm", "/ecg/alter",
            "/ecg/queue", "/ecg/roomselect",
            "/ecg/doc/routine", "/ecg/doc/dev-ready", "/ecg/doc/dev-install",
            "/ecg/dev-dismantle", "/ecg/dev-rent",
            "/ecg/doctor/dev-data-entry",
            "/ecg/job-statistic", "/ecg/dev-statistic",
            // â€”— æŒ‚系统管理(1) ä¸‹ä½†è·¯å¾„在 /ecg çš„页面 â€”—
            "/ecg/room", "/ecg/roomstatus", "/ecg/dev-model",
            "/ecg/device", "/ecg/check-type",
    })
    public String ecgPages() {
        return SPA_INDEX;
    }
    /**
     * è¯Šå®¤ç›¸å…³é¡µé¢ã€‚注意:接口 {@code /clinic/room/page} ç­‰å¸¦å­è·¯å¾„,
     * ä¸Žç²¾ç¡®è·¯å¾„ {@code /clinic/room} ä¸å†²çªã€‚
     */
    @GetMapping({"/clinic/room"})
    public String clinicPages() {
        return SPA_INDEX;
    }
    /**
     * ç³»ç»Ÿç®¡ç†ä¸ŽåŸºç¡€è®¾æ–½é¡µé¢ã€‚
     * <p>
     * è¿™ä¸¤ä¸ªå‰ç¼€**可以**用通配,因为 {@code /admin-api/**} æ‰æ˜¯æŽ¥å£å‘½åç©ºé—´ï¼Œ
     * {@code /system/**}、{@code /infra/**} ä»…用于页面,不存在同名接口。
     * ï¼ˆæ›¾æ ¸æŸ¥ï¼šsystem/infra æ¨¡å—所有 Controller å‡ä¸º {@code @RequestMapping("/system/...")} å½¢å¼ï¼Œ
     * ä½†å®žé™…请求路径都带 {@code /admin-api} å‰ç¼€ï¼Œæ•…不冲突。)
     */
    @GetMapping({"/system/**", "/infra/**"})
    public String adminPages() {
        return SPA_INDEX;
    }
}