yxh
昨天 fce96ef468291fb9a0e6a4d34ab371315e9485d4
ai优化
已删除60个文件
已修改49个文件
7169 ■■■■■ 文件已修改
jh-module-ecg/jh-module-ecg-api/src/main/java/cn/lihu/jh/module/ecg/enums/ErrorCodeConstants.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/appointment/vo/AppointmentPageReqVO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/appointment/vo/AppointmentRespVO.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/appointment/vo/AppointmentSaveReqVO.java 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/callingscreen/ScreenQueueRespVO.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/checktype/vo/CheckTypePageReqVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/checktype/vo/CheckTypeRespVO.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/checktype/vo/CheckTypeSaveReqVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/checktype/vo/SimpleCheckTypeRespVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/devrent/vo/DevRentRespVO.java 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/doctor/DoctorController.java 133 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/external/ExternalController.java 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/jobrecord/vo/JobRecordPageReqVO.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/queue/vo/QueueRespVO.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/queue/vo/QueueSaveReqVO.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/queuesequence/QueueSequenceController.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/queuesequence/vo/QueueSequenceRespVO.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/queuesequence/vo/QueueSequenceSaveReqVO.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/dataobject/appointment/AppointmentDO.java 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/dataobject/checktype/CheckTypeDO.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/dataobject/devrent/DevRentDO.java 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/dataobject/jobrecord/JobRecordDO.java 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/dataobject/queue/QueueDO.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/dataobject/queuesequence/QueueSequenceDO.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/dataobject/room/RoomDO.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/mysql/appointment/AppointmentMapper.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/mysql/checktype/CheckTypeMapper.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/mysql/devrent/DevRentMapper.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/mysql/jobrecord/JobRecordMapper.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/mysql/queue/QueueMapper.java 138 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/mysql/queuesequence/QueueSequenceMapper.java 130 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/mysql/room/RoomMapper.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/enums/ActionTypeEnum.java 117 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/enums/AppointmentTypeEnum.java 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/appointment/AppointmentServiceImpl.java 1188 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/callingscreen/CallingScreenServiceImpl.java 76 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/devrent/DevRentServiceImpl.java 161 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/jobrecord/JobRecordServiceImpl.java 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/queue/BedBO.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/queue/QueueService.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/queue/QueueServiceImpl.java 224 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/queue/QueueServiceTxFunctions.java 110 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/queuesequence/QueueSequenceService.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/queuesequence/QueueSequenceServiceImpl.java 292 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-api/src/main/java/cn/lihu/jh/module/system/api/mail/MailSendApi.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-api/src/main/java/cn/lihu/jh/module/system/api/mail/dto/MailSendSingleToUserReqDTO.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-api/src/main/java/cn/lihu/jh/module/system/api/notify/NotifyMessageSendApi.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-api/src/main/java/cn/lihu/jh/module/system/api/notify/dto/NotifySendSingleToUserReqDTO.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-api/src/main/java/cn/lihu/jh/module/system/api/notify/dto/NotifyTemplateReqDTO.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-api/src/main/java/cn/lihu/jh/module/system/enums/mail/MailSendStatusEnum.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-api/src/main/java/cn/lihu/jh/module/system/enums/notify/NotifyTemplateTypeEnum.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/api/mail/MailSendApiImpl.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/api/notify/NotifyMessageSendApiImpl.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/MailAccountController.java 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/MailLogController.java 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/MailTemplateController.http 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/MailTemplateController.java 89 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/vo/account/MailAccountPageReqVO.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/vo/account/MailAccountRespVO.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/vo/account/MailAccountSaveReqVO.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/vo/account/MailAccountSimpleRespVO.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/vo/log/MailLogPageReqVO.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/vo/log/MailLogRespVO.java 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/vo/template/MailTemplatePageReqVO.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/vo/template/MailTemplateRespVO.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/vo/template/MailTemplateSaveReqVO.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/vo/template/MailTemplateSendReqVO.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/vo/template/MailTemplateSimpleRespVO.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/notify/NotifyMessageController.java 98 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/notify/NotifyTemplateController.java 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/notify/vo/message/NotifyMessageMyPageReqVO.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/notify/vo/message/NotifyMessagePageReqVO.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/notify/vo/message/NotifyMessageRespVO.java 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/notify/vo/template/NotifyTemplatePageReqVO.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/notify/vo/template/NotifyTemplateRespVO.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/notify/vo/template/NotifyTemplateSaveReqVO.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/notify/vo/template/NotifyTemplateSendReqVO.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/dal/dataobject/mail/MailAccountDO.java 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/dal/dataobject/mail/MailLogDO.java 121 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/dal/dataobject/mail/MailTemplateDO.java 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/dal/dataobject/notify/NotifyMessageDO.java 101 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/dal/dataobject/notify/NotifyTemplateDO.java 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/dal/mysql/mail/MailAccountMapper.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/dal/mysql/mail/MailLogMapper.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/dal/mysql/mail/MailTemplateMapper.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/dal/mysql/notify/NotifyMessageMapper.java 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/dal/mysql/notify/NotifyTemplateMapper.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/dal/redis/RedisKeyConstants.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/mq/consumer/mail/MailSendConsumer.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/mq/message/mail/MailSendMessage.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/mq/producer/mail/MailProducer.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/service/mail/MailAccountService.java 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/service/mail/MailAccountServiceImpl.java 99 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/service/mail/MailLogService.java 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/service/mail/MailLogServiceImpl.java 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/service/mail/MailSendService.java 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/service/mail/MailSendServiceImpl.java 174 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/service/mail/MailTemplateService.java 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/service/mail/MailTemplateServiceImpl.java 138 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/service/notify/NotifyMessageService.java 97 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/service/notify/NotifyMessageServiceImpl.java 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/service/notify/NotifySendService.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/service/notify/NotifySendServiceImpl.java 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/service/notify/NotifyTemplateService.java 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/service/notify/NotifyTemplateServiceImpl.java 138 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-server/pom.xml 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-server/src/main/resources/application-local.yaml 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-server/src/main/resources/application.yaml 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/jh.sql 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh-module-ecg/jh-module-ecg-api/src/main/java/cn/lihu/jh/module/ecg/enums/ErrorCodeConstants.java
@@ -22,6 +22,16 @@
    ErrorCode APPOINTMENT_STATUS_UPDATE_FAIL = new ErrorCode(1_010_001_070, "申请单状态更新失败");
    ErrorCode APPOINTMENT_STATUS_REGISTER_UPDATE_FAIL = new ErrorCode(1_010_001_080, "申请单登记状态更新失败");
    // ========== HIS é€šç”¨æŽ¥å£ï¼ˆ/ecg/external/generalInterface)新增错误码 ==========
    // ç›®çš„:原实现把所有异常统一压成 APPOINTMENT_CREATE_FAIL,
    // å¯¼è‡´ HIS ä¾§æ— æ³•区分问题类型(本次排障成本即来自此)。以下按时段细分。
    ErrorCode HIS_ACTION_NOT_SUPPORTED = new ErrorCode(1_010_001_090, "不支持的服务代码(action),请核对接口文档交易编号");
    ErrorCode HIS_MESSAGE_PARSE_FAIL = new ErrorCode(1_010_001_100, "报文解析失败,请核对报文结构与必填字段");
    ErrorCode HIS_APPLY_NO_MISSING = new ErrorCode(1_010_001_110, "报文缺少申请单号");
    ErrorCode HIS_APPOINTMENT_NOT_FOUND = new ErrorCode(1_010_001_120, "未找到对应的预约记录");
    ErrorCode HIS_STATUS_INVALID = new ErrorCode(1_010_001_130, "检查状态编码不在编码表范围内");
    ErrorCode HIS_NOT_ECG_ORDER = new ErrorCode(1_010_001_140, "非心电业务单,已跳过处理");
    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, "诊室和诊疗床状态异常");
@@ -32,6 +42,14 @@
    ErrorCode QUEUE_HAVE_PATIENT = new ErrorCode(1_010_003_003, "队列中有患者");
    ErrorCode QUEUE_NOT_READY_PATIENT = new ErrorCode(1_010_003_004, "没有准备中的患者");
    ErrorCode QUEUE_RECALL_INSTALL_NOT_CUR_ROOM = new ErrorCode(1_010_003_005, "不能召回其他诊室的安装患者");
    /**
     * ã€Œåœæ­¢å€™è¯Šã€åŠŸèƒ½æœªå°±ç»ªã€‚
     * <p>
     * è§¦å‘条件:数据库尚未执行 `_prep/11_需求升级_汇总SQL.sql`(缺 `clinic_room.stop_ready` åˆ—)。
     * ç”¨ç‹¬ç«‹é”™è¯¯ç è€ŒéžæŠŠåŽŸå§‹ SQL å¼‚常抛给前端,便于现场快速定位部署遗漏。
     */
    ErrorCode QUEUE_STOP_READY_NOT_CONFIGURED = new ErrorCode(1_010_003_006,
            "停止候诊功能未配置,请先执行数据库升级脚本(clinic_room.stop_ready)");
    ErrorCode PATIENT_NOT_EXISTS = new ErrorCode(1_010_004_000, "患者不存在");
@@ -66,4 +84,14 @@
    ErrorCode QUEUE_SEQUENCE_TIMESLOT_EXCEPTION = new ErrorCode(1_010_010_002, "本时段序号异常");
    ErrorCode QUEUE_SEQUENCE_TIMESLOT_VIP_FULL = new ErrorCode(1_010_010_003, "本时段VIP序号已满");
    ErrorCode QUEUE_SEQUENCE_TIMESLOT_VIP_EXCEPTION = new ErrorCode(1_010_010_003, "本时段VIP序号异常");
    /**
     * é¢„约时段重复:同一检查类型下同一个时段只能有一条规则。
     * <p>
     * è¡¨ä¸Š**没有** (check_type, time_slot) å”¯ä¸€çº¦æŸï¼ˆåŽ†å²é—ç•™ï¼‰ï¼Œè€Œå–å· SQL
     * {@code select queue_no ... where check_type=? and time_slot=? ... for update}
     * æœŸæœ›åªå‘½ä¸­ä¸€è¡Œï¼›è‹¥å­˜åœ¨é‡å¤è¡Œï¼Œå–号行为不可预期。故在维护入口处拦截。
     */
    ErrorCode QUEUE_SEQUENCE_TIMESLOT_DUPLICATE = new ErrorCode(1_010_010_004,
            "该检查类型的此时段规则已存在,请勿重复添加");
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/appointment/vo/AppointmentPageReqVO.java
@@ -4,7 +4,9 @@
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.*;
import java.sql.Date;
// æ³¨æ„ï¼šåŽŸä¸º java.sql.Date(只表示日期),会让 registerDate/doctorTime ä¸¢å¤±æ—¶åˆ†ç§’。
// 2026-09-30 ä¸Ž AppointmentSaveReqVO/AppointmentDO ç»Ÿä¸€ä¸º java.util.Date。
import java.util.Date;
import java.time.LocalDate;
import java.util.*;
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/appointment/vo/AppointmentRespVO.java
@@ -3,7 +3,9 @@
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.*;
import java.sql.Date;
// æ³¨æ„ï¼šåŽŸä¸º java.sql.Date(只表示日期),会让 registerDate/doctorTime ä¸¢å¤±æ—¶åˆ†ç§’。
// 2026-09-30 ä¸Ž AppointmentSaveReqVO/AppointmentDO ç»Ÿä¸€ä¸º java.util.Date。
import java.util.Date;
import java.time.LocalDate;
import java.util.*;
@@ -136,6 +138,10 @@
     */
    private String tolerance;
    @Schema(description = "是否床边 0=否 1=是", example = "0")
    @ExcelProperty("是否床边")
    private String isBedside;
    /**
     * ä½é™¢å·
     */
@@ -169,4 +175,39 @@
    @ExcelProperty("开单时间")
    private Date doctorTime;
    // ==================== HIS æŽ¥å…¥è¡¥å……字段(2026-09-27 éšè¡¨æ–°å¢žåˆ—一起加入) ====================
    // æ³¨ï¼šbookSeqNum å·²åœ¨ä¸Šæ–¹å£°æ˜Žï¼Œæ­¤å¤„不重复定义
    /**
     * ä¼˜å…ˆçº§ / åŠ æ€¥æ ‡è®°ï¼šN=常规,J=加急
     */
    @ExcelProperty("加急标记")
    private String jjbz;
    /**
     * æ£€æŸ¥æ–¹å¼ç¼–码
     */
    private String methodCode;
    /**
     * æ£€æŸ¥éƒ¨ä½ç¼–码
     */
    private String targetSiteCode;
    /**
     * åŒ»å˜±å·ï¼ˆHIS æŠ¥æ–‡ component2.observationRequest.id.item[root=2.16.156.10011.1.28].extension)。
     * ä¸Žã€Œç”³è¯·å•号 applyNo」不同:前者是医嘱标识,后者是申请单标识。
     */
    private String doctorAdviceNo;
    /**
     * æ£€æŸ¥æˆ¿é—´åç§°
     */
    private String roomName;
    /**
     * æ³¨æ„äº‹é¡¹
     */
    private String remark;
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/appointment/vo/AppointmentSaveReqVO.java
@@ -7,10 +7,27 @@
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.sql.Date;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Date;
/**
 * ç®¡ç†åŽå° - é¢„约新增/修改 Request VO
 *
 * <h3>⚠️ å…³äºŽ {@code registerDate} / {@code doctorTime} çš„类型(2026-09-30 ä¿®æ­£ï¼‰</h3>
 * è¿™ä¸¤ä¸ªå­—段原先声明为 {@code java.sql.Date}(经 {@code import java.sql.Date;}
 * é€šé…åˆ°æœ¬ç±»çš„ {@code Date}),而对应的 {@code AppointmentDO} æ˜¯ {@code java.util.Date}。
 *
 * <p>{@code java.sql.Date} æ˜¯**只表示日期**的 JDBC ç±»åž‹ã€‚实测(写入 datetime åˆ—):
 * <pre>
 *   setObject(java.util.Date)  â†’ 2026-09-26 16:58:53   ä¿ç•™æ—¶åˆ†ç§’
 *   setObject(java.sql.Date)   â†’ 2026-09-26 00:00:00   æ—¶é—´è¢«ä¸¢å¼ƒ
 * </pre>
 * è¿™æ­£æ˜¯ {@code appointment.register_date} å‡ºçް {@code yyyy-MM-dd 00:00:00}
 * çš„æˆå› ç±»åž‹ â€”— å‡¡æ˜¯ä»Žæœ¬ VO å–值落库的路径都会把时间截断。
 *
 * <p>现统一为 {@code java.util.Date},与 DO åŠå„写入路径({@code new Date()})保持一致。
 */
@Schema(description = "管理后台 - é¢„约新增/修改 Request VO")
@Data
public class AppointmentSaveReqVO {
@@ -132,4 +149,42 @@
     * å¼€å•æ—¶é—´
     */
    private Date doctorTime;
    // ==================== HIS æŽ¥å…¥è¡¥å……字段(2026-09-27 éšè¡¨æ–°å¢žåˆ—一起加入) ====================
    /**
     * é¢„约排序号(HIS æŠ¥æ–‡ patient.id.item[4])
     */
    private Integer bookSeqNum;
    /**
     * ä¼˜å…ˆçº§ / åŠ æ€¥æ ‡è®°ï¼šN=常规,J=加急(HIS æŠ¥æ–‡ zdy.jjbz)
     */
    private String jjbz;
    /**
     * æ£€æŸ¥æ–¹å¼ç¼–码(HIS æŠ¥æ–‡ methodCode.item[].code)
     */
    private String methodCode;
    /**
     * æ£€æŸ¥éƒ¨ä½ç¼–码(HIS æŠ¥æ–‡ targetSiteCode.item.code)
     */
    private String targetSiteCode;
    /**
     * åŒ»å˜±å·ï¼ˆHIS æŠ¥æ–‡ observationRequest.id.item[root=2.16.156.10011.1.28].extension)。
     * ä¸Žã€Œç”³è¯·å•号 applyNo」不同。
     */
    private String doctorAdviceNo;
    /**
     * æ£€æŸ¥æˆ¿é—´åç§°ï¼ˆHIS æŠ¥æ–‡ ZDY.roomname)
     */
    private String roomName;
    /**
     * æ³¨æ„äº‹é¡¹ï¼ˆHIS æŠ¥æ–‡ ZDY.remark)
     */
    private String remark;
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/callingscreen/ScreenQueueRespVO.java
@@ -68,4 +68,59 @@
    @Schema(description = "诊疗床编号")
    @ExcelProperty("诊疗床编号")
    private String bedNo;
    // ==================== éœ€æ±‚1:患者信息 18 é¡¹ ====================
    // å‡æ¥è‡ªé¢„约单 appointment(queue è¡¨æ— æ­¤åˆ—),由 CallingScreenServiceImpl
    // è°ƒç”¨ QueueService#fillAppointmentInfo å›žå¡«åŽå†ç» BeanUtils.toBean æ˜ å°„到这里。
    // è¡¥é½åŽå¤§å±/诊间屏才能展示完整患者信息。
    @Schema(description = "就诊流水号")
    @ExcelProperty("就诊流水号")
    private String episodeId;
    @Schema(description = "申请编号")
    @ExcelProperty("申请编号")
    private String applyNo;
    @Schema(description = "出生日期")
    @ExcelProperty("出生日期")
    private java.time.LocalDate patBirthday;
    @Schema(description = "患者电话(手机优先,回退固话)")
    @ExcelProperty("患者电话")
    private String patMobile;
    @Schema(description = "病区名称")
    @ExcelProperty("病区名称")
    private String patWardDesc;
    @Schema(description = "病床号(注意不是本类的 bedNo,后者是诊疗床/工位号)")
    @ExcelProperty("病床号")
    private String patBedNo;
    @Schema(description = "患者来源:1门诊 2急诊 3住院 4体检")
    @ExcelProperty("患者来源")
    private Integer patSrc;
    @Schema(description = "开单医师")
    @ExcelProperty("开单医师")
    private String doctor;
    @Schema(description = "开单日期")
    @ExcelProperty("开单日期")
    private java.util.Date doctorTime;
    @Schema(description = "登记时间")
    @ExcelProperty("登记时间")
    private java.util.Date registerDate;
    @Schema(description = "多耐药:0无 1多耐 2多重耐药")
    @ExcelProperty("多耐药")
    private String tolerance;
    private String isBedside;
    @Schema(description = "医嘱号(HIS observationRequest.id.item[root=2.16.156.10011.1.28];与申请号 applyNo ä¸åŒï¼‰")
    @ExcelProperty("医嘱号")
    private String doctorAdviceNo;
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/checktype/vo/CheckTypePageReqVO.java
@@ -54,4 +54,7 @@
    @Schema(description = "预约序号显示前缀")
    private String seqPrefix;
    @Schema(description = "排序序号")
    private Integer sort;
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/checktype/vo/CheckTypeRespVO.java
@@ -70,4 +70,12 @@
    @Schema(description = "预约序号显示前缀")
    @ExcelProperty("预约序号显示前缀")
    private String seqPrefix;
    @Schema(description = "排队序号规则:0=预约号, 1=签到序号, 2=时段号(默认)", example = "2")
    @ExcelProperty("排队序号规则")
    private Integer seqRule;
    @Schema(description = "排序序号:越小越靠前(检查项目显示顺序)", example = "100")
    @ExcelProperty("排序序号")
    private Integer sort;
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/checktype/vo/CheckTypeSaveReqVO.java
@@ -47,4 +47,10 @@
    @Schema(description = "预约序号显示前缀")
    private String seqPrefix;
    @Schema(description = "排队序号规则:0=预约号, 1=签到序号, 2=时段号(默认)", example = "2")
    private Integer seqRule;
    @Schema(description = "排序序号:越小越靠前(检查项目显示顺序)", example = "100")
    private Integer sort;
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/checktype/vo/SimpleCheckTypeRespVO.java
@@ -28,4 +28,7 @@
    @Schema(description = "是否需要确费")
    private Integer expenseRecognition;
    @Schema(description = "是否需要设备领用:0-不需要,1-需要", example = "1")
    private Integer needDevReady;
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/devrent/vo/DevRentRespVO.java
@@ -5,6 +5,7 @@
import lombok.*;
import java.util.*;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDate;
import java.time.LocalDateTime;
import com.alibaba.excel.annotation.*;
@@ -97,4 +98,59 @@
    @Schema(description = "设备状态")
    private Integer devState;
    // ==================== éœ€æ±‚1:患者信息 18 é¡¹ ====================
    // å‡æ¥è‡ªé¢„约单 appointment(dev_rent è¡¨æ— æ­¤åˆ—),由 DevRentServiceImpl æŒ‰ applyNo å›žå¡«ã€‚
    // è¡¥é½è¿™äº›å­—段后,领用/装机/拆机/数据录入/检查记录 5 ä¸ªç•Œé¢æ‰èƒ½å±•示完整患者信息。
    @Schema(description = "患者性别")
    private Byte patGender;
    @Schema(description = "出生日期")
    private LocalDate patBirthday;
    @Schema(description = "登记时间")
    private Date registerDate;
    @Schema(description = "开单医师")
    private String doctor;
    @Schema(description = "开单日期(开单时间)")
    private Date doctorTime;
    @Schema(description = "患者来源:1门诊 2急诊 3住院 4体检")
    private Integer patSrc;
    @Schema(description = "手机号")
    private String patMobile;
    @Schema(description = "电话")
    private String patPhone;
    @Schema(description = "病区名称")
    private String patWardDesc;
    @Schema(description = "病床号")
    private String patBedNo;
    @Schema(description = "就诊卡号(medical_card,库中多为空)")
    private String medicalCard;
    @Schema(description = "门诊号(实测部分数据存的是身份证号,非真正卡号)")
    private String outpatientNo;
    @Schema(description = "身份证号")
    private String patIdentityId;
    /**
     * åŒ»å˜±å·ï¼ˆHIS æŠ¥æ–‡ observationRequest.id.item[root=2.16.156.10011.1.28].extension)。
     * ä¸Žã€Œç”³è¯·å•号 applyNo」不同:前者是医嘱标识,后者是申请单标识。该节点在规范中非必须项,库中可为空。
     */
    private String doctorAdviceNo;
    @Schema(description = "多耐药:0无 1多耐 2多重耐药")
    private String tolerance;
    @Schema(description = "是否床边 0=否 1=是", example = "0")
    private String isBedside;
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/doctor/DoctorController.java
@@ -10,6 +10,7 @@
import cn.lihu.jh.module.ecg.controller.admin.queue.vo.QueueSaveReqVO;
import cn.lihu.jh.module.ecg.controller.admin.room.vo.RoomRespVO;
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.service.call.CallService;
import cn.lihu.jh.module.ecg.service.queue.QueueService;
@@ -112,6 +113,45 @@
            return success(SUCCESS.getCode());
        return error(result);
    }
    /**
     * é‡Šæ”¾**当前登录用户**占用的全部诊疗床位(退出系统时自动离座)。
     *
     * <p>背景:医生在「诊室选择」页选好床位后即占用该床位({@code clinic_room.doc_id}),
     * è‹¥ä¸€ç›´ä¸ç‚¹ã€Œç¦»åº§ã€å°±é€€å‡ºç³»ç»Ÿï¼ŒåºŠä½ä¼šä¸€ç›´æ˜¾ç¤ºè¢«å ç”¨ï¼Œå…¶ä»–医生无法使用。
     *
     * <p>与 {@link #bedDoctorOff} çš„区别:
     * <ul>
     *   <li>{@code /bed-doctor-off} éœ€è¦å‰ç«¯ä¼  roomId + bedNo(离开某个**指定**床位);</li>
     *   <li>本接口**不需要任何参数**,按当前登录用户反查其占用的**所有**床位并逐一释放。</li>
     * </ul>
     * ä¹‹æ‰€ä»¥åšæˆ"无参":退出登录 / ç©ºé—²è¶…æ—¶ / ä»¤ç‰Œå¤±æ•ˆç­‰åœºæ™¯ä¸‹ï¼Œå‰ç«¯å¯èƒ½å·²ç»æ‹¿ä¸åˆ°
     * åºŠä½çš„ roomId/bedNo(本地缓存已被清理),此时只能由服务端按 userId åæŸ¥ã€‚
     * æœåŠ¡ç«¯åæŸ¥ä¹Ÿæ›´å¯é  â€”— å‰ç«¯ç¼“存的床位若被管理员改动过,就会出现"释放不掉"。
     *
     * <p>幂等:未占用任何床位时直接返回成功。
     */
    @PostMapping("/release-my-beds")
    @Operation(summary = "释放当前登录用户占用的全部床位(退出系统时自动离座)")
    public CommonResult<Boolean> releaseMyBeds() {
        Long userId = SecurityFrameworkUtils.getLoginUserId();
        if (userId == null) {
            // æœªç™»å½•(或令牌已失效)时无床位可释放,按成功返回,避免前端报错
            return success(true);
        }
        String userNickname = SecurityFrameworkUtils.getLoginUserNickname();
        List<RoomDO> occupiedBeds = queueService.getDocRoomInfo(userId);
        if (occupiedBeds == null || occupiedBeds.isEmpty()) {
            return success(true);   // æœªå ç”¨åºŠä½ï¼Œæ— éœ€å¤„理
        }
        log.info("[releaseMyBeds] é€€å‡ºç³»ç»Ÿè‡ªåŠ¨ç¦»åº§. userId={} nickname={} å ç”¨åºŠä½æ•°={}",
                userId, userNickname, occupiedBeds.size());
        for (RoomDO bed : occupiedBeds) {
            // é€ä¸ªé‡Šæ”¾ï¼›å•个失败不影响其它床位
            queueService.startBedDoctorOff(bed.getRoomId(), bed.getBedNo(), userId, userNickname);
        }
        return success(true);
    }
    @GetMapping("/bed-doctor-get")
@@ -460,4 +500,97 @@
        return success("操作成功");
    }
    // ==================== éœ€æ±‚2:撤销「叫号 / æ£€æŸ¥å®Œæˆã€ ====================
    @GetMapping("/undo-finish-patient")
    @Operation(summary = "撤销检查完成:已就诊(40) â†’ å€™è¯Šä¸­(20)")
    @Parameter(name = "roomId", description = "诊室编号", required = true, example = "116")
    @Parameter(name = "bedNo", description = "工位编号", required = true, example = "A1")
    @Parameter(name = "patId", description = "患者编号", required = true, example = "913639423")
    @Parameter(name = "checkType", description = "检查类型", required = true, example = "100")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<String> undoFinishPatient(
            @RequestParam("roomId") Long roomId,
            @RequestParam("bedNo") String bedNo,
            @RequestParam("patId") String patId,
            @RequestParam("checkType") Integer checkType) {
        // è¯´æ˜Žï¼šå¿…须按 patId + checkType ç²¾ç¡®å®šä½ï¼Œé¿å…è¯¯æ’¤é”€å…¶ä»–患者(既有缺陷见 DevRentServiceImpl)
        Integer result = queueService.undoFinishPatient(roomId, bedNo, patId, checkType);
        if (null == result || 0 == result) {
            return error(APPOINTMENT_NOT_EXISTS);
        }
        return success("已撤销检查完成,患者已返回候诊区");
    }
    @GetMapping("/undo-call-patient")
    @Operation(summary = "撤销叫号:就诊中(30) â†’ å€™è¯Šä¸­(20)")
    @Parameter(name = "roomId", description = "诊室编号", required = true, example = "116")
    @Parameter(name = "bedNo", description = "工位编号", required = true, example = "A1")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<String> undoCallPatient(
            @RequestParam("roomId") Long roomId,
            @RequestParam("bedNo") String bedNo) {
        Integer result = queueService.undoCallPatient(roomId, bedNo);
        if (null == result || 0 == result) {
            return error(QUEUE_NOT_EXISTS);
        }
        return success("已撤销叫号,患者已返回候诊区");
    }
    @GetMapping("/last-finished-patient")
    @Operation(summary = "取本工位今日最近一位已完成患者(供撤销提示)")
    @Parameter(name = "roomId", description = "诊室编号", required = true, example = "116")
    @Parameter(name = "bedNo", description = "工位编号", required = true, example = "A1")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<QueueRespVO> lastFinishedPatient(
            @RequestParam("roomId") Long roomId,
            @RequestParam("bedNo") String bedNo) {
        QueueDO queueDO = queueService.getBedLastFinishedQueueItem(roomId, bedNo);
        return success(BeanUtils.toBean(queueDO, QueueRespVO.class));
    }
    // ==================== éœ€æ±‚3:停止候诊开关 ====================
    @GetMapping("/stop-ready")
    @Operation(summary = "停止候诊:不再新增候诊患者(已有候诊仍可叫号)")
    @Parameter(name = "roomId", description = "诊室编号", required = true, example = "116")
    @Parameter(name = "bedNo", description = "工位编号", required = true, example = "A1")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<Boolean> stopReady(
            @RequestParam("roomId") Long roomId,
            @RequestParam("bedNo") String bedNo) {
        ErrorCode ec = queueService.setStopReady(roomId, bedNo, true);
        if (!SUCCESS.getCode().equals(ec.getCode())) {
            return error(ec);
        }
        return success(true);
    }
    @GetMapping("/resume-ready")
    @Operation(summary = "恢复候诊:重新开始接收候诊患者")
    @Parameter(name = "roomId", description = "诊室编号", required = true, example = "116")
    @Parameter(name = "bedNo", description = "工位编号", required = true, example = "A1")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<Boolean> resumeReady(
            @RequestParam("roomId") Long roomId,
            @RequestParam("bedNo") String bedNo) {
        ErrorCode ec = queueService.setStopReady(roomId, bedNo, false);
        if (!SUCCESS.getCode().equals(ec.getCode())) {
            return error(ec);
        }
        return success(true);
    }
    @GetMapping("/get-stop-ready")
    @Operation(summary = "查询停止候诊状态")
    @Parameter(name = "roomId", description = "诊室编号", required = true, example = "116")
    @Parameter(name = "bedNo", description = "工位编号", required = true, example = "A1")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    public CommonResult<Boolean> getStopReady(
            @RequestParam("roomId") Long roomId,
            @RequestParam("bedNo") String bedNo) {
        Boolean stop = queueService.getStopReady(roomId, bedNo);
        return success(stop != null && stop);
    }
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/external/ExternalController.java
@@ -14,12 +14,38 @@
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.util.Map;
import java.util.function.Consumer;
import static cn.lihu.jh.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.lihu.jh.framework.common.pojo.CommonResult.success;
import static cn.lihu.jh.module.ecg.enums.ErrorCodeConstants.APPOINTMENT_CREATE_FAIL;
import static cn.lihu.jh.module.ecg.enums.ErrorCodeConstants.*;
/**
 * ä¾›ç¬¬ä¸‰æ–¹ï¼ˆHIS / é›†æˆå¹³å°ï¼‰è°ƒç”¨çš„通用接口。
 *
 * <h3>路由方式</h3>
 * é€šè¿‡è¯·æ±‚头 {@code action} åŒºåˆ†æœåŠ¡ï¼Œå–å€¼è§ {@link ActionTypeEnum}。
 *
 * <h3>本次改动要点</h3>
 * <ol>
 *   <li><b>补注册 ECG ä¸“属 action</b>:接口文档 6.4/6.5 å®šä¹‰
 *       {@code S050402}(ECG æ£€æŸ¥é¢„约状态新增)、{@code S050502}(ECG æ£€æŸ¥é¢„约状态更新),
 *       ä¸Ž pacs çš„ {@code S050401}/{@code S050501} å¹¶åˆ—。实测日志中这两个编号
 *       å°šæœªå‡ºçŽ°ï¼ˆ0 æ¬¡ï¼‰ï¼Œæ­¤å¤„**主动兼容**,避免院方切换编号时消息被拒。</li>
 *   <li><b>兼容无后缀写法</b>:文档定义 {@code S0201}/{@code S0202},
 *       è€Œå®žæµ‹æŠ¥æ–‡ä¸º {@code S0201ECG}/{@code S0202ECG},两者均接收。</li>
 *   <li><b>细化错误码</b>:原实现把所有异常统一压成
 *       {@code APPOINTMENT_CREATE_FAIL}("申请单创建失败"),
 *       å¯¼è‡´æŽ’障困难(本次问题排查成本即来自此)。现按
 *       ã€ŒæœªçŸ¥ action / æŠ¥æ–‡è§£æžå¤±è´¥ / è®°å½•不存在 / å…¶ä»–」区分返回。</li>
 * </ol>
 *
 * <h3>关于 S0405</h3>
 * {@code S0405}(申请单状态更新)是**全院通用**服务,医院会把所有检查科室
 * ï¼ˆCT/MRI/DR/超声…)的状态一并推送。业务层已按心电范围过滤,
 * éžå¿ƒç”µå•只记日志、不处理(详见 {@code HisEcgFilter})。
 *
 * @author å¿ƒç”µæŽ’队装机系统
 */
@Tag(name = "供第三方调用接口")
@RestController
@RequestMapping("/ecg/external")
@@ -27,18 +53,20 @@
@Slf4j
public class ExternalController {
    /** è¯·æ±‚头中的 action é”®å */
    private static final String HEADER_ACTION = "action";
    @Resource
    private AppointmentService appointmentService;
    /**
     * é€šç”¨æŽ¥å£
     * <p>
     * æ”¯æŒä»¥ä¸‹action类型:
     * - S0201ECG: é¢„约创建
     * - S0202ECG: é¢„约更新
     * é€šç”¨æŽ¥å£ã€‚
     *
     * @param dataMap è¯·æ±‚数据
     * @param headers è¯·æ±‚头,必须包含action字段
     * <p>支持的 action è§ {@link ActionTypeEnum},包括:
     * ç”³è¯·å•新增/更新、申请单状态更新、检查预约状态新增/更新。
     *
     * @param dataMap è¯·æ±‚数据(HL7 V3 è½¬æ¢åŽçš„ Map)
     * @param headers è¯·æ±‚头,必须包含 {@code action}
     * @return å¤„理结果
     */
    @PermitAll
@@ -47,35 +75,54 @@
    public CommonResult<Boolean> generalInterface(
            @RequestBody @NotNull(message = "请求数据不能为空") Map<String, Object> dataMap,
            @RequestHeader @NotEmpty(message = "请求头不能为空") Map<String, String> headers) {
        log.info("[generalInterface][开始处理请求 action({}) dataMap({})]", headers.get("action"), dataMap);
        String actionType = headers.get("action");
        ActionTypeEnum action = ActionTypeEnum.getByType(actionType);
        String rawAction = headers.get(HEADER_ACTION);
        ActionTypeEnum action = ActionTypeEnum.getByType(rawAction);
        if (action == null) {
            log.warn("[generalInterface][未知的action类型 action({})]", actionType);
            throw exception(APPOINTMENT_CREATE_FAIL);
            // ä¸å†åžæˆ"申请单创建失败",明确告知 action ä¸å—支持
            log.warn("[generalInterface][不支持的 action({}),请核对接口文档 6.x äº¤æ˜“编号]", rawAction);
            throw exception(HIS_ACTION_NOT_SUPPORTED);
        }
        log.info("[generalInterface][开始处理请求 action({}) dataMap({})]", rawAction, dataMap);
        try {
            // ä½¿ç”¨ç­–略模式处理不同的action
            Map<ActionTypeEnum, Consumer<Map<String, Object>>> actionHandlers = Map.of(
                    ActionTypeEnum.S0201ECG, appointmentService::handleAppointmentCreate,
                    ActionTypeEnum.S0202ECG, appointmentService::handleAppointmentUpdate,
                    ActionTypeEnum.S040501HIS, appointmentService::handleAppointmentStateUpdate,
                    ActionTypeEnum.S050401, appointmentService::handleCheckAppointmentUpdate,
                    ActionTypeEnum.S0405, appointmentService::handleAppointmentStatusUpdate,
                    ActionTypeEnum.S050501, appointmentService::handleCheckAppointmentUpdate
            );
            Consumer<Map<String, Object>> handler = actionHandlers.get(action);
            if (handler == null) {
                throw exception(APPOINTMENT_CREATE_FAIL);
            }
            handler.accept(dataMap);
            dispatch(action, dataMap);
            return success(true);
        } catch (cn.lihu.jh.framework.common.exception.ServiceException e) {
            // ä¸šåŠ¡å¼‚å¸¸ï¼šä¿ç•™åŽŸå§‹é”™è¯¯ç ä¸Žä¿¡æ¯ï¼Œä¾¿äºŽ HIS ä¾§å®šä½
            log.error("[generalInterface][业务处理失败 action({}) code({}) msg({})]",
                    rawAction, e.getCode(), e.getMessage());
            throw e;
        } catch (Exception e) {
            throw exception(APPOINTMENT_CREATE_FAIL);
            // æœªé¢„期异常:报文解析失败、字段缺失导致的 NPE/类型转换异常等
            log.error("[generalInterface][报文处理异常 action({}) dataMap({})]", rawAction, dataMap, e);
            throw exception(HIS_MESSAGE_PARSE_FAIL);
        }
    }
    /**
     * æŒ‰ action åˆ†æ´¾åˆ°å¯¹åº”处理器。
     * <p>
     * ä½¿ç”¨ {@link ActionTypeEnum} çš„分组方法而非 Map æ˜ å°„,是为了让
     * ã€ŒåŒä¸€æœåŠ¡çš„å¤šç§ç¼–å·ã€ï¼ˆpacs / ECG、有后缀 / æ— åŽç¼€ï¼‰èµ°åŒä¸€å¤„理器,
     * é¿å…æ–°å¢žç¼–号时漏配。
     */
    private void dispatch(ActionTypeEnum action, Map<String, Object> dataMap) {
        if (action.isApplyCreate()) {
            appointmentService.handleAppointmentCreate(dataMap);
        } else if (action.isApplyUpdate()) {
            appointmentService.handleAppointmentUpdate(dataMap);
        } else if (action.isApplyStatusUpdate()) {
            appointmentService.handleAppointmentStateUpdate(dataMap);
        } else if (action.isAppointmentStatusCreate()
                || action.isAppointmentStatusUpdate()) {
            // æ£€æŸ¥é¢„约状态:新增(S050401/S050402) ä¸Ž æ›´æ–°(S050501/S050502)
            // æŠ¥æ–‡ç»“构相同,走同一处理器
            appointmentService.handleCheckAppointmentUpdate(dataMap);
        } else {
            // æžšä¸¾æ–°å¢žä½†æœªæŽ¥å…¥åˆ†æ´¾é€»è¾‘时的兜底,避免静默忽略
            throw exception(HIS_ACTION_NOT_SUPPORTED);
        }
    }
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/jobrecord/vo/JobRecordPageReqVO.java
@@ -51,4 +51,21 @@
    @Schema(description = "对应租赁编号", example = "19472")
    private Long rentId;
    /**
     * æ£€æŸ¥é¡¹ç›®è¿‡æ»¤ï¼ˆ**按诊室/工位隔离已完成记录**)。
     *
     * <p>背景:`job_record` è¡¨**只有 doc_id + check_type,没有 room_id**,
     * è€Œ `/ecg/job-record/doctor-page` å¼ºåˆ¶æŒ‰ç™»å½•医生查自己的记录。
     * è‹¥åŒä¸€åŒ»ç”Ÿåœ¨å¤šä¸ªè¯Šå®¤åè¯Šï¼ˆä¾‹å¦‚ 1号门诊 check_types=[100,1100]、
     * 6号门诊 check_types=[400] å¹³æ¿è¿åŠ¨å¿ƒç”µï¼‰ï¼Œå·²å®Œæˆè®°å½•åˆ—è¡¨ä¼šæŠŠ**多个诊室的记录混在一起**,
     * è¡¨çŽ°ä¸º"1号门诊的叫号检查页出现了平板运动心电的记录"。
     *
     * <p>解决:前端把**当前诊室的 checkTypes** ä¼ ä¸Šæ¥ï¼Œè¿™é‡ŒæŒ‰ `check_type in (...)` è¿‡æ»¤ï¼Œ
     * ä½¿åˆ—表只显示"本诊室开展的检查项目"下、且由自己完成的记录。
     *
     * <p>为空时不加此过滤,保持既有行为(向后兼容)。
     */
    @Schema(description = "检查项目(按诊室隔离记录用)", example = "[100,1100]")
    private Integer[] checkTypes;
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/queue/vo/QueueRespVO.java
@@ -104,6 +104,9 @@
    @TableField(exist = false)
    private String tolerance;
    @Schema(description = "是否床边 0=否 1=是", example = "0")
    private String isBedside;
    /**
     * ä»¥ä¸‹å­—段来自预约单(appointment),由 QueueServiceImpl#fillAppointmentInfo å¡«å……
     */
@@ -119,4 +122,37 @@
    @Schema(description = "开单日期(开单时间)")
    private Date doctorTime;
    // ==================== éœ€æ±‚1:患者信息 18 é¡¹æ‰€éœ€çš„平铺字段 ====================
    // å‡æ¥è‡ªé¢„约单 appointment(queue è¡¨æ— æ­¤åˆ—),由 QueueServiceImpl#fillAppointmentInfo å›žå¡«ã€‚
    @Schema(description = "患者来源:1门诊 2急诊 3住院 4体检")
    private Integer patSrc;
    @Schema(description = "患者手机号")
    private String patMobile;
    @Schema(description = "患者电话")
    private String patPhone;
    @Schema(description = "病区名称")
    private String patWardDesc;
    @Schema(description = "病床号(注意:不是本类的 bedNo,后者是诊疗床/工位号)")
    private String patBedNo;
    @Schema(description = "就诊卡号(medical_card,库中多为空)")
    private String medicalCard;
    @Schema(description = "门诊号(实测部分数据存的是身份证号,非真正卡号)")
    private String outpatientNo;
    @Schema(description = "身份证号")
    private String patIdentityId;
    /**
     * åŒ»å˜±å·ï¼ˆHIS æŠ¥æ–‡ observationRequest.id.item[root=2.16.156.10011.1.28].extension)。
     * ä¸Žã€Œç”³è¯·å•号 applyNo」不同:前者是医嘱标识,后者是申请单标识。该节点在规范中非必须项,库中可为空。
     */
    private String doctorAdviceNo;
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/queue/vo/QueueSaveReqVO.java
@@ -89,5 +89,27 @@
    @Schema(description = "诊疗床编号")
    private String bedNo;
    /**
     * åŠ æ€¥/插队标记:1=加急(排队时优先),其余/空=常规。
     * <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 å³å®žçް"加急优先"。
     */
    @Schema(description = "加急标记 1=加急")
    private Byte jumpFlag;
    /**
     * æ˜¯å¦åºŠè¾¹ï¼ˆéœ€æ±‚2)。来源 HIS æŠ¥æ–‡ {@code observationRequest.zdy.ISCB}(0=否,1=是)
     * â†’ é¢„约记录 {@code appointment.is_bedside} â†’ é¢„约确认时带入排队。
     * <p>
     * ä¸Žã€Œæ£€æŸ¥ç±»åž‹ã€çš„区别:{@code check_type.value=1000} æ˜¯"床边心电图"这个**项目**,
     * è€Œæœ¬å­—段表达**同一项目、患者需到床边做**这一属性。
     */
    @Schema(description = "是否床边 0=否 1=是", example = "0")
    private String isBedside;
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/queuesequence/QueueSequenceController.java
@@ -39,58 +39,61 @@
    private QueueSequenceService queueSequenceService;
    @PostMapping("/create")
    @Operation(summary = "创建当天序号")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    @Operation(summary = "创建预约时段规则")
    @PreAuthorize("@ss.hasAnyPermissions('ecg:check-type:create', 'ecg:doctor:task')")
    public CommonResult<Integer> createQueueSequence(@Valid @RequestBody QueueSequenceSaveReqVO createReqVO) {
        return success(queueSequenceService.createQueueSequence(createReqVO));
    }
    @PutMapping("/update")
    @Operation(summary = "更新当天序号")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    @Operation(summary = "更新预约时段规则")
    @PreAuthorize("@ss.hasAnyPermissions('ecg:check-type:update', 'ecg:doctor:task')")
    public CommonResult<Boolean> updateQueueSequence(@Valid @RequestBody QueueSequenceSaveReqVO updateReqVO) {
        queueSequenceService.updateQueueSequence(updateReqVO);
        return success(true);
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除当天序号")
    @Operation(summary = "删除预约时段规则")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    @PreAuthorize("@ss.hasAnyPermissions('ecg:check-type:delete', 'ecg:doctor:task')")
    public CommonResult<Boolean> deleteQueueSequence(@RequestParam("id") Integer id) {
        queueSequenceService.deleteQueueSequence(id);
        return success(true);
    }
    @GetMapping("/get")
    @Operation(summary = "获得当天序号")
    @Operation(summary = "获得预约时段规则")
    @Parameter(name = "id", description = "编号", required = true, example = "1024")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    @PreAuthorize("@ss.hasAnyPermissions('ecg:check-type:query', 'ecg:doctor:task')")
    public CommonResult<QueueSequenceRespVO> getQueueSequence(@RequestParam("id") Integer id) {
        QueueSequenceDO queueSequence = queueSequenceService.getQueueSequence(id);
        return success(BeanUtils.toBean(queueSequence, QueueSequenceRespVO.class));
    }
    @GetMapping("/get-timeslot-by-checktype")
    @Operation(summary = "获得当天未满的VIP时间段")
    @Parameter(name = "id", description = "编号", required = true, example = "1024")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    @Operation(summary = "获得指定检查类型的全部预约时段")
    @Parameter(name = "checkType", description = "检查类型值", required = true)
    // âš ï¸ æœ¬æŽ¥å£æœ‰ä¸¤ä¸ªè°ƒç”¨æ–¹ï¼šâ‘ ã€Œé¢„约时段维护」界面(check-type æƒé™ï¼‰
    //                      â‘¡ é¢„约流程 appointment/CheckItemPanel.vue(appointment æƒé™ï¼‰
    // æ•…必须同时允许这三类权限,否则会破坏其中一条业务流。
    @PreAuthorize("@ss.hasAnyPermissions('ecg:check-type:query', 'ecg:appointment:query', 'ecg:doctor:task')")
    public CommonResult<List<QueueSequenceRespVO>> getTimeslotByCheckType(@RequestParam("checkType") Integer checkType) {
        List<QueueSequenceDO> queueSequenceList = queueSequenceService.selectGivenCheckTypeTimeslot(checkType);
        return success(BeanUtils.toBean(queueSequenceList, QueueSequenceRespVO.class));
    }
    @GetMapping("/page")
    @Operation(summary = "获得当天序号分页")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    @Operation(summary = "获得预约时段规则分页")
    @PreAuthorize("@ss.hasAnyPermissions('ecg:check-type:query', 'ecg:doctor:task')")
    public CommonResult<PageResult<QueueSequenceRespVO>> getQueueSequencePage(@Valid QueueSequencePageReqVO pageReqVO) {
        PageResult<QueueSequenceDO> pageResult = queueSequenceService.getQueueSequencePage(pageReqVO);
        return success(BeanUtils.toBean(pageResult, QueueSequenceRespVO.class));
    }
    @GetMapping("/export-excel")
    @Operation(summary = "导出当天序号 Excel")
    @PreAuthorize("@ss.hasPermission('ecg:doctor:task')")
    @Operation(summary = "导出预约时段规则 Excel")
    @PreAuthorize("@ss.hasAnyPermissions('ecg:check-type:export', 'ecg:doctor:task')")
    @ApiAccessLog(operateType = EXPORT)
    public void exportQueueSequenceExcel(@Valid QueueSequencePageReqVO pageReqVO,
              HttpServletResponse response) throws IOException {
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/queuesequence/vo/QueueSequenceRespVO.java
@@ -28,10 +28,18 @@
    @ExcelProperty("该时段 æ™®é€šæ‚£è€… åºå·")
    private Integer queueNo;
    @Schema(description = "该时段 æ™®é€šæ‚£è€… èµ·å§‹åºå·ï¼ˆæ¯æ—¥é‡ç½®å›žåˆ°æ­¤å€¼ï¼‰")
    @ExcelProperty("普通起始序号")
    private Integer queueSet;
    @Schema(description = "该时段 vip åºå·")
    @ExcelProperty("该时段 vip åºå·")
    private Integer queueVipNo;
    @Schema(description = "该时段 vip èµ·å§‹åºå·ï¼ˆæ¯æ—¥é‡ç½®å›žåˆ°æ­¤å€¼ï¼‰")
    @ExcelProperty("vip起始序号")
    private Integer queueVipSet;
    @Schema(description = "该时段 æ™®é€šæ‚£è€… åºå·æ»¡")
    @ExcelProperty("该时段 æ™®é€šæ‚£è€… åºå·æ»¡")
    private Integer queueFull;
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/controller/admin/queuesequence/vo/QueueSequenceSaveReqVO.java
@@ -2,31 +2,44 @@
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.*;
import javax.validation.constraints.NotNull;
import java.util.*;
@Schema(description = "管理后台 - å½“天序号新增/修改 Request VO")
@Schema(description = "管理后台 - é¢„约时段规则 æ–°å¢ž/修改 Request VO")
@Data
public class QueueSequenceSaveReqVO {
    @Schema(description = "DB ä¸»é”®", requiredMode = Schema.RequiredMode.REQUIRED, example = "11958")
    @Schema(description = "DB ä¸»é”®ï¼ˆæ–°å¢žæ—¶ç•™ç©ºï¼‰", example = "11958")
    private Integer id;
    @Schema(description = "检查类型", example = "2")
    @Schema(description = "检查类型(取值来自 check_type.value)", example = "100",
            requiredMode = Schema.RequiredMode.REQUIRED)
    @NotNull(message = "检查类型不能为空")
    private Integer checkType;
    @Schema(description = "时段")
    @Schema(description = "时段(HHMMHHMM å½¢å¼ï¼Œå¦‚ 8000830 è¡¨ç¤º 08:00-08:30)", example = "8000830",
            requiredMode = Schema.RequiredMode.REQUIRED)
    @NotNull(message = "时段不能为空")
    private Integer timeSlot;
    @Schema(description = "该时段 æ™®é€šæ‚£è€… åºå·")
    @Schema(description = "该时段 æ™®é€šæ‚£è€… å½“前序号(取号时从此值往后发;可与起始序号不同)")
    private Integer queueNo;
    @Schema(description = "该时段 vip åºå·")
    @Schema(description = "该时段 æ™®é€šæ‚£è€… èµ·å§‹åºå·ï¼ˆæ¯æ—¥é‡ç½®å›žåˆ°æ­¤å€¼ï¼‰")
    private Integer queueSet;
    @Schema(description = "该时段 vip å½“前序号")
    private Integer queueVipNo;
    @Schema(description = "该时段 æ™®é€šæ‚£è€… åºå·æ»¡")
    @Schema(description = "该时段 vip èµ·å§‹åºå·ï¼ˆæ¯æ—¥é‡ç½®å›žåˆ°æ­¤å€¼ï¼‰")
    private Integer queueVipSet;
    @Schema(description = "该时段 æ™®é€šæ‚£è€… åºå·ä¸Šé™ï¼ˆè¾¾åˆ°å³è¯¥æ—¶æ®µæ»¡ï¼‰",
            requiredMode = Schema.RequiredMode.REQUIRED)
    @NotNull(message = "普通序号上限不能为空")
    private Integer queueFull;
    @Schema(description = "该时段 vip åºå·æ»¡")
    @Schema(description = "该时段 vip åºå·ä¸Šé™")
    private Integer queueVipFull;
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/dataobject/appointment/AppointmentDO.java
@@ -132,8 +132,52 @@
    private LocalDateTime syncTime;
    @TableField(exist = false)
    /**
     * é¢„约排序号。
     * <p>
     * æ¥æº HIS æŠ¥æ–‡ {@code actAppointment.subject.patient.id.item[]} ä¸­
     * {@code root=2.16.156.10011.2.5.1.11} çš„项(<b>按 root åŒ¹é…ï¼ŒéžæŒ‰ä½ç½®</b>:
     * æ–‡æ¡£ç¤ºä¾‹æ ‡åœ¨ item[4],但实际报文 item[4] çš„ root æ˜¯ 2.5.1.8「就诊次数」,
     * æŽ’序号实际位于 item[6])。
     */
    private Integer bookSeqNum;
    /**
     * ä¼˜å…ˆçº§ / åŠ æ€¥æ ‡è®°ã€‚æ¥æº HIS æŠ¥æ–‡ {@code observationRequest.zdy.jjbz}:N=常规,J=加急。
     */
    private String jjbz;
    /**
     * æ£€æŸ¥æ–¹å¼ç¼–码。来源 HIS æŠ¥æ–‡ {@code component2.observationRequest.methodCode.item[].code}。
     */
    private String methodCode;
    /**
     * æ£€æŸ¥éƒ¨ä½ç¼–码。来源 HIS æŠ¥æ–‡ {@code component2.observationRequest.targetSiteCode.item.code}。
     */
    private String targetSiteCode;
    /**
     * åŒ»å˜±å·ï¼ˆåŒ»å˜±ID)。来源 HIS æŠ¥æ–‡
     * {@code component2.observationRequest.id.item[root=2.16.156.10011.1.28].extension}。
     * <p>
     * ä¾æ®ã€Šäº¤äº’服务通用接口文档》:6.1 ç”³è¯·å•新增服务的示例中该节点注释为「医嘱ID」,
     * 6.4/6.5 æ£€æŸ¥é¢„约状态服务中注释为「医嘱号」。
     * å®žæµ‹ S0201ECG 220 æ¡ã€S0202ECG 29 æ¡å‡æºå¸¦è¯¥å€¼ï¼ˆå½¢å¦‚ {@code 112663505})。
     * <p>
     * æ³¨æ„ï¼š<b>不是</b> {@code apply_no}(申请单号,root {@code 2.16.156.10011.1.24}),二者不同。
     */
    private String doctorAdviceNo;
    /**
     * æ£€æŸ¥æˆ¿é—´åç§°ã€‚来源 HIS æŠ¥æ–‡ {@code ZDY.roomname}(S050401/S050402)。
     */
    private String roomName;
    /**
     * æ³¨æ„äº‹é¡¹ã€‚来源 HIS æŠ¥æ–‡ {@code ZDY.remark}(S050401/S050402)。
     */
    private String remark;
    private String status;
    /**
@@ -143,10 +187,24 @@
    /**
     * è€è¯æ€§ï¼š æ˜¯å¦å¤šè€1代表是,0代表不是
     * <p>
     * æ¥æº HIS æŠ¥æ–‡ {@code observationRequest.zdy.ISDN}(自定义字段区,官方文档未定义,
     * å®žæµ‹çœŸå®žæŠ¥æ–‡åŒ…含该字段,取值恒为 0)。
     */
    private String tolerance;
    /**
     * æ˜¯å¦åºŠè¾¹ï¼ˆ0否 1是)
     * <p>
     * æ¥æº HIS æŠ¥æ–‡ {@code observationRequest.zdy.ISCB}(自定义字段区,官方文档未定义,
     * å®žæµ‹çœŸå®žæŠ¥æ–‡åŒ…含该字段,取值恒为 0)。
     * <p>
     * ä¸Žã€Œæ£€æŸ¥ç±»åž‹ã€çš„区别:{@code check_type.value=1000} åç§°æ˜¯"床边心电图(常规+频谱)",
     * é‚£æ˜¯**一个检查项目**;而本字段表达的是**同一项目、患者需到床边做**,两者语义不同。
     */
    private String isBedside;
    /**
     * ä½é™¢å·
     */
    private String hospitalNo;
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/dataobject/checktype/CheckTypeDO.java
@@ -78,4 +78,28 @@
    private String notes;
    private String seqPrefix;
    /**
     * æŽ’队序号规则(需求:签到时如何给患者排队序号)
     *
     * <p>取值见 {@link cn.lihu.jh.module.ecg.enums.SeqRuleEnum}:
     * <ul>
     *   <li>0 = é¢„约号(用预约系统下发的号,不再分配新号)</li>
     *   <li>1 = ç­¾åˆ°åºå·ï¼ˆå½“天同检查类型内连续递增,每 9 ä¸ªé¢„ç•™ 1 ä¸ªç»™åŠ æ€¥ï¼‰</li>
     *   <li>2 = æ—¶æ®µå·ï¼ˆæŒ‰é¢„约时间段给号,现状规则)</li>
     * </ul>
     *
     * <p>⚠️ å­˜é‡æ•°æ®è¯¥åˆ—为 null æ—¶ï¼Œ{@code SeqRuleEnum.parse()} ä¼šå›žé€€ä¸º
     * {@code TIMESLOT(2)} â€”— å³**保持改造前的行为**,不会因未配置而改变取号逻辑。
     */
    private Integer seqRule;
    /**
     * æŽ’序序号:越小越靠前(检查项目的显示顺序)
     *
     * <p>需求:检查类型管理列表需按此字段排序,并可在线编辑。
     * <p>存量数据由 {@code _prep/23_add_check_type_sort.sql} æŒ‰ {@code value} å‡åºåˆå§‹åŒ–,
     * ä¿è¯å‡çº§åŽåˆ—表顺序与升级前一致。
     */
    private Integer sort;
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/dataobject/devrent/DevRentDO.java
@@ -5,9 +5,7 @@
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.*;
import java.util.*;
import java.time.LocalDateTime;
import java.time.LocalDateTime;
import java.time.LocalDateTime;
import java.time.LocalDate;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.*;
import cn.lihu.jh.framework.mybatis.core.dataobject.BaseDO;
@@ -97,4 +95,70 @@
    private Integer state;
    private Integer paid;
    // ==================== éœ€æ±‚1:患者信息 18 é¡¹æ‰€éœ€çš„平铺字段 ====================
    // ä»¥ä¸‹å‡æ¥è‡ªé¢„约单 appointment(dev_rent è¡¨æ— å¯¹åº”列),由 DevRentServiceImpl
    // æŸ¥è¯¢åŽæŒ‰ applyNo å›žæŸ¥å›žå¡«ã€‚
    //
    // ã€é‡è¦ã€‘必须标记 @TableField(exist = false):
    //   DevRentDO ä¼šç”¨äºŽ updateById / insert,若不加该注解,MyBatis-Plus ä¼šæŠŠè¿™äº›
    //   ä¸å­˜åœ¨çš„列拼进 SQL,导致 "Unknown column" æŠ¥é”™ã€‚
    //
    // ä¹‹æ‰€ä»¥å¹³é“ºè€Œä¸è®©å‰ç«¯è¯» patDetails JSON:
    //   patDetails æ˜¯ç¡®è®¤é¢„约时写入的快照,可能滞后于 appointment çš„æœ€æ–°å€¼ï¼›
    //   ä¸”前端读 patDetails?.xxx æ—  TS ç±»åž‹æç¤ºï¼Œå®¹æ˜“写错。
    @TableField(exist = false)
    private Byte patGender;
    @TableField(exist = false)
    private LocalDate patBirthday;
    @TableField(exist = false)
    private Date registerDate;
    @TableField(exist = false)
    private String doctor;
    @TableField(exist = false)
    private Date doctorTime;
    @TableField(exist = false)
    private Integer patSrc;
    @TableField(exist = false)
    private String patMobile;
    @TableField(exist = false)
    private String patPhone;
    @TableField(exist = false)
    private String patWardDesc;
    @TableField(exist = false)
    private String patBedNo;
    @TableField(exist = false)
    private String medicalCard;
    @TableField(exist = false)
    private String outpatientNo;
    @TableField(exist = false)
    private String patIdentityId;
    /**
     * åŒ»å˜±å·ï¼ˆHIS æŠ¥æ–‡ observationRequest.id.item[root=2.16.156.10011.1.28].extension)。
     * ä¸Žã€Œç”³è¯·å•号 applyNo」不同:前者是医嘱标识,后者是申请单标识。该节点在规范中非必须项,库中可为空。
     */
    private String doctorAdviceNo;
    /** å¤šè€è¯ï¼š0 æ—  / 1 å¤šè€ / 2 å¤šé‡è€è¯ */
    @TableField(exist = false)
    private String tolerance;
    /**
     * æ˜¯å¦åºŠè¾¹ï¼ˆ0否 1是)—— éœ€æ±‚2,来源 HIS zdy.ISCB
     */
    private String isBedside;
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/dataobject/jobrecord/JobRecordDO.java
@@ -2,8 +2,7 @@
import lombok.*;
import java.util.*;
import java.time.LocalDateTime;
import java.time.LocalDateTime;
import java.time.LocalDate;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.*;
import cn.lihu.jh.framework.mybatis.core.dataobject.BaseDO;
@@ -77,4 +76,67 @@
    @TableField(exist = false)
    private Integer paid;
    // ==================== éœ€æ±‚1:患者信息 18 é¡¹ ====================
    // job_record åªå­˜äº† patId / patName,其余患者信息在 appointment è¡¨ã€‚
    // å›žå¡«é“¾è·¯ï¼šjob_record.rentId â†’ dev_rent.applyNo â†’ appointment
    // ç”± JobRecordServiceImpl#fillPatientInfo æŒ‰ rentId æ‰¹é‡å›žå¡«ï¼ˆé¿å… N+1)。
    //
    // ã€é‡è¦ã€‘必须标 @TableField(exist = false):本 DO ä¼šç”¨äºŽ insert/updateById,
    // è‹¥ä¸åŠ æ³¨è§£ï¼ŒMyBatis-Plus ä¼šæŠŠè¿™äº›ä¸å­˜åœ¨çš„列拼进 SQL â†’ Unknown column æŠ¥é”™ã€‚
    @TableField(exist = false)
    private String applyNo;
    @TableField(exist = false)
    private String episodeId;
    @TableField(exist = false)
    private Byte patGender;
    @TableField(exist = false)
    private LocalDate patBirthday;
    @TableField(exist = false)
    private Date registerDate;
    @TableField(exist = false)
    private String doctor;
    @TableField(exist = false)
    private Date doctorTime;
    @TableField(exist = false)
    private Integer patSrc;
    @TableField(exist = false)
    private String patMobile;
    @TableField(exist = false)
    private String patPhone;
    @TableField(exist = false)
    private String patWardDesc;
    @TableField(exist = false)
    private String patBedNo;
    @TableField(exist = false)
    private String medicalCard;
    @TableField(exist = false)
    private String outpatientNo;
    @TableField(exist = false)
    private String patIdentityId;
    /**
     * åŒ»å˜±å·ï¼ˆHIS æŠ¥æ–‡ observationRequest.id.item[root=2.16.156.10011.1.28].extension)。
     * ä¸Žã€Œç”³è¯·å•号 applyNo」不同:前者是医嘱标识,后者是申请单标识。该节点在规范中非必须项,库中可为空。
     */
    private String doctorAdviceNo;
    /** å¤šè€è¯ï¼š0无 1多耐 2多重耐药 */
    @TableField(exist = false)
    private String tolerance;
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/dataobject/queue/QueueDO.java
@@ -121,6 +121,15 @@
    private String tolerance;
    /**
     * æ˜¯å¦åºŠè¾¹ï¼ˆ0否 1是)—— éœ€æ±‚2。
     * <p>
     * queue è¡¨å·²æœ‰è¯¥åˆ—(见 _prep/22_add_seq_rule_and_bedside.sql),
     * é¢„约确认时由 appointment.is_bedside å¸¦å…¥ï¼›å¯¹å·²å­˜åœ¨çš„队列行,
     * ä¹Ÿå¯ç”± {@code fillAppointmentInfo} å›žæŸ¥è¡¥å¡«ã€‚
     */
    private String isBedside;
    /**
     * ä»¥ä¸‹å­—段来自预约单(appointment),queue è¡¨ä¸­ä¸å­˜åœ¨ï¼Œ
     * ç”± QueueServiceImpl#fillAppointmentInfo åœ¨æŸ¥è¯¢åŽæŒ‰ applyNo å›žæŸ¥å¡«å……。
     */
@@ -135,5 +144,42 @@
    @TableField(exist = false)
    private Date doctorTime;
    // ==================== éœ€æ±‚1:患者信息 18 é¡¹æ‰€éœ€çš„平铺字段 ====================
    // ä»¥ä¸‹å‡æ¥è‡ª appointment,queue è¡¨æ— å¯¹åº”列,由 fillAppointmentInfo å›žå¡«ã€‚
    // ä¹‹æ‰€ä»¥"平铺"而不是让前端读 patDetails JSON:
    //   1) patDetails æ˜¯ç¡®è®¤é¢„约时写入的快照,可能滞后于 appointment çš„æœ€æ–°å€¼ï¼›
    //   2) å‰ç«¯æ¨¡æ¿è¯» `row.patDetails?.xxx` æ— æ³•享受 TS ç±»åž‹æç¤ºï¼Œæ˜“写错。
    @TableField(exist = false)
    private Integer patSrc;
    @TableField(exist = false)
    private String patMobile;
    @TableField(exist = false)
    private String patPhone;
    @TableField(exist = false)
    private String patWardDesc;
    @TableField(exist = false)
    private String patBedNo;
    /** å°±è¯Šå¡å·ï¼ˆmedical_card;库中多为空,前端自行回退到 outpatient_no) */
    @TableField(exist = false)
    private String medicalCard;
    @TableField(exist = false)
    private String outpatientNo;
    @TableField(exist = false)
    private String patIdentityId;
    /**
     * åŒ»å˜±å·ï¼ˆHIS æŠ¥æ–‡ observationRequest.id.item[root=2.16.156.10011.1.28].extension)。
     * ä¸Žã€Œç”³è¯·å•号 applyNo」不同:前者是医嘱标识,后者是申请单标识。该节点在规范中非必须项,库中可为空。
     */
    private String doctorAdviceNo;
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/dataobject/queuesequence/QueueSequenceDO.java
@@ -40,10 +40,21 @@
     */
    private Integer queueNo;
    /**
     * è¯¥æ—¶æ®µ æ™®é€šæ‚£è€… åºå·åˆå§‹å€¼ï¼ˆæ¯æ—¥é‡ç½®æ—¶çš„起始号,见 {@code QueueSequenceMapper#initNumber})
     * <p>
     * âš ï¸ åŽŸå…ˆæœ¬ DO æœªæ˜ å°„该列。手工维护时段规则时必须能设置它 â€”—
     * å¦åˆ™ã€Œé¢„约时段」维护界面无法指定每个时段从几号开始。
     */
    private Integer queueSet;
    /**
     * è¯¥æ—¶æ®µ vip åºå·
     */
    private Integer queueVipNo;
    /**
     * è¯¥æ—¶æ®µ vip åºå·åˆå§‹å€¼ï¼ˆæ¯æ—¥é‡ç½®æ—¶çš„起始号)
     */
    private Integer queueVipSet;
    /**
     * è¯¥æ—¶æ®µ æ™®é€šæ‚£è€… åºå·æ»¡
     */
    private Integer queueFull;
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/dataobject/room/RoomDO.java
@@ -68,4 +68,16 @@
     * @see cn.lihu.jh.module.ecg.enums.BedOpTypeEnum
     */
    private Integer opType;
    /**
     * ã€éœ€æ±‚3】停止候诊开关:0=正常接收候诊患者;1=停止新增候诊患者。
     * <p>
     * å¼€å¯åŽ**已有候诊病人继续叫号,但不再新增候诊病人**。
     * è¯¥å­—段会由 {@code bedReload} é€šè¿‡ BeanUtils è‡ªåŠ¨æ˜ å°„åˆ°
     * {@link cn.lihu.jh.module.ecg.service.queue.BedBO#stopReady}。
     * <p>
     * æ³¨æ„ï¼šä¸ŽåŒ»ç”Ÿ"暂停"({@code status=DOCTOR_PAUSE})是**两个独立概念**,
     * åŽè€…经 {@code updateStop} ä¼šå¼ºåˆ¶å®Œæˆå°±è¯Šä¸­æ‚£è€…并踢走候诊中患者。
     */
    private Boolean stopReady;
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/mysql/appointment/AppointmentMapper.java
@@ -52,5 +52,18 @@
    @Select("select * from lihu.appointment where apply_no = #{applyNo} limit 1")
    AppointmentDO getByApplyNo(@Param("applyNo") String applyNo);
    /**
     * ã€éœ€æ±‚1】按申请号批量查询,供列表页一次性回填患者信息,避免 N+1 æŸ¥è¯¢ã€‚
     *
     * @param applyNoList ç”³è¯·å·åˆ—表(调用方需保证非空,MySQL çš„ IN () ä¼šè¯­æ³•错误)
     */
    @Select("<script>" +
            "select * from lihu.appointment where deleted = 0 and apply_no in (" +
            " <foreach collection='applyNoList' separator=',' item='_applyNo'>" +
            "   #{_applyNo} " +
            " </foreach> )" +
            "</script>")
    List<AppointmentDO> getByApplyNoList(@Param("applyNoList") List<String> applyNoList);
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/mysql/checktype/CheckTypeMapper.java
@@ -30,7 +30,9 @@
                .eqIfPresent(CheckTypeDO::getTimeslotBookNum, reqVO.getTimeslotBookNum())
                .eqIfPresent(CheckTypeDO::getTimeslotReservedNum, reqVO.getTimeslotReservedNum())
                .eqIfPresent(CheckTypeDO::getAffinityCheckTypes, reqVO.getAffinityCheckTypes())
                .orderByDesc(CheckTypeDO::getId));
                // éœ€æ±‚9:按「排序序号」升序展示(原先按 id å€’序,与业务排序无关)
                .orderByAsc(CheckTypeDO::getSort)
                .orderByAsc(CheckTypeDO::getValue));
    }
    default PageResult<CheckTypeDO> selectPageForCallingScreen(CheckTypePageReqVO reqVO) {
@@ -46,7 +48,9 @@
                .eqIfPresent(CheckTypeDO::getTimeslotBookNum, reqVO.getTimeslotBookNum())
                .eqIfPresent(CheckTypeDO::getTimeslotReservedNum, reqVO.getTimeslotReservedNum())
                .eqIfPresent(CheckTypeDO::getAffinityCheckTypes, reqVO.getAffinityCheckTypes())
                .orderByDesc(CheckTypeDO::getId));
                // éœ€æ±‚9:按「排序序号」升序展示(原先按 id å€’序,与业务排序无关)
                .orderByAsc(CheckTypeDO::getSort)
                .orderByAsc(CheckTypeDO::getValue));
    }
    @Select("SELECT * FROM lihu.check_type;")
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/mysql/devrent/DevRentMapper.java
@@ -74,4 +74,23 @@
    @Update("update lihu.dev_rent set paid = #{paid} where id = #{rentId};")
    Integer setPaid(@Param("rentId") Long rentId, @Param("paid") Integer paid);
    /**
     * ã€éœ€æ±‚1】按 id æ‰¹é‡å–「id + apply_no」。
     * <p>
     * ç”¨é€”:作业记录列表页回填患者信息时需要 `job_record.rentId â†’ dev_rent.applyNo`。
     * åŽŸå…ˆç”¨ {@code selectById} é€æ¡å–(N æ¬¡æŸ¥è¯¢ï¼‰ï¼Œæœ¬æ–¹æ³•改为一次 IN æŸ¥è¯¢ã€‚
     * <p>
     * åˆ»æ„**只取两列**而非 `SELECT *`:dev_rent å« `pat_details` ç­‰ JSON å¤§å­—段,
     * å›žå¡«åªéœ€ applyNo,没必要把整个 DO æ‹‰å›žæ¥ã€‚
     *
     * @param rentIdList ç§Ÿèµ id åˆ—表(调用方需保证非空,MySQL çš„ IN () ä¼šè¯­æ³•错误)
     */
    @Select("<script>" +
            "SELECT id, apply_no FROM lihu.dev_rent WHERE id IN (" +
            " <foreach collection='rentIdList' separator=',' item='rentId'>" +
            "  #{rentId} " +
            " </foreach> )" +
            "</script>")
    List<DevRentDO> getApplyNoByIdList(@Param("rentIdList") List<Long> rentIdList);
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/mysql/jobrecord/JobRecordMapper.java
@@ -35,6 +35,8 @@
                .eqIfPresent(JobRecordDO::getRemark, reqVO.getRemark())
                .betweenIfPresent(JobRecordDO::getCreateTime, reqVO.getCreateTime())
                .eqIfPresent(JobRecordDO::getRentId, reqVO.getRentId())
                // æŒ‰è¯Šå®¤çš„æ£€æŸ¥é¡¹ç›®éš”离已完成记录(详见 JobRecordPageReqVO#checkTypes æ³¨é‡Šï¼‰
                .inIfPresent(JobRecordDO::getCheckType, reqVO.getCheckTypes())
                .orderByDesc(JobRecordDO::getId));
    }
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/mysql/queue/QueueMapper.java
@@ -54,11 +54,16 @@
    @Select("""
            <script>
                SELECT * FROM lihu.queue
                where room_id = #{roomId} and status in (
                where room_id = #{roomId} and deleted = 0 and status in (
                 <foreach collection='statusList' separator=',' item='status'>
                   #{status} 
                 </foreach> )
                order by status desc, seq_num, book_timeslot
                order by case
                           when status = 30 then 0
                           when status in (3, 5) then 1
                           else 2
                         end,
                         seq_num, book_timeslot
            </script>
            """)
    @Results({
@@ -66,14 +71,31 @@
    })
    List<QueueDO> getRoomQueueByStatus(@Param("roomId")Long roomId, @Param("statusList")List<Integer> statusList);
    @Select("<script>" +
            "SELECT * FROM lihu.queue " +
            "where status in (" +
            " <foreach collection='statusList' separator=',' item='status'>" +
            "  #{status} " +
            " </foreach> )" +
            " order by status desc, seq_num, book_timeslot" +
            "</script>")
    /**
     * å¤§å±å–数。
     * <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} é€»è¾‘删除过滤。
     */
    @Select("""
            <script>
                SELECT * FROM lihu.queue
                where deleted = 0 and status in (
                 <foreach collection='statusList' separator=',' item='status'>
                   #{status}
                 </foreach> )
                order by case
                           when status = 30 then 0
                           when status in (3, 5) then 1
                           else 2
                         end,
                         seq_num, book_timeslot
            </script>
            """)
    @Results({
            @Result(property = "patDetails", column = "pat_details", typeHandler=com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
    })
@@ -150,6 +172,7 @@
    @Select("<script>" +
            "SELECT count(1) as total_in_status FROM lihu.queue where " +
            " deleted = 0 and book_date = CURDATE() and " +
            " status in ( " +
            " <foreach collection='statusList' separator=',' item='status'>" +
            "  #{status} " +
@@ -172,6 +195,7 @@
    @Select("<script>" +
            "SELECT status, count(1) as total_in_status FROM lihu.queue where " +
            " deleted = 0 and book_date = CURDATE() and " +
            " room_id = #{roomId} and bed_no = #{bedNo} " +
            " group by status " +
            "</script>")
@@ -179,6 +203,7 @@
    @Select("<script>" +
            "SELECT status, count(1) as total_in_status FROM lihu.queue where " +
            " deleted = 0 and book_date = CURDATE() and " +
            " room_id = #{roomId} " +
            " group by status " +
            "</script>")
@@ -186,7 +211,8 @@
    @Select("<script>" +
            "SELECT count(1) FROM lihu.queue " +
            "where room_id = #{roomId} and bed_no = #{bedNo} and status in (" +
            "where deleted = 0 and book_date = CURDATE() and " +
            " room_id = #{roomId} and bed_no = #{bedNo} and status in (" +
            " <foreach collection='statusList' separator=',' item='status'>" +
            "   #{status} " +
            " </foreach> )" +
@@ -196,7 +222,7 @@
    @Select("""
            <script>
                SELECT * FROM lihu.queue 
                where room_id = #{roomId} and bed_no = #{bedNo} and status in (
                where deleted = 0 and room_id = #{roomId} and bed_no = #{bedNo} and status in (
                  <foreach collection='statusList' separator=',' item='status'>
                    #{status} 
                  </foreach> ) 
@@ -213,6 +239,48 @@
               and status = #{curStatus} order by seq_num, book_timeslot limit 1
            """)
    QueueDO getBedFirstQueueItemByStatus(@Param("roomId")Long roomId, @Param("bedNo")String bedNo, @Param("curStatus")Integer curStatus);
    /**
     * ã€éœ€æ±‚2 ç›¸å…³ç¼ºé™·ä¿®å¤ã€‘按「工位 + æ‚£è€… + æ£€æŸ¥ç±»åž‹ + çŠ¶æ€ã€**精确**定位队列行。
     * <p>
     * ç”¨äºŽæ›¿ä»£ {@link #getBedFirstQueueItemByStatus}:后者只按"工位 + çŠ¶æ€å– seq_num æœ€å°çš„一条",
     * å½“医生先点"放弃检查"(会把该患者置为 40)后再点"检查完成"时,
     * ä¼šå–到**下一位患者**并把他误置为已完成(详见 DevRentServiceImpl.routineFinishOperation)。
     */
    @Select("""
            SELECT * from lihu.queue
            where room_id = #{roomId} and bed_no = #{bedNo}
              and status = #{curStatus}
              and pat_id = #{patId}
              and book_check_type = #{checkType}
              and TO_DAYS(book_date) = TO_DAYS(NOW())
            order by seq_num limit 1
            """)
    @Results({
            @Result(property = "patDetails", column = "pat_details", typeHandler=com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
    })
    QueueDO getBedQueueItemByPatAndStatus(@Param("roomId")Long roomId, @Param("bedNo")String bedNo,
                                          @Param("patId")String patId, @Param("checkType")Integer checkType,
                                          @Param("curStatus")Integer curStatus);
    /**
     * æŒ‰å·¥ä½ + çŠ¶æ€ + ä»Šæ—¥å–**最近更新**的一条。
     * <p>
     * æ¯”「seq_num æœ€å°ã€æ›´è´´åˆ"当前正在就诊/装机的那位患者":seq_num æœ€å°æ˜¯æœ¬æ—¶æ®µæœ€æ—©æŽ’队的,
     * è€Œ update_time æœ€æ–°æ‰æ˜¯åˆšè¢«å«å·æŽ¨è¿›åˆ°è¯¥çŠ¶æ€çš„ã€‚
     */
    @Select("""
            SELECT * from lihu.queue
            where room_id = #{roomId} and bed_no = #{bedNo}
              and status = #{curStatus}
              and TO_DAYS(book_date) = TO_DAYS(NOW())
            order by update_time desc, seq_num limit 1
            """)
    @Results({
            @Result(property = "patDetails", column = "pat_details", typeHandler=com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
    })
    QueueDO getBedLatestQueueItemByStatus(@Param("roomId")Long roomId, @Param("bedNo")String bedNo,
                                          @Param("curStatus")Integer curStatus);
    @Select("""
            SELECT * from lihu.queue where apply_no = #{applyNo} and status = #{curStatus}  order by seq_num, book_timeslot limit 1
@@ -326,6 +394,52 @@
    Integer queueJump(@Param("patId")String patId, @Param("bookCheckType")Integer bookCheckType,
                      @Param("curStatus")Integer curStatus, @Param("jumped")Byte jumped );
    // ==================== éœ€æ±‚2:误点「叫号 / æ£€æŸ¥å®Œæˆã€çš„æ’¤é”€ ====================
    /**
     * æ’¤é”€ã€Œæ£€æŸ¥å®Œæˆã€ï¼šæŠŠæœ¬å·¥ä½ä»Šæ—¥çš„**指定患者**从 å·²å°±è¯Š(40) é€€å›ž å€™è¯Šä¸­(20)。
     * <p>
     * å¿…须按 {@code pat_id + book_check_type + ä»Šæ—¥} ç²¾ç¡®å®šä½ï¼Œ**不能**只按
     * "工位 + status=40 å–第一条"——那会在期间已叫下一位患者时误改他人(见
     * {@code DevRentServiceImpl.routineFinishOperation} çš„æ—¢æœ‰ç¼ºé™·ï¼‰ã€‚
     * <p>
     * {@code passed = 0} æ˜¯ä¸ºäº†è®©æ‚£è€…重新回到正常候诊序列(非"过号"态)。
     */
    @Update("update lihu.queue set status = #{newStatus}, passed = 0 " +
            " where room_id = #{roomId} and bed_no = #{bedNo} and status = #{curStatus} " +
            "   and pat_id = #{patId} and book_check_type = #{checkType} " +
            "   and TO_DAYS(book_date) = TO_DAYS(NOW())")
    Integer undoFinishPatient(@Param("roomId")Long roomId, @Param("bedNo")String bedNo,
                              @Param("patId")String patId, @Param("checkType")Integer checkType,
                              @Param("curStatus")Integer curStatus, @Param("newStatus")Integer newStatus);
    /**
     * æ’¤é”€ã€Œè¯¯ç‚¹å«å·ã€ï¼šæŠŠæœ¬å·¥ä½å½“前 å°±è¯Šä¸­(30) çš„æ‚£è€…退回 å€™è¯Šä¸­(20)。
     * <p>
     * ä»…按工位+状态定位("当前正在就诊的那一位"),不使用 seq_num å­æŸ¥è¯¢ï¼Œ
     * é¿å…ä¸Ž {@code updateBedQueueStatus} çš„写法混淆。
     */
    @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 TO_DAYS(book_date) = TO_DAYS(NOW()) " +
            "       order by seq_num, book_timeslot limit 1) a)")
    Integer undoCallPatient(@Param("roomId")Long roomId, @Param("bedNo")String bedNo,
                            @Param("curStatus")Integer curStatus, @Param("newStatus")Integer newStatus);
    /**
     * å–本工位今日**最近一位已完成**的患者(用于界面提示"可撤销的是谁")。
     */
    @Select("SELECT * FROM lihu.queue where room_id = #{roomId} and bed_no = #{bedNo} " +
            " and status = #{status} and TO_DAYS(book_date) = TO_DAYS(NOW()) " +
            " order by update_time desc, seq_num desc limit 1")
    @Results({
            @Result(property = "patDetails", column = "pat_details", typeHandler=com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
    })
    QueueDO getBedLastFinishedQueueItem(@Param("roomId")Long roomId, @Param("bedNo")String bedNo,
                                        @Param("status")Integer status);
    //@Update("TRUNCATE TABLE lihu.queue")
    @Delete("delete from lihu.queue where TO_DAYS(book_date) != TO_DAYS(NOW())")
    void clearQueue();
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/mysql/queuesequence/QueueSequenceMapper.java
@@ -6,6 +6,8 @@
import cn.lihu.jh.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.lihu.jh.framework.mybatis.core.mapper.BaseMapperX;
import cn.lihu.jh.module.ecg.dal.dataobject.queuesequence.QueueSequenceDO;
import cn.lihu.jh.module.ecg.dal.dataobject.queuesequence.SeqCounterDO;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import org.apache.ibatis.annotations.*;
import cn.lihu.jh.module.ecg.controller.admin.queuesequence.vo.*;
@@ -26,7 +28,10 @@
                .eqIfPresent(QueueSequenceDO::getQueueFull, reqVO.getQueueFull())
                .eqIfPresent(QueueSequenceDO::getQueueVipFull, reqVO.getQueueVipFull())
                .betweenIfPresent(QueueSequenceDO::getCreateTime, reqVO.getCreateTime())
                .orderByDesc(QueueSequenceDO::getId));
                // ã€é¢„约时段维护界面】按「检查类型 â†’ æ—¶æ®µã€å‡åºæŽ’列,
                // ä¾¿äºŽåœ¨åŒä¸€é¡¹ç›®ä¸‹é¡ºåºæŸ¥çœ‹/编辑各时段(原实现按 id å€’序,维护时难以阅读)
                .orderByAsc(QueueSequenceDO::getCheckType)
                .orderByAsc(QueueSequenceDO::getTimeSlot));
    }
    @Select(" select * from lihu.queue_sequence where check_type=#{checkType}; ")
@@ -55,4 +60,127 @@
    @Update("UPDATE lihu.queue_sequence SET queue_no=queue_set,queue_vip_no=queue_vip_set WHERE deleted=0; ")
    Integer initNumber();
    /**
     * ã€ä¿®å¤ã€Œæ—¶æ®µä¸å…¨/已满导致没法签到」】判断指定 (检查类型, æ—¶æ®µ) æ˜¯å¦**还有可取号**。
     * <p>
     * æ³¨æ„ï¼šä¸Ž {@link #selectQueueNoForUpdate} ä¸åŒï¼Œæœ¬æ–¹æ³•**不加 for update**、
     * ä¹Ÿä¸è¿”回具体号值 â€”— å®ƒåªç”¨äºŽã€Œå‘后寻找一个可用时段」时的探测,不参与并发取号,
     * çœŸæ­£å–号仍由 {@code selectQueueNoForUpdate} åœ¨äº‹åŠ¡å†…å®Œæˆã€‚
     *
     * @param includeVip true æ—¶åŒæ—¶è¦æ±‚ VIP å·ä¹Ÿæœ‰ä½™é‡ï¼ˆVIP æ‚£è€…用)
     * @return 1 = æœ‰ä½™é‡ï¼›0 = è¯¥æ—¶æ®µä¸å­˜åœ¨æˆ–已满
     */
    @Select("select count(1) from lihu.queue_sequence "
          + " where check_type = #{checkType} and time_slot = #{timeslot} "
          + "   and queue_no < queue_full "
          + "   and (#{includeVip} = 0 or queue_vip_no < queue_vip_full)")
    Integer countAvailableTimeslot(@Param("checkType") Integer checkType,
                                   @Param("timeslot") Integer timeslot,
                                   @Param("includeVip") Integer includeVip);
    // =========================================================================
    // ã€éœ€æ±‚:排队序号规则 = ç­¾åˆ°åºå·ã€‘计数器(seq_counter è¡¨ï¼‰
    //
    // è®¾è®¡è¦ç‚¹ï¼ˆä¸¤æ¬¡å®žæµ‹è¸©å‘后确定,勿改回):
    //   â‘  æ™®é€šå·ä¸Žé¢„留号用**两个独立计数器** â€”— å•个计数器"运算后跳过"必然重号:
    //      æ™®é€šæ‚£è€… candidate=10 â†’ è·³è¿‡é¢„留得 11;下一个 candidate=11 â†’ ä¹Ÿå¾— 11。
    //   â‘¡ æŽ¨è¿›ç”¨å•条 INSERT..ON DUPLICATE KEY UPDATE(原子);
    //      è¯»å›žå¿…须与推进处于**同一事务**,否则 REPEATABLE READ ä¼šè¯»åˆ°æ—§å¿«ç…§ã€‚
    //      å®žæµ‹ï¼šautoCommit=true æ—¶ 160 æ¬¡å–号仅 75 ä¸ªå”¯ä¸€å€¼ï¼›åŒ…事务后 160/160。
    // =========================================================================
    /**
     * åŽŸå­æŽ¨è¿›ã€æ™®é€šå·ã€‘è®¡æ•°å™¨ï¼Œå¹¶è¿”å›žåˆ†é…åˆ°çš„æ™®é€šå·ã€‚
     *
     * <h4>计数器语义(关键,勿改)</h4>
     * {@code normal_next} å­˜çš„æ˜¯**上一次已分配出去的普通号**(首次为 0)。
     * æœ¬æ¬¡åˆ†é… {@code = normal_next + 1};若该值恰是预留号(10 çš„倍数),
     * åˆ™æœ¬æ¬¡åˆ†é…å®žé™…上要跳到 {@code +2},并把计数器一并推进到 {@code +2}。
     *
     * <pre>
     *   normal_next: 0 â†’ åˆ†é…1 â†’ 1 â†’ åˆ†é…2 â†’ â€¦ â†’ 9 â†’ åˆ†é…11(跳过10) â†’ 11 â†’ åˆ†é…12 â†’ â€¦
     *                                            â†‘ è®¡æ•°å™¨ +2,因此不会在下一次再产出 11
     * </pre>
     *
     * <h4>为什么不能只 +1</h4>
     * è‹¥è®¡æ•°å™¨åª +1:{@code normal_next=10} æ—¶åˆ†é… {@code nextNormalSeq(11)=11},
     * è€Œ {@code normal_next=11} æ—¶åˆåˆ†é… 11 â€”— **同一个号发两次**。
     * å•元测试 {@code SeqRuleEnumTest#testNextNormalSeq_shouldSkipMultiplesOfTen} æ›¾æ•获此问题。
     * <p>
     * æœ¬ SQL ä¸­çš„ {@code IF} æ­£æ˜¯"跳过预留号时额外 +1",等价于 Java çš„
     * {@code nextNormalSeq(normal_next + 1)},且**在一条语句内原子完成**。
     *
     * @return æœ¬æ¬¡åˆ†é…åˆ°çš„æ™®é€šå·
     */
    // ==========================================================================
    // âš ï¸ å¿…须加 @InterceptorIgnore(dataPermission = "true"),否则【预约确认直接 500】
    //
    // å®žæµ‹æ•…障(2026-10-01 çº¿ä¸Šæ—¥å¿—):
    //   org.mybatis.spring.MyBatisSystemException: ... UnsupportedOperationException
    //     at JsqlParserSupport.processInsert(JsqlParserSupport.java:107)
    //     at DataPermissionInterceptor.beforePrepare(DataPermissionInterceptor.java:86)
    //   ### The error may involve QueueSequenceMapper.advanceNormalSeq
    //   ï¼ˆè°ƒç”¨é“¾ appointment/confirm â†’ distributeSeqNoByRule â†’ nextCheckInSeqNo â†’ advanceNormalSeq)
    //
    // åŽŸå› ï¼šMyBatis-Plus çš„ DataPermissionInterceptor ä¼šç”¨ JSqlParser è§£æžæ¯æ¡ SQL,
    //   è€Œ JSqlParser çš„ processInsert **不支持 MySQL çš„ `INSERT ... ON DUPLICATE KEY UPDATE`**
    //   è¯­æ³•,直接抛 UnsupportedOperationException;该异常穿透到业务层,导致签到失败。
    //   â€”— è¿™æ˜¯ã€Œæ¡†æž¶æ‹¦æˆªå™¨ã€ä¸Žã€ŒMySQL ä¸“有语法」的冲突,与 SQL æœ¬èº«æ˜¯å¦æ­£ç¡®æ— å…³ã€‚
    //
    // ä¸ºä»€ä¹ˆå¯ä»¥ã€ä¹Ÿåº”该忽略数据权限:
    //   1) seq_counter è¡¨**既没有 dept_id ä¹Ÿæ²¡æœ‰ user_id**,数据权限规则对它本就无意义;
    //   2) æœ¬è¯­å¥æ˜¯çº¯ç²¹çš„原子计数器推进,不涉及任何需要按部门/用户隔离的数据。
    // ==========================================================================
    @InterceptorIgnore(dataPermission = "true")
    @Update("INSERT INTO lihu.seq_counter (check_type, seq_date, normal_next, reserved_next) "
          + "VALUES (#{checkType}, #{seqDate}, 1, 10) "
          + "ON DUPLICATE KEY UPDATE normal_next = normal_next + 1 "
          + "  + IF((normal_next + 1) % 10 = 0, 1, 0)")
    int advanceNormalSeq(@Param("checkType") Integer checkType,
                         @Param("seqDate") java.time.LocalDate seqDate);
    /**
     * åŽŸå­æŽ¨è¿›ã€é¢„ç•™å·ã€‘è®¡æ•°å™¨ï¼Œå¹¶è¿”å›žåˆ†é…åˆ°çš„é¢„ç•™å·ï¼ˆåŠ æ€¥æ‚£è€…ç”¨ï¼‰ã€‚
     * <p>
     * é¢„留号序列:10、20、30…(固定为 10 çš„倍数)。
     * <p>
     * åŒæ ·éœ€è¦ {@code @InterceptorIgnore(dataPermission = "true")},原因见
     * {@link #advanceNormalSeq} ä¸Šæ–¹çš„详细说明(JSqlParser ä¸æ”¯æŒæœ¬è¯­æ³•)。
     *
     * @return æœ¬æ¬¡åˆ†é…åˆ°çš„预留号
     */
    @InterceptorIgnore(dataPermission = "true")
    @Update("INSERT INTO lihu.seq_counter (check_type, seq_date, normal_next, reserved_next) "
          + "VALUES (#{checkType}, #{seqDate}, 1, 10) "
          + "ON DUPLICATE KEY UPDATE reserved_next = reserved_next + 10")
    int advanceReservedSeq(@Param("checkType") Integer checkType,
                           @Param("seqDate") java.time.LocalDate seqDate);
    /**
     * è¯»å–两个计数器的当前值(即本次要分配的号)。
     *
     * <h4>⚠️ å¿…须与 advance* å¤„于【同一事务】内</h4>
     * MySQL é»˜è®¤ REPEATABLE READ:若 SELECT åœ¨ç‹¬ç«‹äº‹åŠ¡ä¸­ï¼Œä¼šè¯»åˆ°äº‹åŠ¡å¼€å§‹æ—¶çš„å¿«ç…§æ—§å€¼ï¼Œ
     * ä»Žè€ŒæŠŠå·²è¢«åˆ«äººç”¨æŽ‰çš„号重复发出去。
     * <p>
     * å®žæµ‹å¯¹æ¯”(8 çº¿ç¨‹ Ã— 20 æ¬¡ = 160 æ¬¡å–号):
     * <table border="1">
     *   <tr><th>方式</th><th>唯一值</th><th>结论</th></tr>
     *   <tr><td>autoCommit=true(推进与读回各自独立事务)</td><td>75 / 160</td><td>🔴 å¤§é‡é‡å·</td></tr>
     *   <tr><td>显式事务(BEGIN â†’ æŽ¨è¿› â†’ è¯»å›ž â†’ COMMIT)</td><td><b>160 / 160</b></td><td>✅ å·å€¼ 1~160 è¿žç»­æ— é‡</td></tr>
     * </table>
     * è°ƒç”¨æ–¹ {@code QueueSequenceServiceImpl#distributeSeqNoByRule} å·²æ ‡æ³¨ {@code @Transactional},
     * è¿™æ˜¯**必须保持**的前提:请勿把本方法挪到无事务的上下文中单独调用。
     *
     * @return {@code [0]=normal_next, [1]=reserved_next}
     */
    @Select("SELECT normal_next, reserved_next FROM lihu.seq_counter "
          + " WHERE check_type = #{checkType} AND seq_date = #{seqDate}")
    @Results({
        @Result(property = "normalNext", column = "normal_next"),
        @Result(property = "reservedNext", column = "reserved_next")
    })
    SeqCounterDO getSeqCounter(@Param("checkType") Integer checkType,
                               @Param("seqDate") java.time.LocalDate seqDate);
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/dal/mysql/room/RoomMapper.java
@@ -92,6 +92,17 @@
            "</script>")
    Integer setBedDoctorStatus(@Param("roomId")Long roomId, @Param("bedNo")String bedNo, @Param("docId")Long docId, @Param("newStatus") BedStatusEnum newStatus, @Param("curStatusList") List<BedStatusEnum> curStatusList);
    /**
     * ã€éœ€æ±‚3】设置工位的「停止候诊」开关(持久化,供 bedReload åŽæ¢å¤ï¼‰ã€‚
     *
     * @param stopReady 0=正常接收候诊患者;1=停止新增候诊患者
     * @return å—影响行数
     */
    @Update("update lihu.clinic_room set stop_ready = #{stopReady}, update_time = NOW() "
          + "where room_id = #{roomId} and bed_no = #{bedNo} and deleted = 0")
    Integer setBedStopReady(@Param("roomId")Long roomId, @Param("bedNo")String bedNo,
                            @Param("stopReady")Integer stopReady);
    @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)" +
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/enums/ActionTypeEnum.java
@@ -3,20 +3,55 @@
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
import java.util.List;
/**
 * Action类型枚举
 * HIS æŠ¥æ–‡ header ä¸­ action çš„取值枚举。
 *
 * <h3>关于 PACS / ECG çš„ action åŒºåˆ†</h3>
 * é™¢æ–¹ã€Šäº¤äº’服务通用接口文档》中,同一服务在不同系统下使用**不同的交易编号**:
 * <pre>
 *   6.4 æ£€æŸ¥é¢„约状态新增:S050401(pacs) / S050402(ECG)
 *   6.5 æ£€æŸ¥é¢„约状态更新:S050501(pacs) / S050502(ECG)
 * </pre>
 *
 * <p><b>实测结论(2026-09-17 æ—¥å¿—,共 1753 æ¡è¯·æ±‚)</b>:实际到达的 action åªæœ‰
 * {@code S0405}、{@code S050401}、{@code S0201ECG}、{@code S0202ECG}、
 * {@code S050501}、{@code S040501HIS} å…­ç§ï¼Œå…¶ä¸­
 * <b>{@code S050402} ä¸Ž {@code S050502} å‡ºçް 0 æ¬¡</b>。
 * å³é™¢æ–¹å½“前走的是 pacs ç¼–号,ECG ä¸“属编号尚未启用。
 *
 * <p>为了在院方切换编号时不丢消息,这里**同时注册两种编号**(主动兼容),
 * å¹¶é¢å¤–兼容申请单新增/更新的无后缀写法 {@code S0201} / {@code S0202}
 * ï¼ˆæ–‡æ¡£åªå®šä¹‰äº†æ— åŽç¼€å½¢å¼ï¼Œè€Œå®žæµ‹æŠ¥æ–‡å¸¦ {@code ECG} åŽç¼€ï¼‰ã€‚
 *
 * @author å¿ƒç”µæŽ’队装机系统
 */
@Getter
@AllArgsConstructor
public enum ActionTypeEnum {
    // ---------- 6.1 ç”³è¯·å•新增(文档为 S0201,实测报文为 S0201ECG,两种都兼容) ----------
    S0201ECG("S0201ECG", "申请单创建"),
    S0202ECG("S0202ECG", "申请单更新"),
    S040501HIS("S040501HIS", "预约更新"),
    S050401("S050401", "检查预约状态新增"),
    S0405("S0405", "申请单登记状态更新"),
    S050501("S050501", "检查预约状态修改");
    S0201("S0201", "申请单创建"),
    // ---------- 6.2 ç”³è¯·å•更新(文档为 S0202,实测报文为 S0202ECG) ----------
    S0202ECG("S0202ECG", "申请单更新"),
    S0202("S0202", "申请单更新"),
    // ---------- 6.3 ç”³è¯·å•状态更新(全院通用,不区分科室) ----------
    S040501HIS("S040501HIS", "预约更新"),
    S0405HIS("S0405HIS", "申请单状态更新"),
    S0405("S0405", "申请单登记状态更新"),
    // ---------- 6.4 æ£€æŸ¥é¢„约状态新增(pacs / ECG ä¸¤ç§ç¼–号都接收) ----------
    S050401("S050401", "检查预约状态新增"),   // pacs
    S050402("S050402", "检查预约状态新增"),   // ECG(文档定义,当前未启用,主动兼容)
    // ---------- 6.5 æ£€æŸ¥é¢„约状态更新(pacs / ECG ä¸¤ç§ç¼–号都接收) ----------
    S050501("S050501", "检查预约状态修改"),   // pacs
    S050502("S050502", "检查预约状态修改");   // ECG(文档定义,当前未启用,主动兼容)
    /**
     * ç±»åž‹
@@ -28,17 +63,79 @@
    private final String description;
    /**
     * æ ¹æ®ç±»åž‹èŽ·å–æžšä¸¾
     * æ ¹æ®ç±»åž‹èŽ·å–æžšä¸¾ã€‚
     * <p>
     * å…¼å®¹å¤„理:入参先去除首尾空白;若未命中,再尝试忽略大小写匹配,
     * é¿å…å›  HIS ä¾§å¤§å°å†™ä¸ä¸€è‡´ï¼ˆå¦‚ {@code s0201ecg})导致整条消息被拒。
     *
     * @param type ç±»åž‹
     * @return æžšä¸¾
     * @param type action å€¼
     * @return æžšä¸¾ï¼›æ— æ³•识别时返回 null
     */
    public static ActionTypeEnum getByType(String type) {
        if (type == null) {
            return null;
        }
        String trimmed = type.trim();
        if (trimmed.isEmpty()) {
            return null;
        }
        // 1. ç²¾ç¡®åŒ¹é…
        for (ActionTypeEnum value : values()) {
            if (value.getType().equals(type)) {
            if (value.type.equals(trimmed)) {
                return value;
            }
        }
        // 2. å¿½ç•¥å¤§å°å†™å…œåº•
        for (ActionTypeEnum value : values()) {
            if (value.type.equalsIgnoreCase(trimmed)) {
                return value;
            }
        }
        return null;
    }
    /** ç”³è¯·å•新增类 action(含无后缀兼容写法) */
    public static final List<ActionTypeEnum> APPLY_CREATE_ACTIONS =
            Arrays.asList(S0201ECG, S0201);
    /** ç”³è¯·å•æ›´æ–°ç±» action(含无后缀兼容写法) */
    public static final List<ActionTypeEnum> APPLY_UPDATE_ACTIONS =
            Arrays.asList(S0202ECG, S0202);
    /** ç”³è¯·å•状态更新类 action(全院通用) */
    public static final List<ActionTypeEnum> APPLY_STATUS_ACTIONS =
            Arrays.asList(S040501HIS, S0405HIS, S0405);
    /** æ£€æŸ¥é¢„约状态新增类 action(pacs + ECG) */
    public static final List<ActionTypeEnum> APPOINTMENT_STATUS_CREATE_ACTIONS =
            Arrays.asList(S050401, S050402);
    /** æ£€æŸ¥é¢„约状态更新类 action(pacs + ECG) */
    public static final List<ActionTypeEnum> APPOINTMENT_STATUS_UPDATE_ACTIONS =
            Arrays.asList(S050501, S050502);
    /** æ˜¯å¦ä¸ºç”³è¯·å•新增 */
    public boolean isApplyCreate() {
        return APPLY_CREATE_ACTIONS.contains(this);
    }
    /** æ˜¯å¦ä¸ºç”³è¯·å•æ›´æ–° */
    public boolean isApplyUpdate() {
        return APPLY_UPDATE_ACTIONS.contains(this);
    }
    /** æ˜¯å¦ä¸ºç”³è¯·å•状态更新(全院通用流,需要按心电业务过滤) */
    public boolean isApplyStatusUpdate() {
        return APPLY_STATUS_ACTIONS.contains(this);
    }
    /** æ˜¯å¦ä¸ºæ£€æŸ¥é¢„约状态新增 */
    public boolean isAppointmentStatusCreate() {
        return APPOINTMENT_STATUS_CREATE_ACTIONS.contains(this);
    }
    /** æ˜¯å¦ä¸ºæ£€æŸ¥é¢„约状态更新 */
    public boolean isAppointmentStatusUpdate() {
        return APPOINTMENT_STATUS_UPDATE_ACTIONS.contains(this);
    }
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/enums/AppointmentTypeEnum.java
@@ -4,41 +4,66 @@
import lombok.Getter;
/**
 * Action类型枚举
 * æ£€æŸ¥çŠ¶æ€ç¼–ç æžšä¸¾ã€‚
 *
 * <h3>修正说明</h3>
 * åŽŸå®žçŽ°å­˜åœ¨ä¸¤å¤„é—®é¢˜ï¼Œå·²ä¿®æ­£ï¼š
 * <ol>
 *   <li><b>字面值含空白污染</b>:原定义为 {@code SQDKL("   1", ...)}、
 *       {@code JCDJ("  3\t", ...)},type å€¼å‰æœ‰ç©ºæ ¼ã€éƒ¨åˆ†å«åˆ¶è¡¨ç¬¦ï¼Œ
 *       å¯¼è‡´ {@link #getByType(String)} å¯¹æ­£å¸¸ä¼ å…¥çš„ {@code "1"}、{@code "3"}
 *       åŒ¹é…å¤±è´¥ï¼ˆè¿”回 null)。现已去除全部空白。</li>
 *   <li><b>包含病理专用状态</b>:原表含 21 æ ‡æœ¬é€æ£€ã€22 å–消标本送检、23 å·²ç¼´è´¹ï¼Œ
 *       è¿™äº›å±žç—…理科业务,心电系统不涉及,已移除。</li>
 * </ol>
 *
 * <p>编码值与《检查状态编码表》一致。状态的**归一化**(去前导零、校验合法性)
 * è¯·ä½¿ç”¨ {@link HisCheckStatusEnum#normalize(String)},本枚举只负责
 * ç±»åž‹å€¼åˆ°åç§°çš„展示映射。
 *
 * @author å¿ƒç”µæŽ’队装机系统
 */
@Getter
@AllArgsConstructor
public enum AppointmentTypeEnum {
    SQDKL("   1", "    ç”³è¯·å•开立"),
    SQDCX("  2", "申请单撤销"),
    JCDJ("  3    ", "检查登记"),
    QXJC("  4", "    å–消检查"),
    JCKS("  5", "    æ£€æŸ¥å¼€å§‹"),
    GZYJ("  6", "    ç»™é€ å½±å‰‚"),
    JCJS("  7", "    æ£€æŸ¥ç»“束"),
    BGSC("  8", "    æŠ¥å‘Šç”Ÿæˆ"),
    BGCX("  9", "    æŠ¥å‘Šæ’¤é”€"),
    BGSH("  10", "    æŠ¥å‘Šå®¡æ ¸");
    SQDKL("1", "申请单开立"),
    SQDCX("2", "申请单撤销"),
    JCDJ("3", "检查登记"),
    QXJC("4", "取消检查"),
    JCKS("5", "检查开始"),
    GZYJ("6", "给造影剂"),
    JCJS("7", "检查结束"),
    BGSC("8", "报告生成"),
    BGCX("9", "报告撤销"),
    BGSH("10", "报告审核");
    /**
     * ç±»åž‹
     * çŠ¶æ€ç¼–ç ï¼ˆæ ‡å‡†å€¼ï¼Œæ— ç©ºç™½ã€æ— å‰å¯¼é›¶ï¼‰
     */
    private final String type;
    /**
     * æè¿°
     * çŠ¶æ€åç§°
     */
    private final String description;
    /**
     * æ ¹æ®ç±»åž‹èŽ·å–æžšä¸¾
     * æ ¹æ®çŠ¶æ€ç¼–ç èŽ·å–åç§°ã€‚
     * <p>
     * å…¥å‚会先做归一化处理(去空白、去前导零),因此传入
     * {@code "3"}、{@code "03"}、{@code " 3 "} å‡å¯æ­£ç¡®åŒ¹é…ã€‚
     *
     * @param type ç±»åž‹
     * @return æžšä¸¾
     * @param type çŠ¶æ€ç¼–ç 
     * @return çŠ¶æ€åç§°ï¼›æ— æ³•è¯†åˆ«æ—¶è¿”å›ž null
     */
    public static String getByType(String type) {
        String normalized = HisCheckStatusEnum.normalize(type);
        if (normalized == null) {
            return null;
        }
        for (AppointmentTypeEnum value : values()) {
            if (value.getType().equals(type)) {
                return value.getDescription();
            if (value.type.equals(normalized)) {
                return value.description;
            }
        }
        return null;
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/appointment/AppointmentServiceImpl.java
@@ -14,9 +14,11 @@
import cn.lihu.jh.module.ecg.enums.AppointmentTypeEnum;
import cn.lihu.jh.module.ecg.enums.HisCheckCodeEnum;
import cn.lihu.jh.module.ecg.enums.HisCheckNameEnum;
import cn.lihu.jh.module.ecg.enums.HisCheckStatusEnum;
import cn.lihu.jh.module.ecg.feign.RemoteDataService;
import cn.lihu.jh.module.ecg.service.config.EcgConfigService;
import cn.lihu.jh.module.ecg.service.devrent.ApplicationTemplate;
import cn.lihu.jh.module.ecg.service.his.HisEcgFilter;
import cn.lihu.jh.module.ecg.service.queue.QueueService;
import cn.lihu.jh.module.ecg.service.queuesequence.QueueSequenceService;
import cn.lihu.jh.module.ecg.webservice.WebServiceClient;
@@ -52,6 +54,16 @@
@Slf4j
public class AppointmentServiceImpl implements AppointmentService {
    /**
     * æ‚£è€…性别「未知」的兜底值。
     * <p>
     * {@code appointment.pat_gender} åœ¨æ•°æ®åº“中为 NOT NULL ä¸”无默认值
     * ï¼ˆå®žæµ‹ï¼š{@code Field 'pat_gender' doesn't have a default value})。
     * è€Œ {@code S0405} ç­‰æŠ¥æ–‡çš„æ€§åˆ«å­—段路径与 {@code S0201ECG} ä¸åŒã€ä¸”可能缺失,
     * å®¹é”™æ–°å¢žæ—¶è‹¥å–不到,按 0(未知)写入,避免插入失败。
     */
    private static final Byte UNKNOWN_GENDER = (byte) 0;
    @Resource
    private EcgConfigService ecgConfigService;
@@ -66,6 +78,15 @@
    @Resource
    private AppointmentMapper appointmentMapper;
    /**
     * å¿ƒç”µä¸šåŠ¡å•åˆ¤å®šå™¨ã€‚
     * <p>
     * ç”¨äºŽ {@code S0405}(全院通用状态流)的过滤:非心电单只记日志、不落库,
     * é¿å…å‘ appointment è¡¨çŒå…¥æ”¾å°„/超声等其他科室的数据。
     */
    @Resource
    private HisEcgFilter hisEcgFilter;
    @Autowired
    private WebServiceClient webServiceClient;
@@ -302,12 +323,19 @@
        if (!DateUtils.isToday(appointment.getBookDate())) throw exception(APPOINTMENT_NOT_TODAY);
        Integer newSeqNo = null;
        if (confirmReqVO.getIsVip() == 0) {
            newSeqNo = queueSequenceService.distributeSeqNo(confirmReqVO.getBookCheckType(), confirmReqVO.getBookTimeslot());
        } else {
            newSeqNo = queueSequenceService.distributeVipSeqNo(confirmReqVO.getBookCheckType(), confirmReqVO.getBookTimeslot());
        }
        // ã€éœ€æ±‚:排队序号规则】按 check_type.seq_rule å†³å®šå–号方式:
        //   0=预约号(用预约系统已下发的号;当前 HIS æœªä¸‹å‘,会自动回退到签到序号)
        //   1=签到序号(当天同检查类型连续递增,每 9 ä¸ªé¢„ç•™ 1 ä¸ªç»™åŠ æ€¥ï¼‰
        //   2=时段号(按 bookTimeslot ä»Žå·æ± å–号 â€”— æ”¹é€ å‰çš„原有行为,也是未配置时的默认)
        boolean urgent = isHisUrgent(appointment);
        Integer newSeqNo = queueSequenceService.distributeSeqNoByRule(
                confirmReqVO.getBookCheckType(),
                confirmReqVO.getBookTimeslot(),
                appointment.getBookSeqNum(),
                urgent);
        log.info("[appoitmentConfirm][分配排队序号] appointmentId={} checkType={} timeslot={} è§„则取号={} åŠ æ€¥={}",
                appointment.getId(), confirmReqVO.getBookCheckType(), confirmReqVO.getBookTimeslot(),
                newSeqNo, urgent);
        try {
            QueueSaveReqVO queueSaveReqVO = new QueueSaveReqVO();
@@ -324,6 +352,15 @@
            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 å³ä¼˜å…ˆã€‚
            if (urgent) {
                queueSaveReqVO.setJumpFlag((byte) 1);
                log.info("[appoitmentConfirm][HIS åŠ æ€¥æ‚£è€…ï¼ŒæŽ’é˜Ÿæ ‡è®°ä¸ºåŠ æ€¥] applyNo={} patId={}",
                        appointment.getApplyNo(), appointment.getPatId());
            }
            queueSaveReqVO.setPatDetails(getPatDetails(appointment));
            queueService.queue(queueSaveReqVO);
        } catch (DuplicateKeyException duplicateKeyException) {
@@ -333,8 +370,27 @@
        return newSeqNo;
    }
    private PatDetails getPatDetails(AppointmentDO appointment) {
        PatDetails patDetails = new PatDetails();
    /**
     * åˆ¤æ–­è¯¥é¢„约是否为 HIS æ ‡è®°çš„加急单。
     * <p>
     * ä¾æ® {@code appointment.jjbz}(来源 HIS æŠ¥æ–‡ {@code zdy.jjbz}):
     * {@code J} / {@code 1} / {@code Y} è§†ä¸ºåŠ æ€¥ï¼Œå…¶ä½™ï¼ˆå¦‚ {@code N})为常规。
     * <p>
     * <b>注意</b>:实测 2026-09-17 æ—¥å¿—中 60 æ¡ S0201ECG çš„ {@code jjbz} **全为 N**,
     * æœªå–å¾— {@code J} çš„真实样例,因此该分支尚未经过真实报文验证。
     *
     * @param appointment é¢„约记录,可为 null
     * @return true=加急
     */
    private boolean isHisUrgent(AppointmentDO appointment) {
        if (appointment == null || StringUtils.isEmpty(appointment.getJjbz())) {
            return false;
        }
        String flag = appointment.getJjbz().trim();
        return "J".equalsIgnoreCase(flag) || "1".equals(flag) || "Y".equalsIgnoreCase(flag);
    }
    private PatDetails getPatDetails(AppointmentDO appointment) {        PatDetails patDetails = new PatDetails();
        patDetails.setAppointmentId(appointment.getId());
        patDetails.setId(appointment.getPatId());
        patDetails.setName(appointment.getPatName());
@@ -376,6 +432,64 @@
                }
                return null;
            }).ifPresent(appointment::setTolerance);
            // æ˜¯å¦åºŠè¾¹ï¼ˆéœ€æ±‚2):zdy.ISCB。0=否,1=是。
            //
            // ä¸Ž ISDN åŒåœ¨ zdy è‡ªå®šä¹‰å­—段区,且**同属官方文档未定义的 7 ä¸ªæ‰©å±•字段**
            // ï¼ˆå®žæµ‹çœŸå®žæŠ¥æ–‡ 26 ä¸ªå­—段,文档只定义了 19 ä¸ªï¼‰ã€‚
            // è§£æžé€»è¾‘抽到 findBedsideFlag,便于单元测试。
            String bedsideFlag = findBedsideFlag(dataMap);
            if (bedsideFlag != null) {
                appointment.setIsBedside(bedsideFlag);
            }
            // ä¼˜å…ˆçº§/加急标记(zdy.jjbz:N=常规,J=加急)
            Map<String, Object> zdyNode = dig(dataMap, "controlActProcess", "subject",
                    "observationRequest", "zdy");
            if (zdyNode != null) {
                Object jjbz = zdyNode.get("jjbz");
                if (jjbz != null && StringUtils.isNotEmpty(jjbz.toString().trim())) {
                    appointment.setJjbz(jjbz.toString().trim());
                }
            }
            // æ£€æŸ¥æ–¹å¼ç¼–码:methodCode.item[0].code
            // ç±»åž‹å®‰å…¨ï¼šmethodCode çš„ item å¯èƒ½ä¸å­˜åœ¨ï¼Œå¯èƒ½æ˜¯å•个 Map,也可能是 List
            Map<String, Object> c2 = dig(dataMap, "controlActProcess", "subject",
                    "observationRequest", "component2", "observationRequest");
            if (c2 != null) {
                String methodCode = digText(c2, "methodCode", "item", "code");
                if (StringUtils.isEmpty(methodCode)) {
                    // ä¼  List æ—¶ digText ä¼šè‡ªåŠ¨å–é¦–å…ƒç´ å†ä¸‹é’»ï¼Œè¿™é‡Œå†å…œä¸€æ¬¡ code
                    methodCode = digText(c2, "methodCode", "item", "part", "value");
                }
                if (StringUtils.isNotEmpty(methodCode)) {
                    appointment.setMethodCode(methodCode);
                }
                String targetSite = digText(c2, "targetSiteCode", "item", "code");
                if (StringUtils.isNotEmpty(targetSite)) {
                    appointment.setTargetSiteCode(targetSite);
                }
                // åŒ»å˜±å·ï¼šcomponent2.observationRequest.id.item[root=2.16.156.10011.1.28].extension
                //
                // ä¾æ®ï¼ˆHIS《交互服务通用接口文档》):
                //   Â· 6.1 ç”³è¯·å•新增服务(本项目所用 S0201ECG)的示例中该节点注释即为「医嘱ID」:
                //       <id><!--医嘱ID--><item root="2.16.156.10011.1.28" extension=""/></id>
                //   Â· 6.4/6.5 æ£€æŸ¥é¢„约状态服务的示例中同样注释为「医嘱号」:
                //       <!--  åŒ»å˜±å·  --> ... <id root="2.16.156.10011.1.28" extension="11"/>
                //   å®žæµ‹ S0201ECG 220 æ¡ã€S0202ECG 29 æ¡**均携带该值且无空值**
                //   ï¼ˆå½¢å¦‚ 112663505 / 112663700,且同一医嘱在新增与更新报文中取值一致)。
                //
                // ã€ä¸ºä»€ä¹ˆä¸ç”¨ digText ç›´æŽ¥å– item.extension】
                //   digText é‡åˆ° List **只取首元素**;而 id.item åœ¨éƒ¨åˆ†æœåŠ¡ä¸­æ˜¯æ•°ç»„
                //   ï¼ˆå¦‚ 6.4 åŒæ—¶å« 1.24 ç”³è¯·å•号与 1.28 åŒ»å˜±å·ï¼‰ã€‚æ•…å¿…é¡»**按 root åŒ¹é…**,
                //   å¦åˆ™å¯èƒ½æŠŠç”³è¯·å•号当成医嘱号。
                String doctorAdviceNo = findExtensionByRoot(c2.get("id"), ORDER_NO_ROOT);
                if (StringUtils.isNotEmpty(doctorAdviceNo)) {
                    appointment.setDoctorAdviceNo(doctorAdviceNo);
                }
            }
            // åŒ»ç”Ÿå¼€å•æ—¶é—´
            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 -> {
@@ -453,13 +567,29 @@
//            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));
            String checkName = 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 -> (Map<String, Object>) code.get("displayName")).map(displayName -> (String) displayName.get("value")).orElseThrow(() -> exception(APPOINTMENT_CREATE_FAIL));
            if (appointment.getPatSrc() != null && appointment.getPatSrc() != 4) {
                if (checkType != null || checkType != "")
                    appointment.setBookCheckType(Integer.valueOf(HisCheckCodeEnum.getByCode(checkType.substring(1))));
            } else if (appointment.getPatSrc() != null && appointment.getPatSrc() == 4) {
                if (StringUtils.isNotEmpty(checkName)) {
                    checkName = checkName.replaceAll("\\s+", "");
                    appointment.setBookCheckType(Integer.valueOf(HisCheckNameEnum.getByName(checkName)));
            // ä¾æ®æ£€æŸ¥ç±»åž‹è§£æžæœ¬åœ° book_check_type。
            // ä¿®æ­£ç‚¹ï¼ˆåŽŸå®žçŽ°æœ‰ç¼ºé™·ï¼‰ï¼š
            //   1. åˆ¤æ–­å†™æˆ `checkType != null || checkType != ""`,恒为 true;
            //   2. patSrc ä¸º Integer,`!= 4` åœ¨è¶…出 Integer ç¼“存范围时会失效,改用 Objects.equals;
            //   3. HIS ä»£ç å¯èƒ½æ˜ å°„不到,直接 Integer.valueOf(null) ä¼šæŠ›
            //      NumberFormatException(实测日志中有 "Cannot parse null string")。
            if (Objects.equals(appointment.getPatSrc(), 4)) {
                String normalizedName = StringUtils.isNotEmpty(checkName)
                        ? checkName.replaceAll("\\s+", "") : null;
                String mapped = StringUtils.isNotEmpty(normalizedName)
                        ? HisCheckNameEnum.getByName(normalizedName) : null;
                Integer value = parseIntegerQuietly(mapped);
                if (value != null) {
                    appointment.setBookCheckType(value);
                } else {
                    log.warn("[handleAppointmentCreate][体检患者检查项目名称无法映射] checkName={}", checkName);
                }
            } else {
                Integer value = resolveBookCheckTypeFromComponent2(dataMap);
                if (value != null) {
                    appointment.setBookCheckType(value);
                } else {
                    log.warn("[handleAppointmentCreate][HIS æ£€æŸ¥ä»£ç æ— æ³•映射到本地检查类型] checkType={}", checkType);
                }
            }
@@ -487,16 +617,84 @@
            // å°†AppointmentDO转换为AppointmentSaveReqVO并保存
            AppointmentSaveReqVO saveReqVO = BeanUtils.toBean(appointment, AppointmentSaveReqVO.class);
            //根据申请编号先查询一下是否存在,如果存在,就不新增了
            // æŒ‰ç”³è¯·å•号检查是否已存在。
            // ã€æœ¬æ¬¡æ”¹åЍ Q5】原实现命中已存在时直接 return(不更新),
            // ä¸Ž S0202ECG / S0405 ä¸¤æ¡æ›´æ–°æµçš„"找不到则兜底新增"方向相反,语义不统一。
            // çŽ°æ”¹ä¸ºï¼šå‘½ä¸­å·²å­˜åœ¨åˆ™ä»¥æœ¬æ¬¡æŠ¥æ–‡å†…å®¹æ‰§è¡Œæ›´æ–°ï¼Œé¿å… HIS é‡å¤æŽ¨é€æ—¶æ—§æ•°æ®ä¸è¢«ä¿®æ­£ã€‚
            AppointmentDO appointmentDO = appointmentMapper.getByApplyNo(saveReqVO.getApplyNo());
            log.info("=======是否新增过了:{}", ObjectUtils.isNotEmpty(appointmentDO) ? appointmentDO.getId() : null);
            if (ObjectUtils.isNotEmpty(appointmentDO)) return appointmentDO.getId();
            log.info("[handleAppointmentCreate][是否已存在] applyNo={} existingId={}",
                    saveReqVO.getApplyNo(), appointmentDO == null ? null : appointmentDO.getId());
            if (appointmentDO != null) {
                // ä¿ç•™åŽŸè®°å½•ä¸­æœ¬æ¬¡æŠ¥æ–‡æœªæä¾›çš„å­—æ®µï¼Œé¿å…è¢«ç©ºå€¼è¦†ç›–
                mergeUnsetFields(appointment, appointmentDO);
                saveReqVO = BeanUtils.toBean(appointment, AppointmentSaveReqVO.class);
                saveReqVO.setId(appointmentDO.getId());
                updateAppointment(saveReqVO);
                return appointmentDO.getId();
            }
            return createAppointment(saveReqVO);
        } catch (Exception e) {
            log.error("[handleAppointmentCreate][处理预约创建失败 dataMap({})]", dataMap, e);
            throw exception(APPOINTMENT_CREATE_FAIL);
        }
    }
    /**
     * æ›´æ–°å‰åˆå¹¶ï¼šæœ¬æ¬¡æŠ¥æ–‡æœªæä¾›ï¼ˆä¸º null)的字段,沿用数据库中的原值。
     * <p>
     * ç›®çš„:{@code S0201ECG} çš„æŸäº›å­—段在部分场景下会缺失(例如体检患者无预约时间、
     * åºŠå¤´å±æ¶ˆæ¯æ— ç§‘室信息)。若直接以报文内容整体覆盖,会把已有数据清空。
     * è¿™æ˜¯"部分更新"语义,与更新流的 {@code ifPresent} é€å­—段赋值保持一致。
     * <p>
     * æ³¨æ„ï¼šåœ¨ {@link AppointmentDO} å±‚合并(而非 VO å±‚),因为
     * {@code AppointmentSaveReqVO} ä¸Ž DO çš„字段类型并不一致
     * ï¼ˆVO çš„ {@code bookDate/patBirthday} ä¸º {@code LocalDateTime}、
     * {@code registerDate/doctorTime} ä¸º {@code java.sql.Date};
     * DO å¯¹åº”为 {@code LocalDate} / {@code java.util.Date})。
     *
     * @param target æœ¬æ¬¡æŠ¥æ–‡è§£æžå‡ºçš„对象(会被就地补全)
     * @param source æ•°æ®åº“中已有的记录
     */
    private void mergeUnsetFields(AppointmentDO target, AppointmentDO source) {
        if (target.getApplyNo() == null) target.setApplyNo(source.getApplyNo());
        if (target.getEpisodeId() == null) target.setEpisodeId(source.getEpisodeId());
        if (target.getPatSrc() == null) target.setPatSrc(source.getPatSrc());
        if (target.getPatId() == null) target.setPatId(source.getPatId());
        if (target.getPatName() == null) target.setPatName(source.getPatName());
        if (target.getPatGender() == null) target.setPatGender(source.getPatGender());
        if (target.getPatBirthday() == null) target.setPatBirthday(source.getPatBirthday());
        if (target.getPatMobile() == null) target.setPatMobile(source.getPatMobile());
        if (target.getPatPhone() == null) target.setPatPhone(source.getPatPhone());
        if (target.getPatIdentityId() == null) target.setPatIdentityId(source.getPatIdentityId());
        if (target.getPatAddr() == null) target.setPatAddr(source.getPatAddr());
        if (target.getPatDeptCode() == null) target.setPatDeptCode(source.getPatDeptCode());
        if (target.getPatDeptDesc() == null) target.setPatDeptDesc(source.getPatDeptDesc());
        if (target.getPatWardCode() == null) target.setPatWardCode(source.getPatWardCode());
        if (target.getPatWardDesc() == null) target.setPatWardDesc(source.getPatWardDesc());
        if (target.getPatBedNo() == null) target.setPatBedNo(source.getPatBedNo());
        if (target.getBookDate() == null) target.setBookDate(source.getBookDate());
        if (target.getBookTimeslot() == null) target.setBookTimeslot(source.getBookTimeslot());
        if (target.getBookTime() == null) target.setBookTime(source.getBookTime());
        if (target.getBookCheckType() == null) target.setBookCheckType(source.getBookCheckType());
        if (target.getBookSrc() == null) target.setBookSrc(source.getBookSrc());
        if (target.getPaid() == null) target.setPaid(source.getPaid());
        if (target.getStatus() == null) target.setStatus(source.getStatus());
        if (target.getRegisterDate() == null) target.setRegisterDate(source.getRegisterDate());
        if (target.getTolerance() == null) target.setTolerance(source.getTolerance());
        if (target.getHospitalNo() == null) target.setHospitalNo(source.getHospitalNo());
        if (target.getMedicalCard() == null) target.setMedicalCard(source.getMedicalCard());
        if (target.getOutpatientNo() == null) target.setOutpatientNo(source.getOutpatientNo());
        if (target.getDoctor() == null) target.setDoctor(source.getDoctor());
        if (target.getDoctorCode() == null) target.setDoctorCode(source.getDoctorCode());
        if (target.getDoctorTime() == null) target.setDoctorTime(source.getDoctorTime());
        // â€”— HIS è¡¥å……字段(随表新增列一起加入,保持"部分更新不清空"语义)——
        if (target.getJjbz() == null) target.setJjbz(source.getJjbz());
        if (target.getMethodCode() == null) target.setMethodCode(source.getMethodCode());
        if (target.getTargetSiteCode() == null) target.setTargetSiteCode(source.getTargetSiteCode());
        if (target.getRoomName() == null) target.setRoomName(source.getRoomName());
        if (target.getRemark() == null) target.setRemark(source.getRemark());
        if (target.getBookSeqNum() == null) target.setBookSeqNum(source.getBookSeqNum());
    }
    @Override
@@ -589,10 +787,13 @@
                }
                return null;
            }).ifPresent(existingAppointment::setPaid);
            //设置检查类型
            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));
            if (checkType != null || checkType != "")
                existingAppointment.setBookCheckType(Integer.valueOf(HisCheckCodeEnum.getByCode(checkType.substring(1))));
            // è®¾ç½®æ£€æŸ¥ç±»åž‹
            Integer checkTypeValue = resolveBookCheckTypeFromComponent2(dataMap);
            if (checkTypeValue != null) {
                existingAppointment.setBookCheckType(checkTypeValue);
            } else {
                log.warn("[handleAppointmentUpdate][未能解析检查类型,保留原值] applyNo={}", applyNo);
            }
            // å°†AppointmentDO转换为AppointmentSaveReqVO并更新
            AppointmentSaveReqVO updateReqVO = BeanUtils.toBean(existingAppointment, AppointmentSaveReqVO.class);
@@ -603,111 +804,892 @@
        }
    }
    /**
     * ä»ŽæŠ¥æ–‡ {@code component2.observationRequest.code.code} è§£æžæœ¬åœ°æ£€æŸ¥ç±»åž‹å€¼ã€‚
     *
     * <h3>修正的缺陷</h3>
     * åŽŸå®žçŽ°ä¸ºï¼š
     * <pre>
     *   if (checkType != null || checkType != "")            // æ’为 true,且逻辑应为 &amp;&amp;
     *       setBookCheckType(Integer.valueOf(
     *           HisCheckCodeEnum.getByCode(checkType.substring(1))));  // æ— é•¿åº¦æ ¡éªŒ
     * </pre>
     * å­˜åœ¨ä¸‰å¤„问题:
     * <ol>
     *   <li>{@code ||} ä½¿åˆ¤æ–­æ’真;</li>
     *   <li>{@code substring(1)} ç›´æŽ¥æˆªæ–­ï¼ŒæŠ¥æ–‡å€¼é•¿åº¦ä¸è¶³æ—¶æŠ›
     *       {@code StringIndexOutOfBoundsException}(实测日志中有类似异常);</li>
     *   <li>{@code HisCheckCodeEnum.getByCode()} å¯èƒ½è¿”回 null,
     *       éšåŽ {@code Integer.valueOf(null)} æŠ› {@code NumberFormatException}
     *       ï¼ˆå®žæµ‹æ—¥å¿—中确有 {@code Cannot parse null string})。</li>
     * </ol>
     * çŽ°æ”¹ä¸ºï¼šHIS ä»£ç å…¼å®¹å¸¦/不带 {@code Y} å‰ç¼€ï¼ˆå¯¹åº”原 substring(1) è¯­ä¹‰ï¼‰ï¼Œ
     * ä¸”仅在能映射到本地 {@code check_type} å€¼æ—¶æ‰è¿”回,否则返回 null ç”±è°ƒç”¨æ–¹ä¿ç•™åŽŸå€¼ã€‚
     *
     * @return æœ¬åœ°æ£€æŸ¥ç±»åž‹å€¼ï¼ˆå¦‚ 1000);无法解析时返回 null
     */
    private Integer resolveBookCheckTypeFromComponent2(Map<String, Object> dataMap) {
        // ç±»åž‹å®‰å…¨ï¼šobservationRequest.code å¯èƒ½æ˜¯ Map,也可能是空字符串
        String hisCode = digText(dataMap, "controlActProcess", "subject", "observationRequest",
                "component2", "observationRequest", "code", "code");
        if (StringUtils.isEmpty(hisCode)) {
            return null;
        }
        String raw = hisCode.trim().replaceAll("\\s+", "");
        // å…¼å®¹ï¼šHIS æŠ¥æ–‡å€¼å½¢å¦‚ "Y1202065",而枚举 code ä¸ºåŽ»æŽ‰é¦–å­—ç¬¦åŽçš„ "1202065";
        // åŒæ—¶ä¹Ÿå®¹å¿æŠ¥æ–‡æœ¬èº«ä¸å¸¦å‰ç¼€
        String[] candidates = raw.length() > 1
                ? new String[]{raw, raw.substring(1)}
                : new String[]{raw};
        for (String candidate : candidates) {
            String mapped = HisCheckCodeEnum.getByCode(candidate);
            if (StringUtils.isNotEmpty(mapped)) {
                return parseIntegerQuietly(mapped);
            }
        }
        log.warn("[resolveBookCheckTypeFromComponent2][HIS æ£€æŸ¥ä»£ç æ— æ³•映射到本地检查类型] hisCode={}", hisCode);
        return null;
    }
    /**
     * ç”³è¯·å•状态更新(action: {@code S0405} / {@code S0405HIS} / {@code S040501HIS})。
     *
     * <h3>⚠️ è¯¥æœåŠ¡æ˜¯å…¨é™¢é€šç”¨çš„</h3>
     * æŽ¥å£æ–‡æ¡£ 6.3 å®šä¹‰å…¶ action ä¸º {@code S0405|S0405HIS|S040501HIS},**不区分科室**。
     * åŒ»é™¢ä¼šæŠŠæ‰€æœ‰æ£€æŸ¥ç§‘室(CT、MRI、DR、超声、骨密度…)的检查状态一并推送。
     * å®žæµ‹ 2026-09-17:本服务收到 1225 æ¬¡è¯·æ±‚,其中仅 3.5% çš„申请单号属心电业务,
     * å…¶ä½™ä¸ºæ”¾å°„/超声等科室的单(如 {@code 2126091700001} èƒ¸éƒ¨CT平扫 / æ€¥è¯ŠCT室)。
     *
     * <h3>处理策略</h3>
     * <ol>
     *   <li>提取申请单号与检查状态码,并对状态码做**归一化**(去前导零、校验合法性)</li>
     *   <li>查本地预约记录:
     *     <ul>
     *       <li><b>存在</b> â†’ æ›´æ–°çŠ¶æ€ï¼›çŠ¶æ€ä¸º 3(检查登记)时同时写入登记时间</li>
     *       <li><b>不存在 + æ˜¯å¿ƒç”µå•</b> â†’ è®° WARN å¹¶**插入新记录**(Q4 è¦æ±‚容错新增)</li>
     *       <li><b>不存在 + éžå¿ƒç”µå•</b> â†’ ä»…è®° DEBUG æ—¥å¿—,**不插入**(避免灌入无关数据)</li>
     *     </ul>
     *   </li>
     * </ol>
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void handleAppointmentStateUpdate(Map<String, Object> dataMap) {
        if (dataMap == null) {
            throw exception(APPOINTMENT_STATUS_UPDATE_FAIL);
        }
        // ä»ŽdataMap中提取申请单号
        String applyNo = 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("placerGroup")).map(placerGroup -> (Map<String, Object>) placerGroup.get("component2")).map(component2 -> (Map<String, Object>) component2.get("observationRequest")).map(observationRequest -> (Map<String, Object>) observationRequest.get("id")).map(id -> (Map<String, Object>) id.get("item")).map(item -> (String) item.get("extension")).orElseThrow(() -> exception(APPOINTMENT_CREATE_FAIL));
        // æŸ¥è¯¢çŽ°æœ‰é¢„çº¦è®°å½•
        // 1. æå–申请单号(类型安全导航)
        String applyNo = digText(dataMap, "controlActProcess", "subject", "placerGroup",
                "component2", "observationRequest", "id", "item", "extension");
        if (StringUtils.isEmpty(applyNo)) {
            log.warn("[handleAppointmentStateUpdate][报文缺少申请单号,忽略] dataMap={}", dataMap);
            throw exception(HIS_APPLY_NO_MISSING);
        }
        // 2. æå–并归一化检查状态码
        //    ç±»åž‹å®‰å…¨ï¼šprocessStep.code åœ¨éƒ¨åˆ†æŠ¥æ–‡ä¸­æ˜¯ Map(code={code=7,...}),
        //    åœ¨å¦ä¸€äº›æŠ¥æ–‡ä¸­å¯èƒ½æ˜¯ç©ºå­—符串,直接用链式强转会抛 ClassCastException
        String rawStatus = digText(dataMap, "controlActProcess", "subject", "placerGroup",
                "component2", "observationRequest", "component1", "processStep", "code", "code");
        String status = HisCheckStatusEnum.normalize(rawStatus);
        if (status == null) {
            // å®žæµ‹æŠ¥æ–‡ä¸­è¯¥å€¼å­˜åœ¨ "3" / "03" / "7" / "08" / "10" ç­‰ä¸åŒå†™æ³•,
            // å½’一化后仍无法识别说明不是编码表内的状态(如病理专用 21/22/23)
            log.warn("[handleAppointmentStateUpdate][检查状态编码非法或不在编码表内,忽略] applyNo={} rawStatus={}",
                    applyNo, rawStatus);
            throw exception(HIS_STATUS_INVALID);
        }
        // 3. æœ¬åœ°è®°å½•匹配
        AppointmentDO existingAppointment = appointmentMapper.getByApplyNo(applyNo);
        // å¦‚果记录不存在,则调用handleAppointmentCreate进行新增
        if (existingAppointment == null) {
            handleAppointmentCreate(dataMap);
        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);
            return;
        }
        // ä»ŽdataMap中提取申请单号
//        String status = 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("placerGroup")).map(placerGroup -> (Map<String, Object>) placerGroup.get("component2")).map(component2 -> (Map<String, Object>) component2.get("observationRequest")).map(observationRequest -> (Map<String, Object>) observationRequest.get("component1")).map(component1 -> (Map<String, Object>) component1.get("processStep")).map(processStep -> (Map<String, Object>) processStep.get("code")).map(code -> (Map<String, Object>) code.get("displayName")).map(displayName -> (String) displayName.get("value")).orElseThrow(() -> exception(APPOINTMENT_CREATE_FAIL));
        String status = 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("placerGroup")).map(placerGroup -> (Map<String, Object>) placerGroup.get("component2")).map(component2 -> (Map<String, Object>) component2.get("observationRequest")).map(observationRequest -> (Map<String, Object>) observationRequest.get("component1")).map(component1 -> (Map<String, Object>) component1.get("processStep")).map(processStep -> (Map<String, Object>) processStep.get("code")).map(code -> (String) code.get("code")).orElseThrow(() -> exception(APPOINTMENT_CREATE_FAIL));
        existingAppointment.setStatus(status);
        if ("3".equals(status)) {
            existingAppointment.setRegisterDate(new Date());
        // 4. è®°å½•不存在:先判定是否心电业务单,非心电单只记日志、不插入
        //
        //    S0405 æ˜¯å…¨é™¢é€šç”¨æµã€‚可用的心电判定依据(按实测报文):
        //      A. apply_no åŽç¼€ï¼ˆ1101/6401)
        //      B. component2.observationRequest.component1.processStep.code.ZDY
        //         ä¸­çš„ jcxmcode / jcxmname(实测 1138/1225 æ¡å¸¦è¯¥å­—段,
        //         å½¢å¦‚ jcxmname=胸部CT平扫,上腹部CT平扫 / roomname=急诊CT室)
        //      C. æ‰§è¡Œç§‘室(需配置白名单)
        Map<String, Object> zdyFromS0405 = extractZdyFromStateMessage(dataMap);
        String s0405CheckCode = extractZdyValueFromMap(zdyFromS0405, "jcxmcode");
        String s0405CheckName = extractZdyValueFromMap(zdyFromS0405, "jcxmname");
        String hisDeptCode = extractDeptCodeFromComponent2(dataMap);
        if (!hisEcgFilter.isEcgOrder("S0405", applyNo, s0405CheckCode, s0405CheckName, hisDeptCode)) {
            // å…¨é™¢æµä¸­çš„其他科室单据,静默跳过(DEBUG æ—¥å¿—ç”± filter è¾“出)
            return;
        }
        // å°†AppointmentDO转换为AppointmentSaveReqVO并更新
        AppointmentSaveReqVO updateReqVO = BeanUtils.toBean(existingAppointment, AppointmentSaveReqVO.class);
        updateAppointment(updateReqVO);
    }
        // 5. æ˜¯å¿ƒç”µå•但本地无记录 â†’ å®¹é”™æ–°å¢žï¼ˆQ4 è¦æ±‚)
        log.warn("[handleAppointmentStateUpdate][心电业务单但本地无记录,执行容错新增] applyNo={} status={} dept={}",
                applyNo, status, hisDeptCode);
        insertAppointmentFromStateMessage(applyNo, status, dataMap);
    }
    /**
     * ä»ŽdataMap中提取encounter数据
     * ä»Ž S0405 æŠ¥æ–‡ä¸­æž„造并插入一条预约记录。
     *
     * <h3>⚠️ å·²çŸ¥çš„æ•°æ®ç¼ºå¤±</h3>
     * {@code S0405} æŠ¥æ–‡**不包含检查项目信息**(无 {@code component2.observationRequest.code}),
     * å› æ­¤æ— æ³•确定 {@code book_check_type}(检查类型)。实测确认:申请单号后缀
     * ä¸Žæ£€æŸ¥ç±»åž‹**无对应关系**(同为 {@code ...1101} åŽç¼€çš„记录存在 1000/100/1100 ç­‰å¤šç§ç±»åž‹ï¼‰ï¼Œ
     * æ•…不做猜测,保持为空并输出 WARN,待后续 {@code S0202ECG}(申请单更新)
     * æˆ–人工补录修正。
     * <p>
     * åŒç† {@code book_date}(预约日期)也不在报文中,保持为空 â€”— è¿™ä¸ŽçŽ°æœ‰æ•°æ®ä¸€è‡´
     * ï¼ˆåº“中约 48% è®°å½• {@code book_date} ä¸ºç©ºï¼Œé¢„约日期由 S050401/S040501HIS åŽç»­è¡¥å…¥ï¼‰ã€‚
     */
    private Map<String, Object> extractEncounter(Map<String, Object> dataMap) {
        return 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("componentOf1")).map(componentOf1 -> (Map<String, Object>) componentOf1.get("encounter")).orElse(null);
    private void insertAppointmentFromStateMessage(String applyNo, String status, Map<String, Object> dataMap) {
        AppointmentDO appointment = new AppointmentDO();
        appointment.setApplyNo(applyNo);
        appointment.setStatus(status);
        if (HisCheckStatusEnum.REGISTERED.getCode().toString().equals(status)) {
            appointment.setRegisterDate(new Date());
        }
        // æ‚£è€…信息(S0405 çš„ patient.id.item ä¸ºåˆ—表:域ID/患者ID/门诊号/住院号/就诊次数/就诊流水号)
        // ç”¨ç±»åž‹å®‰å…¨å¯¼èˆªï¼Œé¿å…æŸä¸€çº§ä¸æ˜¯ Map æ—¶æŠ› ClassCastException
        Map<String, Object> encounter = dig(dataMap, "controlActProcess", "subject",
                "placerGroup", "componentOf1", "encounter");
        if (encounter != null) {
            // å°±è¯Šæµæ°´å·ï¼šid.item[1]
            List<?> encIdItems = asList(dig(encounter, "id"), "item");
            if (encIdItems.size() > 1 && encIdItems.get(1) instanceof Map) {
                appointment.setEpisodeId(str(((Map<String, Object>) encIdItems.get(1)).get("extension")));
            }
            // æ‚£è€…信息
            Map<String, Object> patient = dig(encounter, "subject", "patient");
            if (patient != null) {
                List<?> patIdItems = asList(patient, "id", "item");
                // æ‚£è€…ID item[1],门诊号 item[2],住院号 item[3]
                if (patIdItems.size() > 1 && patIdItems.get(1) instanceof Map) {
                    appointment.setPatId(str(((Map<String, Object>) patIdItems.get(1)).get("extension")));
                }
                if (patIdItems.size() > 2 && patIdItems.get(2) instanceof Map) {
                    appointment.setOutpatientNo(str(((Map<String, Object>) patIdItems.get(2)).get("extension")));
                }
                if (patIdItems.size() > 3 && patIdItems.get(3) instanceof Map) {
                    appointment.setHospitalNo(str(((Map<String, Object>) patIdItems.get(3)).get("extension")));
                }
                // å§“名
                String patName = digText(patient, "patientPerson", "name", "item", "part", "value");
                if (StringUtils.isNotEmpty(patName)) {
                    appointment.setPatName(patName);
                }
                // æ€§åˆ«ï¼špat_gender ä¸º NOT NULL,尽量取;取不到时由下方兜底为 0
                String gender = digText(patient, "patientPerson", "administrativeGenderCode", "code");
                Byte genderByte = parseByteQuietly(gender);
                if (genderByte != null) {
                    appointment.setPatGender(genderByte);
                }
            }
        }
        // æ‰§è¡Œç§‘室(写入 pat_dept_code / pat_dept_desc)
        Map<String, Object> org = dig(dataMap, "controlActProcess", "subject", "placerGroup",
                "component2", "observationRequest", "location", "serviceDeliveryLocation",
                "serviceProviderOrganization");
        if (org != null) {
            String deptCode = digText(org, "id", "item", "extension");
            if (StringUtils.isNotEmpty(deptCode)) {
                appointment.setPatDeptCode(deptCode);
            }
            String deptDesc = digText(org, "name", "item", "part", "value");
            if (StringUtils.isNotEmpty(deptDesc)) {
                appointment.setPatDeptDesc(deptDesc);
            }
        }
        if (StringUtils.isEmpty(appointment.getPatName())) {
            log.warn("[insertAppointmentFromStateMessage][容错新增的记录缺少患者姓名] applyNo={}", applyNo);
        }
        log.warn("[insertAppointmentFromStateMessage][注意:S0405 æŠ¥æ–‡ä¸å«æ£€æŸ¥é¡¹ç›®ï¼Œ" +
                "book_check_type å°†ä¸ºç©ºï¼Œéœ€åŽç»­ S0202ECG æˆ–人工补录] applyNo={}", applyNo);
        // pat_id / pat_name / pat_gender åœ¨æ•°æ®åº“中均为 NOT NULL,缺值时提前报错,
        // é¿å… SQL å¼‚常导致事务回滚(实测报错:Field 'pat_gender' doesn't have a default value)
        if (StringUtils.isEmpty(appointment.getPatId())) {
            log.error("[insertAppointmentFromStateMessage][报文缺患者编号(pat_id),无法新增] applyNo={}", applyNo);
            throw exception(PATIENT_NOT_EXISTS);
        }
        if (appointment.getPatGender() == null) {
            log.warn("[insertAppointmentFromStateMessage][报文未提供患者性别,按未知(0)写入] applyNo={}", applyNo);
            appointment.setPatGender(UNKNOWN_GENDER);
        }
        AppointmentSaveReqVO saveReqVO = BeanUtils.toBean(appointment, AppointmentSaveReqVO.class);
        createAppointment(saveReqVO);
    }
    @Override
    public void handleCheckAppointmentUpdate(Map<String, Object> dataMap) {
        // ä»ŽdataMap中获取controlActProcess
        Map<String, Object> controlActProcess = (Map<String, Object>) dataMap.get("controlActProcess");
        Map<String, Object> subject = (Map<String, Object>) controlActProcess.get("subject");
        Map<String, Object> actAppointment = (Map<String, Object>) subject.get("actAppointment");
    /**
     * å– S0405 æŠ¥æ–‡ä¸­çš„ {@code ZDY} èŠ‚ç‚¹ã€‚
     * <p>
     * å®žæµ‹è·¯å¾„:{@code controlActProcess.subject.placerGroup.component2.observationRequest
     * .component1.processStep.code.ZDY},内容形如:
     * <pre>
     *   ZDY={roomcode=111, roomname=急诊CT室, jcxmcode=1080,3016,3014,
     *        jcxmname=胸部CT平扫,上腹部CT平扫,下腹部CT平扫,
     *        pdh=000336, yysd=00:00-01:00, isjj=1}
     * </pre>
     * æ³¨æ„ï¼š{@code observationRequest.code} åœ¨æŠ¥æ–‡ä¸­**可能是空字符串而非 Map**,
     * æ•…必须用 {@link #dig(Object, String...)} åšç±»åž‹å®‰å…¨å¯¼èˆªã€‚
     */
    private Map<String, Object> extractZdyFromStateMessage(Map<String, Object> dataMap) {
        return dig(dataMap, "controlActProcess", "subject", "placerGroup", "component2",
                "observationRequest", "component1", "processStep", "code", "ZDY");
    }
        // èŽ·å–ç”³è¯·ç¼–å·
        List<Map<String, Object>> referencedOrders = (List<Map<String, Object>>) actAppointment.get("referencedOrder");
        String applyNo = null;
        if (!CollectionUtils.isEmpty(referencedOrders)) {
            Map<String, Object> actOrder = (Map<String, Object>) referencedOrders.get(0).get("actOrder");
            Map<String, Object> id = (Map<String, Object>) actOrder.get("id");
            applyNo = (String) id.get("extension");
    /** ä»Ž Map ä¸­å–字符串字段(可为空) */
    private String extractZdyValueFromMap(Map<String, Object> map, String key) {
        if (map == null) {
            return null;
        }
        Object v = map.get(key);
        return v == null ? null : v.toString();
    }
    /** å®‰å…¨è½¬å­—符串:null è¿”回 null,其余 toString() */
    private static String str(Object o) {
        return o == null ? null : o.toString();
    }
    /** {@code appointment.remark} åˆ—宽上限(见 08_fix_remark_length.sql,当前 varchar(2000)) */
    private static final int REMARK_MAX_LEN = 2000;
    /** {@code appointment.room_name} åˆ—宽上限 */
    private static final int ROOM_NAME_MAX_LEN = 200;
    /**
     * æŒ‰åˆ—宽截断文本,防止 {@code Data too long for column} å¯¼è‡´**整个事务回滚**。
     *
     * <p><b>血泪教训</b>:2026-09-28 ç”Ÿäº§çŽ¯å¢ƒè¯¥é”™è¯¯å‡ºçŽ° 52 æ¬¡ã€‚心电图运动试验的
     * æ³¨æ„äº‹é¡¹é•¿è¾¾ 474 å­—符,而 {@code remark} åˆ—原为 {@code varchar(255)},
     * æº¢å‡ºåŽæŠ› {@code MysqlDataTruncation},使整条 UPDATE å›žæ»š â€”—
     * è¿žå¸¦åŒä¸€äº‹åŠ¡ä¸­çš„ {@code book_time}、{@code room_name} ä¸€èµ·ä¸¢å¤±ï¼Œ
     * è¡¨çŽ°ä¸º"预约时间段没存进数据库"。
     *
     * @param value åŽŸå§‹æ–‡æœ¬
     * @param maxLen ç›®æ ‡åˆ—宽
     * @return æˆªæ–­åŽçš„æ–‡æœ¬ï¼ˆnull åŽŸæ ·è¿”å›žï¼‰
     */
    private static String truncate(String value, int maxLen) {
        if (value == null) {
            return null;
        }
        if (value.length() <= maxLen) {
            return value;
        }
        log.warn("[truncate][文本超长已截断] åŽŸé•¿åº¦={} æˆªæ–­è‡³={} å†…容前 60 å­—={}",
                value.length(), maxLen,
                value.substring(0, Math.min(60, value.length())));
        return value.substring(0, maxLen);
    }
    /**
     * å±‚级导航后取「列表」节点:{@code dig(root, path...)} å¾—到的值若是 List åˆ™è¿”回,
     * å¦åˆ™è¿”回空列表(**不抛异常**)。
     */
    @SuppressWarnings("unchecked")
    private static List<?> asList(Map<String, Object> root, String... path) {
        Object cur = root;
        for (String key : path) {
            if (!(cur instanceof Map)) {
                return Collections.emptyList();
            }
            cur = ((Map<String, Object>) cur).get(key);
        }
        return cur instanceof List ? (List<?>) cur : Collections.emptyList();
    }
    /**
     * ç±»åž‹å®‰å…¨çš„层级导航:逐级取 Map å­èŠ‚ç‚¹ã€‚
     *
     * <p><b>为什么需要它</b>:HIS æŠ¥æ–‡ç» Jackson ååºåˆ—化后,同一字段的类型**并不稳定**。
     * ä¾‹å¦‚ {@code observationRequest.code} åœ¨éƒ¨åˆ†æŠ¥æ–‡ä¸­æ˜¯ {@code Map}({@code code={code=7,...}}),
     * åœ¨å¦ä¸€äº›æŠ¥æ–‡ä¸­æ˜¯**空字符串**({@code code=})。若用
     * {@code .map(m -> (Map<String,Object>) m.get("code"))} è¿™ç§é“¾å¼å¼ºåˆ¶è½¬æ¢ï¼Œ
     * é‡åˆ°å­—符串会抛 {@code ClassCastException},**导致整条消息处理中断**。
     *
     * <p>实测生产日志 2026-09-28 ä¸­è¯¥ç±»å¼‚常出现 **287 æ¬¡**,正是由此写法引起
     * ï¼ˆ{@code AppointmentServiceImpl.lambda$extractCheckCodeFromComponent2})。
     *
     * @param root èµ·å§‹èŠ‚ç‚¹
     * @param path ä¾æ¬¡è¦å–的键
     * @return æœ«ç«¯èŠ‚ç‚¹ï¼›ä»»ä¸€çŽ¯èŠ‚ä¸æ˜¯ Map æˆ–缺失时返回 null(**不抛异常**)
     */
    @SuppressWarnings("unchecked")
    private static Map<String, Object> dig(Map<String, Object> root, String... path) {
        Object cur = root;
        for (String key : path) {
            if (!(cur instanceof Map)) {
                return null;
            }
            cur = ((Map<String, Object>) cur).get(key);
        }
        return cur instanceof Map ? (Map<String, Object>) cur : null;
    }
    /** HIS æŠ¥æ–‡ä¸­ã€ŒåŒ»å˜±å·/医嘱ID」的 id root(见《交互服务通用接口文档》6.1 / 6.4 / 6.5) */
    private static final String ORDER_NO_ROOT = "2.16.156.10011.1.28";
    /**
     * åœ¨ HL7 çš„ {@code id} èŠ‚ç‚¹ä¸­ï¼ŒæŒ‰ {@code root} åŒ¹é…å¹¶å–出 {@code extension}。
     * <p>
     * å…¼å®¹ä¸‰ç§çœŸå®žå½¢æ€ï¼ˆå‡å·²åœ¨æŠ¥æ–‡/文档中出现):
     * <pre>
     *   id={item={root=..., extension=...}}          // å•个 Map
     *   id={item=[{root=..., extension=...}, {...}]} // List(多标识,须按 root åŒºåˆ†ï¼‰
     *   {root=..., extension=...}                    // ç›´æŽ¥å°±æ˜¯æ ‡è¯†æœ¬èº«
     * </pre>
     * <p>
     * <b>为什么不复用 {@link #digText}</b>:{@code digText} é‡åˆ° List åªå–首元素,
     * è€Œ {@code id.item} ä¸ºæ•°ç»„时可能首元素是「申请单号(root 1.24)」而非「医嘱号(root 1.28)」,
     * ä¼šå–错值。故这里显式遍历并按 root åŒ¹é…ã€‚
     *
     * @param idNode {@code id} èŠ‚ç‚¹ï¼ˆMap æˆ– List,可为 null)
     * @param root   ç›®æ ‡ root å€¼
     * @return åŒ¹é…é¡¹çš„ extension;未匹配/为空时返回 null
     */
    private static String findExtensionByRoot(Object idNode, String root) {
        if (idNode == null) {
            return null;
        }
        Object item = (idNode instanceof Map) ? ((Map<String, Object>) idNode).get("item") : idNode;
        if (item == null) {
            return null;
        }
        List<Object> candidates = new ArrayList<>();
        if (item instanceof List) {
            candidates.addAll((List<Object>) item);
        } else {
            candidates.add(item);
        }
        for (Object cand : candidates) {
            if (!(cand instanceof Map)) {
                continue;
            }
            Map<String, Object> m = (Map<String, Object>) cand;
            Object r = m.get("root");
            if (r != null && root.equals(r.toString().trim())) {
                Object ext = m.get("extension");
                if (ext != null && StringUtils.isNotEmpty(ext.toString().trim())) {
                    return ext.toString().trim();
                }
            }
        }
        return null;
    }
    /**
     * ä»Ž HIS æŠ¥æ–‡ä¸­å– {@code zdy} è‡ªå®šä¹‰å­—段区的「是否床边」标记(需求2)。
     *
     * <h4>字段来源</h4>
     * {@code controlActProcess.subject.observationRequest.zdy.ISCB},
     * å–值 {@code 0=否 1=是}。
     *
     * <p>该字段**在 HIS å®˜æ–¹ã€Šäº¤äº’服务通用接口文档》中未定义** â€”— æ–‡æ¡£é‡Œ 25 ä¸ª
     * {@code zdy} å—å…± 19 ä¸ªå­—段,实测真实报文却有 26 ä¸ªï¼Œ{@code ISCB} å±žå…¶ä¸­
     * 7 ä¸ªæœªå…¬å¼€æ‰©å±•字段之一({@code ISCB/ISDN/BLLSH/SWCJBS/KDYSSJH/KLX/VTEGWBZ})。
     * å®žæµ‹æ ·æœ¬ä¸­ {@code ISCB} æ’为 0。
     *
     * <h4>与「床边检查类型」的区别</h4>
     * {@code check_type.value=1000} çš„名称是"床边心电图(常规+频谱)",那是**一个检查项目**;
     * æœ¬æ ‡è®°è¡¨è¾¾çš„æ˜¯**同一项目、患者需到床边做**,两者语义不同,不可互相替代。
     *
     * <p>抽成静态方法是为了可单元测试(见 {@code HisParseSafetyTest})。
     *
     * @param dataMap HIS æŠ¥æ–‡æ ¹èŠ‚ç‚¹
     * @return {@code "0"}/{@code "1"};字段缺失或为空时返回 null
     */
    static String findBedsideFlag(Map<String, Object> dataMap) {
        if (dataMap == null) {
            return null;
        }
        Object cap = dataMap.get("controlActProcess");
        if (!(cap instanceof Map)) {
            return null;
        }
        Object subject = ((Map<String, Object>) cap).get("subject");
        if (!(subject instanceof Map)) {
            return null;
        }
        Object obs = ((Map<String, Object>) subject).get("observationRequest");
        if (!(obs instanceof Map)) {
            return null;
        }
        Object zdy = ((Map<String, Object>) obs).get("zdy");
        if (!(zdy instanceof Map)) {
            return null;
        }
        Object iscb = ((Map<String, Object>) zdy).get("ISCB");
        if (iscb == null) {
            return null;
        }
        String v = iscb.toString().trim();
        return v.isEmpty() ? null : v;
    }
    /**
     * å–末端节点的标量值(用于 {@code code} å¯èƒ½æ˜¯ Map ä¹Ÿå¯èƒ½æ˜¯å­—符串的场景)。
     *
     * @return æ ‡é‡æ–‡æœ¬ï¼›æœ«ç«¯ä¸º Map æ—¶å–其中的 {@code value}/{@code code};取不到返回 null
     */
    private static String digText(Map<String, Object> root, String... path) {
        Object cur = root;
        for (int i = 0; i < path.length; i++) {
            if (cur instanceof Map) {
                cur = ((Map<String, Object>) cur).get(path[i]);
            } else if (cur instanceof List) {
                // åˆ—表形态(如 item=[{...}])取第一个元素继续
                List<?> list = (List<?>) cur;
                if (list.isEmpty() || !(list.get(0) instanceof Map)) {
                    return null;
                }
                cur = ((Map<String, Object>) list.get(0)).get(path[i]);
            } else {
                return null;
            }
        }
        if (cur == null) {
            return null;
        }
        if (cur instanceof Map) {
            Map<String, Object> m = (Map<String, Object>) cur;
            Object v = m.get("value");
            if (v == null) {
                v = m.get("code");
            }
            return v == null ? null : v.toString();
        }
        if (cur instanceof String) {
            String s = (String) cur;
            return s.trim().isEmpty() ? null : s;
        }
        return cur.toString();
    }
    /** ä»ŽæŠ¥æ–‡çš„ {@code component2.observationRequest.code.code} å–检查项目代码(可为空) */
    private String extractCheckCodeFromComponent2(Map<String, Object> dataMap) {
        // ç±»åž‹å®‰å…¨ï¼šcode å¯èƒ½æ˜¯ Map,也可能是空字符串
        return digText(dataMap, "controlActProcess", "subject", "placerGroup", "component2",
                "observationRequest", "code", "code");
    }
    /** ä»ŽæŠ¥æ–‡çš„ {@code component2.observationRequest.location...} å–执行科室编码(可为空) */
    private String extractDeptCodeFromComponent2(Map<String, Object> dataMap) {
        return digText(dataMap, "controlActProcess", "subject", "placerGroup", "component2",
                "observationRequest", "location", "serviceDeliveryLocation",
                "serviceProviderOrganization", "id", "item", "extension");
    }
    /**
     * ä»Ž dataMap ä¸­æå– encounter æ•°æ®ã€‚
     *
     * <p>报文路径:{@code controlActProcess.subject.observationRequest.componentOf1.encounter}。
     *
     * <p><b>注意</b>:{@code componentOf1} ä½äºŽ {@code observationRequest} ä¹‹ä¸‹ï¼Œ
     * ä¸Ž {@code id}、{@code code}、{@code component2} åŒçº§ï¼›**不是** {@code subject} çš„直接子节点。
     * æž„造测试报文或解析日志时容易搞错层级,此处逐级判空以便定位。
     */
    @SuppressWarnings("unchecked")
    private Map<String, Object> extractEncounter(Map<String, Object> dataMap) {
        Map<String, Object> cap = (Map<String, Object>) dataMap.get("controlActProcess");
        if (cap == null) {
            return null;
        }
        Map<String, Object> subject = (Map<String, Object>) cap.get("subject");
        if (subject == null) {
            return null;
        }
        Map<String, Object> observationRequest = (Map<String, Object>) subject.get("observationRequest");
        if (observationRequest == null) {
            return null;
        }
        Object componentOf1 = observationRequest.get("componentOf1");
        if (componentOf1 == null) {
            return null;
        }
        return (Map<String, Object>) ((Map<String, Object>) componentOf1).get("encounter");
    }
    /**
     * æ£€æŸ¥é¢„约状态新增/更新
     * ï¼ˆaction: {@code S050401}/{@code S050402} æ–°å¢žã€{@code S050501}/{@code S050502} æ›´æ–°ï¼‰ã€‚
     *
     * <h3>本次改动</h3>
     * <ol>
     *   <li><b>删除死代码</b>:原实现在记录不存在时调用 {@code handleAppointmentCreate(dataMap)},
     *       ä½†æœ¬æœåŠ¡çš„æŠ¥æ–‡ç»“æž„æ˜¯ {@code subject.actAppointment},而 create æœŸæœ›
     *       {@code subject.observationRequest} â€”— ä¸¤è€…完全不兼容,{@code extractEncounter}
     *       å¿…然返回 null å¹¶æŠ›å¼‚常;原紧随其后的 {@code log.warn("未找到预约记录")}
     *       å› æ­¤**永远不会执行**。现改为明确记日志 + å®¹é”™æ–°å¢žã€‚</li>
     *   <li><b>状态归一化</b>:{@code ZDY.statusCode} æ˜¯**预约状态**(如 1=已预约),
     *       ä¸Ž {@code S0405} çš„**检查状态编码**语义不同,但同写入 {@code status} åˆ—。
     *       è¿™é‡Œç»Ÿä¸€èµ° {@link HisCheckStatusEnum#normalize(String)} æ ¡éªŒã€‚</li>
     *   <li><b>容错新增</b>(Q4 è¦æ±‚):记录不存在时记 WARN å¹¶ä¾æ®æœ¬æŠ¥æ–‡æ’入新记录。</li>
     *   <li><b>启用未使用字段</b>(Q7 è¦æ±‚):{@code ZDY.remark}(注意事项)、
     *       {@code ZDY.area}(位置)、{@code ZDY.roomcode}/{@code roomname}(房间)、
     *       {@code patient.id.item[4]}(预约排序号)。</li>
     *   <li><b>空值防御</b>:原实现直接 {@code LocalDateTime.parse(bookTimeStr)}
     *       ä¸Ž {@code Integer.parseInt(bookSrc)},报文缺字段时会抛异常导致整条消息失败。</li>
     * </ol>
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void handleCheckAppointmentUpdate(Map<String, Object> dataMap) {
        Map<String, Object> controlActProcess = (Map<String, Object>) dataMap.get("controlActProcess");
        if (controlActProcess == null) {
            throw exception(HIS_MESSAGE_PARSE_FAIL);
        }
        Map<String, Object> subject = (Map<String, Object>) controlActProcess.get("subject");
        Map<String, Object> actAppointment = subject == null ? null
                : (Map<String, Object>) subject.get("actAppointment");
        if (actAppointment == null) {
            throw exception(HIS_MESSAGE_PARSE_FAIL);
        }
        if (applyNo == null) {
        // 1. èŽ·å–ç”³è¯·ç¼–å·ã€‚
        //    æ³¨æ„ï¼šæŽ¥å£æ–‡æ¡£å®šä¹‰é¢„约号在 actAppointment.id.extension(如 3360270),
        //    è€Œå®žé™…报文同时提供了 referencedOrder[].actOrder.id.extension(如 2126091700001,
        //    ä¸Ž S0201ECG åˆ›å»ºçš„申请单号一致)。本地 appointment.apply_no å­˜çš„æ˜¯åŽè€…,
        //    å› æ­¤**优先取 referencedOrder**,取不到再回退到 actAppointment.id。
        String applyNo = extractApplyNoFromCheckAppointment(actAppointment);
        if (StringUtils.isEmpty(applyNo)) {
            log.warn("[handleCheckAppointmentUpdate][未找到申请编号,忽略] dataMap={}", dataMap);
            throw exception(HIS_APPLY_NO_MISSING);
        }
            log.warn("[handleCheckAppointmentUpdate][未找到申请编号]");
        // 1.1 ã€å¿…做过滤】S050401/S050501 åŒæ ·æ˜¯**全院通用**服务,会推送放射/超声等
        //     éžå¿ƒç”µç§‘室的预约单(实测 2026-09-17:S050401 109/256、S050501 4/19 ä¸ºéžå¿ƒç”µï¼Œ
        //     æ£€æŸ¥é¡¹ç›®å¦‚"右肘正侧位/2号机房(DR)"、"彩超右下肢动脉/5号诊室")。
        //     æ­¤å‰è¯¥å¤„理器**没有任何过滤**,导致这些单被当作心电申请单采集入库。
        //     éžå¿ƒç”µå•直接跳过(只记日志、不落库、不返回错误,避免 HIS åå¤é‡æŽ¨ï¼‰ã€‚
        Map<String, Object> zdyNode = (Map<String, Object>) actAppointment.get("ZDY");
        String checkDeptCode = extractDeptCodeFromCheckAppointment(actAppointment);
        if (!hisEcgFilter.isEcgOrderWithZdy("S050401/S050501", applyNo, checkDeptCode, zdyNode)) {
            return;
        }
        // æ ¹æ®applyNo查询预约记录
        // 2. è§£æžé¢„约时间(可能为空,原实现会直接抛异常)
        Map<String, Object> zdy = zdyNode;
        Map<String, Object> effectiveTime = (Map<String, Object>) actAppointment.get("effectiveTime");
        Map<String, Object> anyTime = effectiveTime == null ? null
                : (Map<String, Object>) effectiveTime.get("any");
        String bookTimeStr = anyTime == null ? null : (String) anyTime.get("value");
        LocalDateTime bookTime = parseDateTime(bookTimeStr);
        LocalDate bookDate = bookTime == null ? null : bookTime.toLocalDate();
        // 3. æ—¶é—´æ®µï¼ˆZDY.time,形如 "2026-09-17 08:30-09:00"),解析失败不影响其他字段
        Integer bookTimeslot = parseBookTimeslot(zdy == null ? null : (String) zdy.get("time"));
        // 4. å°±è¯Šç±»åˆ« â†’ bookSrc(文档:actAppointment.code.code,住院/门诊/急诊代码)
        Map<String, Object> code = (Map<String, Object>) actAppointment.get("code");
        String bookSrc = code == null ? null : (String) code.get("code");
        Integer bookSrcValue = parseIntegerQuietly(bookSrc);
        // 5. é¢„约状态归一化(ZDY.statusCode)
        String rawStatus = zdy == null || zdy.get("statusCode") == null
                ? null : zdy.get("statusCode").toString();
        String status = HisCheckStatusEnum.normalize(rawStatus);
        if (status == null && rawStatus != null) {
            log.warn("[handleCheckAppointmentUpdate][预约状态编码不在编码表内,将不更新状态] applyNo={} rawStatus={}",
                    applyNo, rawStatus);
        }
        // 6. åŒ¹é…æœ¬åœ°è®°å½•
        AppointmentDO appointment = appointmentMapper.getByApplyNo(applyNo);
        if (appointment == null) {
            handleAppointmentCreate(dataMap);
            log.warn("[handleCheckAppointmentUpdate][未找到预约记录 applyNo({})]", applyNo);
            log.warn("[handleCheckAppointmentUpdate][未找到预约记录,执行容错新增] applyNo={} status={}",
                    applyNo, status);
            insertAppointmentFromCheckAppointment(applyNo, actAppointment, bookDate, bookTime,
                    bookTimeslot, bookSrcValue, status);
            return;
        }
        // èŽ·å–é¢„çº¦æ—¶é—´
        Map<String, Object> effectiveTime = (Map<String, Object>) actAppointment.get("effectiveTime");
        Map<String, Object> any = (Map<String, Object>) effectiveTime.get("any");
        String bookTimeStr = (String) any.get("value");
        // èŽ·å–é¢„çº¦æ¥æº
        Map<String, Object> code = (Map<String, Object>) actAppointment.get("code");
        String bookSrc = (String) code.get("code");
        // è§£æžé¢„约时间
        LocalDateTime bookTime = LocalDateTime.parse(bookTimeStr, DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
        LocalDate bookDate = bookTime.toLocalDate();
        Integer bookTimeslot = null;
        // èŽ·å–æ—¶é—´æ®µ
        try {
//            String timeRange = (String) ((Map<String, Object>) actAppointment.get("ZDY")).get("time");这行如果是更新操作的时候,有可能报空指针(但不影响,可以不用管)
            String timeRange = (String) ((Map<String, Object>) actAppointment.get("ZDY")).get("time");
            String[] time1 = timeRange.split(" ");
            String[] times = time1[1].split("-");
            String startTime = times[0].trim();
            String endTime = times[1].trim();
            // è½¬æ¢æ—¶é—´æ®µæ ¼å¼ä¸ºhhmmhhmm
            bookTimeslot = Integer.parseInt(startTime.replace(":", "")) * 10000 + Integer.parseInt(endTime.replace(":", ""));
        } catch (Exception e) {
            e.printStackTrace();
        // 7. æ›´æ–°å·²æœ‰è®°å½•
        if (bookDate != null) {
            appointment.setBookDate(bookDate);
        }
        // æ›´æ–°é¢„约信息
        appointment.setBookDate(bookDate);
        appointment.setBookTimeslot(bookTimeslot);
        appointment.setBookTime(bookTime);
        appointment.setBookSrc(Integer.parseInt(bookSrc));
        appointment.setStatus((String) ((Map<String, Object>) actAppointment.get("ZDY")).get("statusCode"));
        // ä¿å­˜æ›´æ–°
        if (bookTime != null) {
            appointment.setBookTime(bookTime);
        }
        if (bookTimeslot != null) {
            appointment.setBookTimeslot(bookTimeslot);
        }
        if (bookSrcValue != null) {
            appointment.setBookSrc(bookSrcValue);
        }
        if (status != null) {
            appointment.setStatus(status);
        }
        // â€”— HIS è¡¥å……字段(随表新增列一起启用)——
        // æ³¨æ„ï¼šremark å¯èƒ½é•¿è¾¾æ•°ç™¾å­—符(如实测心电图运动试验注意事项 474 å­—),
        // å¿…须按列宽截断,否则 Data too long ä¼šå¯¼è‡´æ•´ä¸ªäº‹åŠ¡å›žæ»šï¼ˆè§ truncate æ–¹æ³•说明)
        if (zdy != null) {
            Object remark = zdy.get("remark");
            if (remark != null && StringUtils.isNotEmpty(remark.toString())) {
                appointment.setRemark(truncate(remark.toString(), REMARK_MAX_LEN));
            }
            Object roomName = zdy.get("roomname");
            if (roomName != null && StringUtils.isNotEmpty(roomName.toString())) {
                appointment.setRoomName(truncate(roomName.toString(), ROOM_NAME_MAX_LEN));
            }
        }
        // é¢„约排序号(patient.id.item[4])
        Integer seqNum = extractBookSeqNum(actAppointment);
        if (seqNum != null) {
            appointment.setBookSeqNum(seqNum);
        }
        appointmentMapper.updateById(appointment);
    }
    /**
     * å–检查预约报文中的执行/申请科室编码。
     * <p>
     * æŠ¥æ–‡è·¯å¾„:{@code actAppointment.subject.patient.providerOrganization.id.item.extension}
     * ï¼ˆå®žæµ‹æ ·ä¾‹ {@code ZY012})。仅用于心电判定的"依据 C"(需配置白名单才生效)。
     */
    private String extractDeptCodeFromCheckAppointment(Map<String, Object> actAppointment) {
        return digText(actAppointment, "subject", "patient", "providerOrganization", "id",
                "item", "extension");
    }
    /**
     * é¢„约排序号在报文中的 root æ ‡è¯†ã€‚
     * <p>
     * æŽ¥å£æ–‡æ¡£ 6.4 çš„示例里把「预约排序号」注在 {@code item[4]} ä½ç½®ï¼Œ
     * ä½†**实际报文的 root ä¸Žä½ç½®ä¸ä¸€è‡´**:真实报文(2026-09-17 æ—¥å¿—)为
     * <pre>
     *   item[0] root=2.16.156.10011.2.5.1.5    åŸŸID
     *   item[1] root=2.16.156.10011.2.5.1.4    æ‚£è€…ID
     *   item[2] root=2.16.156.10011.1.10       é—¨ï¼ˆæ€¥ï¼‰è¯Šå·
     *   item[3] root=2.16.156.10011.1.12       ä½é™¢å·
     *   item[4] root=2.16.156.10011.2.5.1.8    å°±è¯Šæ¬¡æ•°   â† ä¸æ˜¯æŽ’序号
     *   item[5] root=2.16.156.10011.2.5.1.9    å°±è¯Šæµæ°´å·
     *   item[6] root=2.16.156.10011.2.5.1.11   é¢„约排序号 â† å®žé™…在此
     * </pre>
     * å› æ­¤**按 root åŒ¹é…**而非按位置取值,避免位置漂移导致取错字段。
     */
    private static final String ROOT_BOOK_SEQ_NUM = "2.16.156.10011.2.5.1.11";
    /**
     * å–「预约排序号」。
     * <p>
     * æŠ¥æ–‡è·¯å¾„:{@code actAppointment.subject.patient.id.item[]} ä¸­
     * {@code root=2.16.156.10011.2.5.1.11} çš„那一项的 {@code extension}。
     *
     * @return æŽ’序号;取不到时返回 null
     */
    @SuppressWarnings("unchecked")
    private Integer extractBookSeqNum(Map<String, Object> actAppointment) {
        List<?> items = asList(actAppointment, "subject", "patient", "id", "item");
        for (Object it : items) {
            if (!(it instanceof Map)) {
                continue;
            }
            Map<String, Object> item = (Map<String, Object>) it;
            Object root = item.get("root");
            if (root != null && ROOT_BOOK_SEQ_NUM.equals(root.toString().trim())) {
                Object ext = item.get("extension");
                return ext == null ? null : parseIntegerQuietly(ext.toString());
            }
        }
        return null;
    }
    /**
     * å–检查预约报文的申请单号。
     * <p>
     * ä¼˜å…ˆ {@code referencedOrder[0].actOrder.id.extension}(与 S0201ECG åˆ›å»ºçš„申请单号一致,
     * å®žæµ‹ {@code 2126091700001});取不到时回退到 {@code actAppointment.id.extension}
     * ï¼ˆæŽ¥å£æ–‡æ¡£å®šä¹‰çš„预约号,实测 {@code 3360270})。
     */
    @SuppressWarnings("unchecked")
    private String extractApplyNoFromCheckAppointment(Map<String, Object> actAppointment) {
        Object referencedOrders = actAppointment.get("referencedOrder");
        if (referencedOrders instanceof List) {
            List<?> list = (List<?>) referencedOrders;
            if (!list.isEmpty() && list.get(0) instanceof Map) {
                Map<String, Object> first = (Map<String, Object>) list.get(0);
                Map<String, Object> actOrder = (Map<String, Object>) first.get("actOrder");
                if (actOrder != null) {
                    Map<String, Object> id = (Map<String, Object>) actOrder.get("id");
                    if (id != null && id.get("extension") != null) {
                        return id.get("extension").toString();
                    }
                }
            }
        }
        Map<String, Object> id = (Map<String, Object>) actAppointment.get("id");
        if (id != null && id.get("extension") != null) {
            return id.get("extension").toString();
        }
        return null;
    }
    /** ä¾æ®æ£€æŸ¥é¢„约报文插入新记录(Q4 å®¹é”™æ–°å¢žï¼‰ */
    @SuppressWarnings("unchecked")
    private void insertAppointmentFromCheckAppointment(String applyNo, Map<String, Object> actAppointment,
                                                       LocalDate bookDate, LocalDateTime bookTime,
                                                       Integer bookTimeslot, Integer bookSrcValue, String status) {
        AppointmentDO appointment = new AppointmentDO();
        appointment.setApplyNo(applyNo);
        appointment.setBookDate(bookDate);
        appointment.setBookTime(bookTime);
        appointment.setBookTimeslot(bookTimeslot);
        appointment.setBookSrc(bookSrcValue);
        appointment.setStatus(status);
        Map<String, Object> zdy = (Map<String, Object>) actAppointment.get("ZDY");
        // ZDY ä¸­çš„ remark(注意事项)与 roomname(房间名称)已随表新增列落库;
        // area(位置)与 roomcode(房间编码)目前仍无对应列,暂不保存。
        if (zdy != null) {
            Object remark = zdy.get("remark");
            if (remark != null && StringUtils.isNotEmpty(remark.toString())) {
                appointment.setRemark(truncate(remark.toString(), REMARK_MAX_LEN));
            }
            Object roomName = zdy.get("roomname");
            if (roomName != null && StringUtils.isNotEmpty(roomName.toString())) {
                appointment.setRoomName(truncate(roomName.toString(), ROOM_NAME_MAX_LEN));
            }
        }
        // é¢„约排序号(patient.id.item[4])
        Integer bookSeqNum = extractBookSeqNum(actAppointment);
        if (bookSeqNum != null) {
            appointment.setBookSeqNum(bookSeqNum);
        }
        // æ‚£è€…信息
        Map<String, Object> subjectNode = (Map<String, Object>) actAppointment.get("subject");
        if (subjectNode != null) {
            Map<String, Object> patient = (Map<String, Object>) subjectNode.get("patient");
            if (patient != null) {
                List<?> patIdItems = Optional.ofNullable(patient.get("id"))
                        .map(id -> (Map<String, Object>) id).map(id -> id.get("item"))
                        .filter(it -> it instanceof List).map(it -> (List<?>) it)
                        .orElse(Collections.emptyList());
                if (patIdItems.size() > 1) {
                    appointment.setPatId((String) ((Map<String, Object>) patIdItems.get(1)).get("extension"));
                }
                if (patIdItems.size() > 2) {
                    appointment.setOutpatientNo((String) ((Map<String, Object>) patIdItems.get(2)).get("extension"));
                }
                if (patIdItems.size() > 3) {
                    appointment.setHospitalNo((String) ((Map<String, Object>) patIdItems.get(3)).get("extension"));
                }
                // é¢„约排序号已在上方通过 extractBookSeqNum ç»Ÿä¸€èµ‹å€¼ï¼Œæ­¤å¤„不再重复处理
                // æ‚£è€…姓名
                Optional.ofNullable(patient.get("patientPerson"))
                        .map(p -> (Map<String, Object>) p)
                        .map(p -> (Map<String, Object>) p.get("name"))
                        .map(n -> (Map<String, Object>) n.get("item"))
                        .map(i -> (Map<String, Object>) i.get("part"))
                        .map(part -> (String) part.get("value"))
                        .ifPresent(appointment::setPatName);
                // ç—…人科室(providerOrganization)—— Q7 å¯ç”¨
                Optional.ofNullable(patient.get("providerOrganization"))
                        .map(org -> (Map<String, Object>) org)
                        .ifPresent(org -> {
                            Optional.ofNullable(org.get("id"))
                                    .map(id -> (Map<String, Object>) id)
                                    .map(id -> (Map<String, Object>) id.get("item"))
                                    .map(item -> (Map<String, Object>) item)
                                    .map(item -> (String) item.get("extension"))
                                    .ifPresent(appointment::setPatDeptCode);
                            Optional.ofNullable(org.get("name"))
                                    .map(n -> (Map<String, Object>) n)
                                    .map(n -> (Map<String, Object>) n.get("item"))
                                    .map(i -> (Map<String, Object>) i.get("part"))
                                    .map(part -> (String) part.get("value"))
                                    .ifPresent(appointment::setPatDeptDesc);
                        });
            }
        }
        if (StringUtils.isEmpty(appointment.getPatName())) {
            log.warn("[insertAppointmentFromCheckAppointment][容错新增的记录缺少患者姓名] applyNo={}", applyNo);
        }
        // appointment.pat_id / pat_name åœ¨æ•°æ®åº“中为 NOT NULL,
        // ç¼ºå€¼æ—¶è‹¥ç›´æŽ¥ insert ä¼šæŠ› SQL å¼‚常并导致整个事务回滚。
        // è¿™é‡Œæå‰ç»™å‡ºæ˜Žç¡®é”™è¯¯ï¼Œä¾¿äºŽ HIS ä¾§å®šä½æŠ¥æ–‡é—®é¢˜ã€‚
        if (StringUtils.isEmpty(appointment.getPatId())) {
            log.error("[insertAppointmentFromCheckAppointment][报文缺患者编号(pat_id),无法新增] applyNo={}", applyNo);
            throw exception(PATIENT_NOT_EXISTS);
        }
        if (appointment.getPatGender() == null) {
            log.warn("[insertAppointmentFromCheckAppointment][报文未提供患者性别,按未知(0)写入] applyNo={}", applyNo);
            appointment.setPatGender(UNKNOWN_GENDER);
        }
        AppointmentSaveReqVO saveReqVO = BeanUtils.toBean(appointment, AppointmentSaveReqVO.class);
        createAppointment(saveReqVO);
    }
    /** è§£æž {@code yyyyMMddHHmmss} æ—¶é—´ï¼Œå¤±è´¥æˆ–为空返回 null(不抛异常) */
    private LocalDateTime parseDateTime(String value) {
        if (StringUtils.isEmpty(value)) {
            return null;
        }
        try {
            return LocalDateTime.parse(value.trim(), DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
        } catch (Exception e) {
            log.warn("[parseDateTime][时间格式非法,忽略] value={}", value);
            return null;
        }
    }
    /** è§£æž ZDY.time(形如 "2026-09-17 08:30-09:00")为 hhmmhhmm æ•´æ•°ï¼Œå¤±è´¥è¿”回 null */
    private Integer parseBookTimeslot(String timeRange) {
        if (StringUtils.isEmpty(timeRange)) {
            return null;
        }
        try {
            String[] parts = timeRange.trim().split("\\s+");
            if (parts.length < 2) {
                return null;
            }
            String[] times = parts[1].split("-");
            if (times.length < 2) {
                return null;
            }
            String startTime = times[0].trim().replace(":", "");
            String endTime = times[1].trim().replace(":", "");
            return Integer.parseInt(startTime) * 10000 + Integer.parseInt(endTime);
        } catch (Exception e) {
            log.warn("[parseBookTimeslot][时间段解析失败,忽略] timeRange={}", timeRange);
            return null;
        }
    }
    /** å®½æ¾æ•´æ•°è§£æžï¼šéžæ•°å­—或空返回 null(避免 Integer.parseInt(null) æŠ›å¼‚常) */
    private Integer parseIntegerQuietly(String value) {
        if (StringUtils.isEmpty(value) || !value.trim().matches("-?\\d+")) {
            return null;
        }
        try {
            return Integer.parseInt(value.trim());
        } catch (NumberFormatException e) {
            return null;
        }
    }
    /** å®½æ¾ Byte è§£æžï¼šéžæ•°å­—或空返回 null(用于患者性别等 tinyint å­—段) */
    private Byte parseByteQuietly(String value) {
        if (StringUtils.isEmpty(value) || !value.trim().matches("-?\\d+")) {
            return null;
        }
        try {
            return Byte.parseByte(value.trim());
        } catch (NumberFormatException e) {
            return null;
        }
    }
    @Override
@@ -735,6 +1717,20 @@
            //记录登记时间
            date = new Date();
        }
        // ã€è¯Šæ–­æ—¥å¿—】登记时间写入前后带类型信息,用于快速定位「00:00:00 æˆªæ–­ã€ç±»é—®é¢˜ã€‚
        //
        // èƒŒæ™¯ï¼š2026-09-27 èµ· appointment.register_date å‡ºçŽ°å¤§é‡ yyyy-MM-dd 00:00:00,
        //       ç»å®žéªŒç¡®è®¤æˆå› æ˜¯ã€Œå†™å…¥å€¼æ˜¯ DATE è¯­ä¹‰çš„值」而非驱动问题:
        //         Â· java.util.Date + DATETIME åˆ— â†’ ä¿ç•™æ—¶åˆ†ç§’
        //         Â· java.sql.Date  / DATE åˆ—     â†’ å¿…然变成 00:00:00
        //      æœ¬æ—¥å¿—打印 date çš„实际类名与值,若日志里出现 java.sql.Date æˆ–非 3 çŠ¶æ€å´è¢«å†™ï¼Œ
        //      å³å¯ç›´æŽ¥å®šä½å†™å…¥æ–¹ã€‚
        if (date != null) {
            log.info("[handleAppointmentStatusUpdate][登记时间写入] applyNo={} status={} "
                            + "日期对象类型={} å€¼={}(时间部分应为当前时刻,若为 00:00:00 è¯´æ˜Žè¢«æˆªæ–­ï¼‰",
                    applyNo, status, date.getClass().getName(),
                    new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(date));
        }
        //状态更新
        Integer i = appointmentMapper.updateStatusByApplyNo(applyNo, status, date);
        log.info("申请单登记applyNo:{},状态status:{},更新结果:{}", applyNo, status, i);
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/callingscreen/CallingScreenServiceImpl.java
@@ -34,6 +34,15 @@
    @Resource
    private RoomMapper roomMapper;
    /**
     * éœ€æ±‚1:用于回填患者信息(性别/出生日期/病区/床号/电话/来源/开单医师等)。
     * <p>
     * ä¸‹åˆ—屏幕查询是**直接查 queueMapper** çš„,不经过 QueueService çš„æŸ¥è¯¢æ–¹æ³•,
     * å› æ­¤å¿…须显式调用 fillAppointmentInfo è¡¥é½ï¼›å¦åˆ™å¤§å±/诊间屏只有姓名。
     */
    @Resource
    private cn.lihu.jh.module.ecg.service.queue.QueueService queueService;
    @Resource
    private CheckTypeMapper checkTypeMapper;
@@ -88,13 +97,38 @@
    @Override
    public Map<Integer, List<QueueDO>> getBigScreenPatient() {
        Map<Integer, List<QueueDO>> mapDisplayColQueue = new HashMap<>();
        int displayColCnt = (int) mapCheckTypeVsDisplayColumn.values().stream().distinct().count();
        for (int dispCol=0; dispCol<displayColCnt; dispCol++) {
            List<QueueDO> columnQueueData = new ArrayList<>();
            mapDisplayColQueue.put(dispCol, columnQueueData);
        }
        mapDisplayColQueue.values().forEach(List::clear);
        // ã€ä¿®å¤ NPE】按**真实 calling_column å€¼**建键,而不是 0..count-1。
        //
        // åŽŸå®žçŽ°ï¼š
        //   int displayColCnt = distinct().count();          // ä¾‹å¦‚ = 5
        //   for (i = 0; i < displayColCnt; i++) put(i, ...); // å»ºé”® 0,1,2,3,4
        // ä½†ä¸‹é¢å–值用的是**真实 calling_column**(如 0,1,3,4,-1),
        // å…¶ä¸­ -1(表示"不在大屏显示")永远不会被建键 â†’ get(-1) è¿”回 null â†’ NPE,
        // å¯¼è‡´**整个大屏接口 500、整屏空白**。实测 check_type ä¸­æœ‰ 7 ä¸ªç±»åž‹çš„
        // calling_column = -1(700/800/900/1200/1300/1400/1500),只要这些类型有患者
        // åœ¨æŽ’队,就会触发(大屏 SQL æ—  check_type è¿‡æ»¤ï¼Œå¿…然被捞出)。
        //
        // ä¿®å¤åŽï¼šåªä¸º**有效的显示列**(>=0)建键;-1 çš„类型在大屏不显示,
        // ç”±ä¸‹æ–¹ col åˆ¤å®šè·³è¿‡ï¼Œä¸å†äº§ç”Ÿ NPE。
        //
        // ã€å¥å£®æ€§ã€‘mapCheckTypeVsDisplayColumn ä»…在 getDisplayColInfo() ä¸­èµ‹å€¼
        // ï¼ˆå£°æ˜Žå¤„为 null)。正常启动流程由 MySpringEventListener è°ƒç”¨è¯¥æ–¹æ³•初始化,
        // ä½†è‹¥å¯åŠ¨é˜¶æ®µè¯¥è°ƒç”¨æœªæˆåŠŸï¼ˆä¾‹å¦‚ CheckTypeMapper æŸ¥è¯¢å¼‚常),
        // è¿™é‡Œä¼šç›´æŽ¥ NPE â†’ å¤§å±å†æ¬¡ 500。故先判空并惰性初始化。
        if (null == mapCheckTypeVsDisplayColumn) {
            log.warn("[getBigScreenPatient] æ˜¾ç¤ºåˆ—映射尚未初始化,尝试惰性初始化");
            try {
                getDisplayColInfo();
            } catch (Exception e) {
                log.error("[getBigScreenPatient] æ˜¾ç¤ºåˆ—映射初始化失败,返回空结果", e);
                return mapDisplayColQueue;   // è¿”回空 map,前端展示空屏而非 500
            }
        }
        mapCheckTypeVsDisplayColumn.values().stream()
                .filter(col -> col != null && col >= 0)
                .distinct()
                .forEach(col -> mapDisplayColQueue.put(col, new ArrayList<>()));
        List<Integer> queueStatusList = new ArrayList<>();
        queueStatusList.add(QueueStatusEnum.READY.getStatus());
@@ -105,8 +139,20 @@
        queueStatusList.add(QueueStatusEnum.PASSED_WAITING.getStatus());
        queueStatusList.add(QueueStatusEnum.AFFINITY.getStatus());
        List<QueueDO> queueDOList = queueMapper.getBigScreenQueueByStatus( queueStatusList );
        // éœ€æ±‚1:回填患者信息(性别/出生日期/病区/床号/电话/来源/开单医师等)
        queueService.fillAppointmentInfo(queueDOList);
        queueDOList.forEach(queueDO -> {
            List<QueueDO> columnQueueData = mapDisplayColQueue.get(mapCheckTypeVsDisplayColumn.get(queueDO.getBookCheckType()));
            Integer col = mapCheckTypeVsDisplayColumn.get(queueDO.getBookCheckType());
            // åŒé‡é˜²å¾¡ï¼šæ˜ å°„缺失或列为 -1(不显示)时跳过,避免 NPE
            if (col == null || col < 0) {
                return;
            }
            List<QueueDO> columnQueueData = mapDisplayColQueue.get(col);
            if (columnQueueData == null) {
                log.warn("[getBigScreenPatient][显示列未初始化,已跳过] checkType={} callingColumn={}",
                        queueDO.getBookCheckType(), col);
                return;
            }
            columnQueueData.add( queueDO );
        });
@@ -150,8 +196,22 @@
        queueStatusList.add(QueueStatusEnum.READY.getStatus());
        queueStatusList.add(QueueStatusEnum.ONSTAGE.getStatus());
        queueStatusList.add(QueueStatusEnum.PASSED.getStatus());
        // ã€éœ€æ±‚8 ä¿®å¤ã€‘补上「已过号-排队」(3)。
        //
        // éœ€æ±‚原文:「过号患者应出现在**大屏和诊间屏**」,且「过号与等待中排在一起、按序号升序」。
        // å¤§å±çš„ getBigScreenPatient å·²å« PASSED_WAITING(3),但本方法(诊间屏)**漏了**,
        // å¯¼è‡´ status=3 çš„æ‚£è€…在大屏可见、在诊间屏不可见。
        //
        // å®žæµ‹å¤çŽ°ï¼ˆæž„é€  seq 1~5 å…± 5 ä½æ‚£è€…,含 status=3 ä¸Ž 5):
        //   å¤§å±è¿”回 5 ä½ âœ… ï¼›è¯Šé—´å±è¿”回 4 ä½ï¼ˆç¼º status=3 é‚£ä½ï¼‰ðŸ”´
        //
        // æŽ’序无需额外处理:getRoomQueueByStatus çš„ order by å·²æŠŠ status in (3,5) å½’为同一组
        // ï¼ˆæŽ’在就诊中之后、候诊之前),组内再按 seq_num、book_timeslot å‡åº â€”— æ­£åˆéœ€æ±‚。
        queueStatusList.add(QueueStatusEnum.PASSED_WAITING.getStatus());
        queueStatusList.add(QueueStatusEnum.AFFINITY.getStatus());
        List<QueueDO> queueDOList = queueMapper.getRoomQueueByStatus(roomId, queueStatusList);
        // éœ€æ±‚1:回填患者信息(诊间屏-检查队列)
        queueService.fillAppointmentInfo(queueDOList);
        //把 [亲和的] æŒªåˆ°ä¸»é¡¹åŽé¢
        List<QueueDO> affinityWaitingList = new ArrayList<>();
@@ -197,6 +257,8 @@
        queueStatusList.add(QueueStatusEnum.INSTALLING.getStatus());
        queueStatusList.add(QueueStatusEnum.RECALLED_INSTALL.getStatus());
        List<QueueDO> queueDOList = queueMapper.getRoomQueueByStatus(roomId, queueStatusList);
        // éœ€æ±‚1:回填患者信息(诊间屏-装机队列)
        queueService.fillAppointmentInfo(queueDOList);
        //把 [亲和的] æŒªåˆ°ä¸»é¡¹åŽé¢
        List<QueueDO> affinityReceivedList = new ArrayList<>();
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/devrent/DevRentServiceImpl.java
@@ -154,15 +154,63 @@
        //queueMapper.updateBedQueueStatus(createReqVO.getRoomId(), createReqVO.getBedNo(),
        //        QueueStatusEnum.ONSTAGE.getStatus(), QueueStatusEnum.FINISH.getStatus());
        QueueDO firstOnStageQueueItem = queueMapper.getBedFirstQueueItemByStatus(createReqVO.getRoomId(), createReqVO.getBedNo(), QueueStatusEnum.ONSTAGE.getStatus());
        // QueueStatusEnum.ONSTAGE  =>  QueueStatusEnum.FINISH
        firstOnStageQueueItem.setStatus(QueueStatusEnum.FINISH.getStatus());  // é»˜è®¤çŠ¶æ€ è®¾ç½®
        queueMapper.updateById(firstOnStageQueueItem);
        // ã€ç¼ºé™·ä¿®å¤ã€‘原实现:
        //   QueueDO firstOnStageQueueItem = queueMapper.getBedFirstQueueItemByStatus(
        //           roomId, bedNo, ONSTAGE);
        //   firstOnStageQueueItem.setStatus(FINISH);   // â† å–不到时为 null,直接 NPE
        //
        // ä¸¤ä¸ªé—®é¢˜ï¼š
        //   1) NPE:该工位当前没有"就诊中(30)"患者时返回 null(例如医生先点了"放弃检查",
        //      cancelRoutineOperation å·²æŠŠè¯¥æ‚£è€…置为 40),第 159 è¡Œç©ºæŒ‡é’ˆ â†’ æ•´ä¸ªäº‹åŠ¡å›žæ»šã€‚
        //   2) è¯¯ä¼¤ï¼šåªæŒ‰"工位 + çŠ¶æ€å– seq_num æœ€å°çš„一条",若期间已叫了下一位患者,
        //      ä¼šæŠŠ**下一位患者**误置为"已就诊(40)"。
        //
        // ä¿®å¤ç­–略(按可靠性从高到低):
        //   â‘  ä¼˜å…ˆæŒ‰ patId + checkType ç²¾ç¡®åŒ¹é…ï¼ˆå‰ç«¯ submitForm ä¼ çš„ formData å«è¿™ä¸¤ä¸ªå­—段);
        //   â‘¡ é€€åŒ–到"工位 + çŠ¶æ€ + ä»Šæ—¥"取 update_time æœ€æ–°çš„一条(比 seq_num æœ€å°æ›´è´´åˆå½“前患者);
        //   â‘¢ ä»å–不到 â†’ è®°è­¦å‘Šå¹¶**跳过队列更新**,而不是抛 NPE è®©æ•´ä¸ª"检查完成"失败。
        //      æ­¤æ—¶è®¾å¤‡å½’还、作业记录等仍会正常保存(数据不丢),仅队列状态未变。
        QueueDO onStageQueueItem = null;
        // æ£€æŸ¥é¡¹ç›®.亲和性 å¤„理逻辑
        CheckTypeDO checkTypeDO = queueServiceTxFunctions.getCheckTypeItem(firstOnStageQueueItem.getBookCheckType());
        if (checkTypeDO.getAffinityCheckTypes().length > 0) {
            procAffinityWhenRoutineFinish(firstOnStageQueueItem.getPatId(), checkTypeDO.getAffinityCheckTypes());
        // â‘  ç²¾ç¡®å®šä½ï¼ˆç”¨ Spring çš„ StringUtils,避免额外依赖;
        //    æœ¬æ¨¡å—未引入 commons-lang3,勿用 org.apache.commons.lang3.StringUtils)
        if (org.springframework.util.StringUtils.hasText(createReqVO.getPatId())
                && null != createReqVO.getCheckType()) {
            onStageQueueItem = queueMapper.getBedQueueItemByPatAndStatus(
                    createReqVO.getRoomId(), createReqVO.getBedNo(),
                    createReqVO.getPatId(), createReqVO.getCheckType(),
                    QueueStatusEnum.ONSTAGE.getStatus());
        }
        // â‘¡ é€€åŒ–:取该工位今日最近更新的一条"就诊中"
        if (null == onStageQueueItem) {
            onStageQueueItem = queueMapper.getBedLatestQueueItemByStatus(
                    createReqVO.getRoomId(), createReqVO.getBedNo(), QueueStatusEnum.ONSTAGE.getStatus());
            if (null != onStageQueueItem) {
                log.warn("[routineFinish] æœªæŒ‰ patId å‘½ä¸­ï¼Œé€€åŒ–为按工位取最近就诊中患者. "
                                + "roomId={} bedNo={} reqPatId={} reqCheckType={} å®žé™…patId={} checkType={}",
                        createReqVO.getRoomId(), createReqVO.getBedNo(), createReqVO.getPatId(),
                        createReqVO.getCheckType(), onStageQueueItem.getPatId(),
                        onStageQueueItem.getBookCheckType());
            }
        }
        // â‘¢ ä»å–不到:跳过队列更新,不抛异常(保证"检查完成"本身可用)
        if (null == onStageQueueItem) {
            log.warn("[routineFinish] è¯¥å·¥ä½æ— [就诊中]患者,跳过队列状态更新. roomId={} bedNo={} patId={} applyNo={}",
                    createReqVO.getRoomId(), createReqVO.getBedNo(),
                    createReqVO.getPatId(), createReqVO.getApplyNo());
        } else {
            // QueueStatusEnum.ONSTAGE  =>  QueueStatusEnum.FINISH
            onStageQueueItem.setStatus(QueueStatusEnum.FINISH.getStatus());
            queueMapper.updateById(onStageQueueItem);
            // æ£€æŸ¥é¡¹ç›®.亲和性 å¤„理逻辑
            CheckTypeDO checkTypeDO = queueServiceTxFunctions.getCheckTypeItem(onStageQueueItem.getBookCheckType());
            if (null != checkTypeDO && null != checkTypeDO.getAffinityCheckTypes()
                    && checkTypeDO.getAffinityCheckTypes().length > 0) {
                procAffinityWhenRoutineFinish(onStageQueueItem.getPatId(), checkTypeDO.getAffinityCheckTypes());
            }
        }
        //检查完成后,此时检查登记写成了,需要将这个回写给集成平台(线上环境才回写)
@@ -177,7 +225,13 @@
                log.info("routineFinishWebServiceClient的返回值为:{}", s);
                if (s.contains("更新成功")) {
                    //需要根据applyNo,将appiontment表里的status改成3
                    appointmentMapper.updateStatusByApplyNo(createReqVO.getApplyNo(), "3", new Date());
                    // ã€è¯Šæ–­æ—¥å¿—】同 AppointmentServiceImpl#handleAppointmentStatusUpdate:
                    // æ‰“印日期对象类型与值,便于定位 register_date è¢«å†™æˆ 00:00:00 çš„写入方。
                    java.util.Date regNow = new Date();
                    log.info("[devRentCreate][登记时间写入] applyNo={} æ—¥æœŸå¯¹è±¡ç±»åž‹={} å€¼={}",
                            createReqVO.getApplyNo(), regNow.getClass().getName(),
                            new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(regNow));
                    appointmentMapper.updateStatusByApplyNo(createReqVO.getApplyNo(), "3", regNow);
                }
            } else {
                //检查类型是床边的或常规的,需要根据applyNo,将appiontment表里的status改成7
@@ -220,7 +274,12 @@
            log.info("webservice的返回值为:{}", s);
            if (s.contains("更新成功")) {
                //需要根据applyNo,将appiontment表里的status改成3
                appointmentMapper.updateStatusByApplyNo(createReqVO.getApplyNo(), "3", new Date());
                // ã€è¯Šæ–­æ—¥å¿—】同上:打印日期对象类型与值
                java.util.Date regNow2 = new Date();
                log.info("[devRentOther][登记时间写入] applyNo={} æ—¥æœŸå¯¹è±¡ç±»åž‹={} å€¼={}",
                        createReqVO.getApplyNo(), regNow2.getClass().getName(),
                        new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(regNow2));
                appointmentMapper.updateStatusByApplyNo(createReqVO.getApplyNo(), "3", regNow2);
            }
        }
        List<Integer> jobTypeList = new ArrayList<Integer>();
@@ -771,13 +830,17 @@
    @Override
    public DevRentDO getDevRent(Long id) {
        return devRentMapper.selectById(id);
        DevRentDO devRentDO = devRentMapper.selectById(id);
        fillPatientInfo(devRentDO);
        return devRentDO;
    }
    @Override
    public PageResult<DevRentDO> getDevRentPage(DevRentPageReqVO pageReqVO) {
        pageReqVO.setState(0);
        return devRentMapper.selectPage(pageReqVO);
        PageResult<DevRentDO> pageResult = devRentMapper.selectPage(pageReqVO);
        fillPatientInfo(pageResult.getList());
        return pageResult;
    }
    @Override
@@ -786,7 +849,9 @@
        stateList.add(DevRentStateEnum.INSTALLED.getState());
        stateList.add(DevRentStateEnum.RECEIVED.getState());
        searchReqVO.setStateList(stateList);
        return devRentMapper.getRentByState(searchReqVO);
        DevRentDO devRentDO = devRentMapper.getRentByState(searchReqVO);
        fillPatientInfo(devRentDO);
        return devRentDO;
    }
    @Override
@@ -794,7 +859,9 @@
        List<Integer> stateList = new ArrayList<>();
        stateList.add(DevRentStateEnum.FREE.getState());
        searchReqVO.setStateList(stateList);
        return devRentMapper.getRentByState(searchReqVO);
        DevRentDO devRentDO = devRentMapper.getRentByState(searchReqVO);
        fillPatientInfo(devRentDO);
        return devRentDO;
    }
    @Override
@@ -803,7 +870,9 @@
        stateList.add(DevRentStateEnum.RECEIVED.getState());
        stateList.add(DevRentStateEnum.RECEIVED_CANCELLED.getState());
        searchReqVO.setStateList(stateList);
        return devRentMapper.getRentByState(searchReqVO);
        DevRentDO devRentDO = devRentMapper.getRentByState(searchReqVO);
        fillPatientInfo(devRentDO);
        return devRentDO;
    }
    @Override
@@ -811,7 +880,67 @@
        List<Integer> stateList = new ArrayList<>();
        stateList.add(DevRentStateEnum.DISMANTLED.getState());
        searchReqVO.setStateList(stateList);
        return devRentMapper.getRentByState(searchReqVO);
        DevRentDO devRentDO = devRentMapper.getRentByState(searchReqVO);
        fillPatientInfo(devRentDO);
        return devRentDO;
    }
    // ==================== éœ€æ±‚1:回填患者信息 18 é¡¹ ====================
    // dev_rent è¡¨åªå­˜äº† applyNo / patName / patDetails,
    // å…¶ä½™æ‚£è€…信息(性别、出生日期、病区、床号、电话、来源、就诊卡号、
    // ç™»è®°æ—¶é—´ã€å¼€å•医师、开单日期、多耐药)都在 appointment è¡¨ã€‚
    //
    // å‚ç…§ QueueServiceImpl#fillAppointmentInfo çš„做法:查询后按 applyNo å›žæŸ¥é¢„约单补齐。
    // è¿™æ ·é¢†ç”¨/装机/拆机/数据录入/检查记录 5 ä¸ªç•Œé¢æ‰èƒ½å±•示完整患者信息。
    /**
     * å›žå¡«å•个对象的患者信息(对象为 null æˆ– applyNo ä¸ºç©ºæ—¶å®‰å…¨è·³è¿‡ï¼‰ã€‚
     */
    private void fillPatientInfo(DevRentDO devRentDO) {
        if (null == devRentDO || !org.springframework.util.StringUtils.hasText(devRentDO.getApplyNo())) {
            return;
        }
        AppointmentDO appointment = appointmentMapper.getByApplyNo(devRentDO.getApplyNo());
        if (null == appointment) {
            return;
        }
        applyAppointmentToDevRent(devRentDO, appointment);
    }
    /**
     * æ‰¹é‡å›žå¡«ï¼ˆé¿å… N+1 ä¹‹å¤–的额外开销由调用方控制;此处逐条回查,
     * ä¸Ž QueueServiceImpl#fillAppointmentInfo ä¿æŒä¸€è‡´çš„做法)。
     */
    private void fillPatientInfo(List<DevRentDO> devRentDOList) {
        if (null == devRentDOList || devRentDOList.isEmpty()) {
            return;
        }
        devRentDOList.forEach(this::fillPatientInfo);
    }
    /**
     * æŠŠé¢„约单中的患者信息复制到 dev_rent å¯¹è±¡ã€‚
     * <p>
     * æ³¨æ„ï¼šåªè¦†ç›–**为空的**平铺字段?——不,这里直接覆盖:
     * è¿™äº›å­—段均标记为 {@code @TableField(exist=false)},不参与写库,
     * æœ¬æ¬¡æŸ¥è¯¢è¿”回的就是最新数据,不存在"覆盖掉用户刚录入的值"的风险。
     */
    private void applyAppointmentToDevRent(DevRentDO target, AppointmentDO source) {
        target.setPatGender(source.getPatGender());
        target.setPatBirthday(source.getPatBirthday());
        target.setRegisterDate(source.getRegisterDate());
        target.setDoctor(source.getDoctor());
        target.setDoctorTime(source.getDoctorTime());
        target.setPatSrc(source.getPatSrc());
        target.setPatMobile(source.getPatMobile());
        target.setPatPhone(source.getPatPhone());
        target.setPatWardDesc(source.getPatWardDesc());
        target.setPatBedNo(source.getPatBedNo());
        target.setMedicalCard(source.getMedicalCard());
        if (target.getDoctorAdviceNo() == null) target.setDoctorAdviceNo(source.getDoctorAdviceNo());
        target.setOutpatientNo(source.getOutpatientNo());
        target.setPatIdentityId(source.getPatIdentityId());
        target.setTolerance(source.getTolerance());
    }
    // ç¡®è´¹å¤„理
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/jobrecord/JobRecordServiceImpl.java
@@ -1,7 +1,10 @@
package cn.lihu.jh.module.ecg.service.jobrecord;
import cn.lihu.jh.module.ecg.controller.admin.appointment.vo.AppointmentRespVO;
import cn.lihu.jh.module.ecg.dal.dataobject.appointment.AppointmentDO;
import cn.lihu.jh.module.ecg.dal.dataobject.devrent.DevRentDO;
import cn.lihu.jh.module.ecg.dal.dataobject.jobrecord.JobRecordStatisticDO;
import cn.lihu.jh.module.ecg.dal.mysql.appointment.AppointmentMapper;
import cn.lihu.jh.module.ecg.dal.mysql.devrent.DevRentMapper;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
@@ -38,6 +41,9 @@
    @Resource
    private DevRentMapper devRentMapper;
    @Resource
    private AppointmentMapper appointmentMapper;
    @Override
    public Long createJobRecord(JobRecordSaveReqVO createReqVO) {
        // æ’å…¥
@@ -72,7 +78,11 @@
    @Override
    public JobRecordDO getJobRecord(Long id) {
        return jobRecordMapper.selectById(id);
        JobRecordDO jobRecordDO = jobRecordMapper.selectById(id);
        if (null != jobRecordDO) {
            fillPatientInfo(java.util.Collections.singletonList(jobRecordDO));
        }
        return jobRecordDO;
    }
    @Override
@@ -86,11 +96,92 @@
            for (int i = 0; i < jobRecordDOList.size(); i++) {
                jobRecordDOList.get(i).setPaid(paidInfoList.get(i));
            }
            // éœ€æ±‚1:回填患者信息(批量,避免 N+1)
            fillPatientInfo(jobRecordDOList);
        }
        return  pageResult;
    }
    // ==================== éœ€æ±‚1:回填患者信息 18 é¡¹ ====================
    // å›žå¡«é“¾è·¯ï¼ˆä¸‰çº§ï¼‰ï¼š
    //   job_record.rentId â†’ dev_rent.applyNo â†’ appointment
    // ä¹‹æ‰€ä»¥ä¸ç”¨é€æ¡ getByApplyNo:job_record åˆ—表页一次可能返回几十条,
    // é€æ¡æŸ¥ä¼šå½¢æˆ N+1。这里改为「批量取 dev_rent」+「批量取 appointment」。
    private void fillPatientInfo(List<JobRecordDO> jobRecordDOList) {
        if (null == jobRecordDOList || jobRecordDOList.isEmpty()) {
            return;
        }
        // 1) æ‰¹é‡å– dev_rent,拿到 applyNo
        List<Long> rentIdList = jobRecordDOList.stream()
                .map(JobRecordDO::getRentId)
                .filter(java.util.Objects::nonNull)
                .distinct()
                .toList();
        if (rentIdList.isEmpty()) {
            return;
        }
        Map<Long, String> applyNoByRentId = new HashMap<>();
        // ä¸€æ¬¡ IN æŸ¥è¯¢å–全部 applyNo(原先逐条 selectById â†’ N æ¬¡æŸ¥è¯¢ï¼‰
        List<DevRentDO> devRentDOList = devRentMapper.getApplyNoByIdList(rentIdList);
        if (null != devRentDOList) {
            devRentDOList.forEach(d -> {
                if (null != d && null != d.getId()
                        && org.springframework.util.StringUtils.hasText(d.getApplyNo())) {
                    applyNoByRentId.put(d.getId(), d.getApplyNo());
                }
            });
        }
        if (applyNoByRentId.isEmpty()) {
            return;
        }
        // 2) æ‰¹é‡å– appointment(一次 IN æŸ¥è¯¢ï¼‰
        List<String> applyNoList = applyNoByRentId.values().stream().distinct().toList();
        Map<String, AppointmentDO> appointmentByApplyNo = new HashMap<>();
        List<AppointmentDO> appointmentDOList = appointmentMapper.getByApplyNoList(applyNoList);
        if (null != appointmentDOList) {
            appointmentDOList.forEach(a -> {
                if (null != a && org.springframework.util.StringUtils.hasText(a.getApplyNo())) {
                    appointmentByApplyNo.put(a.getApplyNo(), a);
                }
            });
        }
        // 3) å›žå¡«
        jobRecordDOList.forEach(jobRecordDO -> {
            if (null == jobRecordDO || null == jobRecordDO.getRentId()) {
                return;
            }
            String applyNo = applyNoByRentId.get(jobRecordDO.getRentId());
            if (null == applyNo) {
                return;
            }
            AppointmentDO appointment = appointmentByApplyNo.get(applyNo);
            if (null == appointment) {
                return;
            }
            jobRecordDO.setApplyNo(applyNo);
            jobRecordDO.setEpisodeId(appointment.getEpisodeId());
            jobRecordDO.setPatGender(appointment.getPatGender());
            jobRecordDO.setPatBirthday(appointment.getPatBirthday());
            jobRecordDO.setRegisterDate(appointment.getRegisterDate());
            jobRecordDO.setDoctor(appointment.getDoctor());
            jobRecordDO.setDoctorTime(appointment.getDoctorTime());
            jobRecordDO.setPatSrc(appointment.getPatSrc());
            jobRecordDO.setPatMobile(appointment.getPatMobile());
            jobRecordDO.setPatPhone(appointment.getPatPhone());
            jobRecordDO.setPatWardDesc(appointment.getPatWardDesc());
            jobRecordDO.setPatBedNo(appointment.getPatBedNo());
            jobRecordDO.setMedicalCard(appointment.getMedicalCard());
            jobRecordDO.setDoctorAdviceNo(appointment.getDoctorAdviceNo());
            jobRecordDO.setOutpatientNo(appointment.getOutpatientNo());
            jobRecordDO.setPatIdentityId(appointment.getPatIdentityId());
            jobRecordDO.setTolerance(appointment.getTolerance());
        });
    }
    @Override
    public List<JobRecordStatisticDO> getJobStatistic(LocalDateTime jobTimeFrom, LocalDateTime jobTimeTo) {
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/queue/BedBO.java
@@ -29,6 +29,22 @@
    Integer readyPatNum = 0;
    Integer maxReadyPatNum = 0;
    /**
     * ã€éœ€æ±‚3】停止候诊开关。
     * <p>
     * true = è¯¥å·¥ä½æš‚停接收**新的**候诊患者(已有的候诊患者仍可正常叫号);
     * false = æ­£å¸¸æŽ¥æ”¶ã€‚
     * <p>
     * ä¸Ž {@code BedStatusEnum.DOCTOR_PAUSE} çš„区别:医生"暂停"走的是
     * {@code updateStop},会顺手把就诊中(30)强制完成、并把候诊中(20)的行
     * {@code room_id=0/bed_no=' '} è¸¢èµ°ï¼ˆæ‚£è€…凭空消失),不满足
     * "已有候诊病人继续叫号"的要求;故本开关**独立实现**,只拦截新增。
     * <p>
     * è¯¥å­—段需**落库**({@code clinic_room.stop_ready}),否则每次
     * {@code bedReload}(开诊/重启)都会丢失。
     */
    Boolean stopReady = false;
    @Override
    public int compareTo(@NotNull BedBO o) {
        return Integer.compare(this.readyPatNum, o.readyPatNum);
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/queue/QueueService.java
@@ -144,6 +144,45 @@
    Integer recallInstallPatient(Long roomId, String bedNo, String patId, Integer checkType, Long roomId_operator, String bedNo_operator);
    // ==================== éœ€æ±‚2:撤销「叫号 / æ£€æŸ¥å®Œæˆã€ ====================
    /**
     * æ’¤é”€ã€Œæ£€æŸ¥å®Œæˆã€ï¼šæŠŠæœ¬å·¥ä½ä»Šæ—¥çš„æŒ‡å®šæ‚£è€…从 å·²å°±è¯Š(40) é€€å›ž å€™è¯Šä¸­(20)。
     * <p>
     * å¿…须提供 patId + checkType åšç²¾ç¡®å®šä½ï¼Œé¿å…è¯¯æ”¹å…¶ä»–患者。
     *
     * @return å—影响行数;0 è¡¨ç¤ºæœªæ‰¾åˆ°è¯¥æ‚£è€…今日的已完成记录
     */
    Integer undoFinishPatient(Long roomId, String bedNo, String patId, Integer checkType);
    /**
     * æ’¤é”€ã€Œè¯¯ç‚¹å«å·ã€ï¼šæŠŠæœ¬å·¥ä½å½“前 å°±è¯Šä¸­(30) çš„æ‚£è€…退回 å€™è¯Šä¸­(20)。
     *
     * @return å—影响行数;0 è¡¨ç¤ºå½“前无就诊中患者
     */
    Integer undoCallPatient(Long roomId, String bedNo);
    /**
     * å–本工位今日**最近一位已完成**的患者(供界面提示可撤销对象)。
     */
    QueueDO getBedLastFinishedQueueItem(Long roomId, String bedNo);
    // ==================== éœ€æ±‚3:停止候诊开关 ====================
    /**
     * è®¾ç½®ã€Œåœæ­¢å€™è¯Šã€å¼€å…³ï¼ˆæŒä¹…化到 {@code clinic_room.stop_ready})。
     * <p>
     * å¼€å¯åŽï¼š**已有候诊病人继续叫号,但不再新增候诊病人**;
     * å…³é—­åŽæ¢å¤æ­£å¸¸æŽ¥æ”¶ã€‚
     *
     * @param stop true=停止候诊;false=恢复
     * @return æˆåŠŸè¿”å›ž SUCCESS
     */
    ErrorCode setStopReady(Long roomId, String bedNo, boolean stop);
    /** æŸ¥è¯¢ã€Œåœæ­¢å€™è¯Šã€çŠ¶æ€ï¼›å·¥ä½ä¸å­˜åœ¨è¿”å›ž null */
    Boolean getStopReady(Long roomId, String bedNo);
    Integer patientJump(String patId, Integer bookCheckType, Byte jumped );
    List<RoomDO> getDocRoomInfo(Long docId);
@@ -151,4 +190,20 @@
    List<QueueDO> getCheckRelatedPatient(Long roomId, String bedNo);
    List<QueueDO> getInstallRelatedPatient(Long roomId, String bedNo);
    // ==================== éœ€æ±‚1:患者信息回填 ====================
    /**
     * æŒ‰ {@code applyNo} ä»Žé¢„约单(appointment)回填队列对象的患者信息。
     * <p>
     * queue è¡¨åªå­˜äº†æ‚£è€…编号/姓名,其余(性别、出生日期、病区、床号、电话、来源、
     * å°±è¯Šå¡å·ã€ç™»è®°æ—¶é—´ã€å¼€å•医师、开单日期、多耐药)都在 appointment è¡¨ã€‚
     * <p>
     * ä¹‹æ‰€ä»¥å¯¹å¤–暴露:大屏/诊间屏({@code CallingScreenServiceImpl})直接查
     * {@code queueMapper},不经过本 Service çš„æŸ¥è¯¢æ–¹æ³•,需要显式调用本方法补齐。
     * ä¸è¡¥é½åˆ™å¤§å±/诊间屏的患者信息只有姓名(性别/病区/床号等全为空)。
     *
     * @param queueDOList é˜Ÿåˆ—对象列表(空/null å®‰å…¨ï¼‰
     */
    void fillAppointmentInfo(List<QueueDO> queueDOList);
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/queue/QueueServiceImpl.java
@@ -10,7 +10,9 @@
import cn.lihu.jh.module.ecg.dal.dataobject.checktype.CheckTypeDO;
import cn.lihu.jh.module.ecg.dal.dataobject.devrent.DevRentDO;
import cn.lihu.jh.module.ecg.dal.mysql.appointment.AppointmentMapper;
import cn.lihu.jh.module.ecg.dal.mysql.checktype.CheckTypeMapper;
import cn.lihu.jh.module.ecg.dal.mysql.devrent.DevRentMapper;
import cn.lihu.jh.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.lihu.jh.module.ecg.enums.DevRentStateEnum;
import cn.lihu.jh.module.system.api.oauth2.OAuth2TokenApi;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
@@ -66,6 +68,13 @@
    @Resource
    private DevRentMapper devRentMapper;
    /**
     * ç”¨äºŽã€Œè®¾å¤‡é¢†ç”¨/装机」取数时过滤出 {@code need_dev_ready = 1} çš„æ£€æŸ¥é¡¹ç›®
     * ï¼ˆè§ {@link #filterNeedDevReady})。
     */
    @Resource
    private CheckTypeMapper checkTypeMapper;
    ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor();
@@ -433,7 +442,8 @@
     * queue è¡¨ä¸å­˜è¿™äº›å­—段,但医生端列表接口需要展示,所以查询后按 applyNo å›žæŸ¥é¢„约单填充:
     * å¤šè€è¯ã€æ€§åˆ«ã€å‡ºç”Ÿæ—¥æœŸã€ç™»è®°æ—¶é—´ã€å¼€å•医师、开单日期。
     */
    private void fillAppointmentInfo(List<QueueDO> queueDOList) {
    @Override
    public void fillAppointmentInfo(List<QueueDO> queueDOList) {
        if (CollectionUtils.isEmpty(queueDOList)) {
            return;
        }
@@ -446,11 +456,26 @@
                return;
            }
            queueDO.setTolerance(appointment.getTolerance());
            // éœ€æ±‚2:床边标记(HIS zdy.ISCB)。queue è¡¨å·²æœ‰è¯¥åˆ—,
            // ä½†å¯¹ã€Œ22_* è„šæœ¬æ‰§è¡Œå‰å°±å·²å…¥é˜Ÿã€çš„è¡Œï¼Œé è¿™é‡Œå›žæŸ¥è¡¥å¡«ï¼Œä¿è¯å±å¹•/页面能看到。
            if (queueDO.getIsBedside() == null) {
                queueDO.setIsBedside(appointment.getIsBedside());
            }
            queueDO.setPatGender(appointment.getPatGender());
            queueDO.setPatBirthday(appointment.getPatBirthday());
            queueDO.setRegisterDate(appointment.getRegisterDate());
            queueDO.setDoctor(appointment.getDoctor());
            queueDO.setDoctorTime(appointment.getDoctorTime());
            // éœ€æ±‚1:补平铺的患者信息来源字段(病历区/床号/电话/来源/就诊卡号等)
            queueDO.setPatSrc(appointment.getPatSrc());
            queueDO.setPatMobile(appointment.getPatMobile());
            queueDO.setPatPhone(appointment.getPatPhone());
            queueDO.setPatWardDesc(appointment.getPatWardDesc());
            queueDO.setPatBedNo(appointment.getPatBedNo());
            queueDO.setMedicalCard(appointment.getMedicalCard());
            queueDO.setDoctorAdviceNo(appointment.getDoctorAdviceNo());
            queueDO.setOutpatientNo(appointment.getOutpatientNo());
            queueDO.setPatIdentityId(appointment.getPatIdentityId());
        });
    }
@@ -471,18 +496,33 @@
                patientStatisticVO.setReadyNum(item.getTotalInStatus());
            } else if (QueueStatusEnum.FINISH.getStatus() == item.getStatus()) {
                patientStatisticVO.setFinishedNum(item.getTotalInStatus());
            } else if (QueueStatusEnum.PASSED.getStatus() == item.getStatus()) {
                patientStatisticVO.setPassedNum(item.getTotalInStatus());
            } else if (QueueStatusEnum.PASSED.getStatus() == item.getStatus()
                    || QueueStatusEnum.PASSED_WAITING.getStatus() == item.getStatus()) {
                // ã€éœ€æ±‚4 å£å¾„统一】过号 = å·²è¿‡å·(5) + å·²è¿‡å·-排队(3)。
                // åŽŸå…ˆåªç®— 5,而列表与屏幕都含 3,导致"过号"数字比实际少。
                int cur = patientStatisticVO.getPassedNum() == null ? 0 : patientStatisticVO.getPassedNum();
                patientStatisticVO.setPassedNum(cur + item.getTotalInStatus());
            }
        });
        // ç»Ÿè®¡ è¯¥å·¥ä½ç±»åž‹çš„【排队中】人员数量
        // ã€Q7 ç¡®è®¤ã€‘此处刻意统计**全项目**(该工位所含检查类型)的"排队中"总数,
        // è¯­ä¹‰ä¸º"全院还有多少人等着做这类检查",与"本工位一眼可见的候诊人数"互补。
        // ä¸”已补 deleted=0 / book_date=今日(见 QueueMapper.checkTypeAndStatusStatistic)。
        RoomDO roomDO = getRoomDO(roomId, bedNo);
        Integer[] checkTypes = roomDO.getCheckTypes();
        List<Integer> statusList = new ArrayList<>();
        statusList.add(QueueStatusEnum.WAITING.getStatus());
        Integer num = queueMapper.checkTypeAndStatusStatistic(checkTypes, statusList);
        patientStatisticVO.setQueuingNum(num);
        int queuingNum = 0;
        // ã€é˜²å¾¡ã€‘checkTypes ä¸ºç©ºæ—¶ä¸èƒ½æ‹¼ `book_check_type in ()`(MySQL è¯­æ³•错误)。
        // å®žæµ‹è¯¥åœºæ™¯ä¼šè®©ç»Ÿè®¡æŽ¥å£ 500,进而导致医生端顶部统计整块报错。
        if (checkTypes != null && checkTypes.length > 0) {
            List<Integer> statusList = new ArrayList<>();
            statusList.add(QueueStatusEnum.WAITING.getStatus());
            Integer num = queueMapper.checkTypeAndStatusStatistic(checkTypes, statusList);
            queuingNum = num == null ? 0 : num;
        } else {
            log.warn("[getPatientStatistic][工位未配置检查类型,排队中按 0 è®¡] roomId={} bedNo={}", roomId, bedNo);
        }
        patientStatisticVO.setQueuingNum(queuingNum);
        return patientStatisticVO;
    }
@@ -524,8 +564,19 @@
        bedQueueStatisticDOList.forEach(item -> {
            if (QueueStatusEnum.READY.getStatus() == item.getStatus()) {
                patientStatisticVO.setReadyNum(item.getTotalInStatus());
            } else if (QueueStatusEnum.PASSED.getStatus() == item.getStatus()) {
                patientStatisticVO.setPassedNum(item.getTotalInStatus());
            } else if (QueueStatusEnum.PASSED.getStatus() == item.getStatus()
                    || QueueStatusEnum.PASSED_WAITING.getStatus() == item.getStatus()) {
                // ã€éœ€æ±‚4 å£å¾„统一】过号 = å·²è¿‡å·(5) + å·²è¿‡å·-排队(3)。
                //
                // ä¸Ž getPatientStatistic ä¿æŒåŒä¸€å£å¾„。原实现只判 PASSED(5),
                // è€Œé¢†ç”¨ç•Œé¢çš„列表/屏幕都含 status=3,导致「过号」数字比实际少
                // â€”— è¿™æ­£æ˜¯ç”¨æˆ·åé¦ˆçš„「统计与实际不符」。
                //
                // å¦ï¼šåŽŸå®žçŽ°ç”¨ setPassedNum(item.getTotalInStatus())(**覆盖**而非累加),
                // å›  SQL æ˜¯ group by status,status=3 ä¸Ž status=5 æ˜¯ä¸¤è¡Œï¼Œ
                // è¦†ç›–会只保留最后一行 â†’ æ”¹ä¸ºç´¯åŠ ã€‚
                int cur = patientStatisticVO.getPassedNum() == null ? 0 : patientStatisticVO.getPassedNum();
                patientStatisticVO.setPassedNum(cur + item.getTotalInStatus());
            }
        });
@@ -545,10 +596,17 @@
        // ç»Ÿè®¡ è¯¥å·¥ä½ç±»åž‹çš„【排队中】人员数量
        RoomDO roomDO = getRoomDO(roomId, bedNo);
        Integer[] checkTypes = roomDO.getCheckTypes();
        List<Integer> statusList = new ArrayList<>();
        statusList.add(QueueStatusEnum.WAITING.getStatus());
        Integer num = queueMapper.checkTypeAndStatusStatistic(checkTypes, statusList);
        patientStatisticVO.setQueuingNum(num);
        Integer num = 0;
        // ã€é˜²å¾¡ã€‘checkTypes ä¸ºç©ºæ—¶ä¸èƒ½æ‹¼ `book_check_type in ()`(MySQL è¯­æ³•错误),
        // å®žæµ‹è¯¥åœºæ™¯ä¼šè®©ç»Ÿè®¡æŽ¥å£ 500、医生端顶部统计整块报错。与 getPatientStatistic ä¿æŒä¸€è‡´ã€‚
        if (checkTypes != null && checkTypes.length > 0) {
            List<Integer> statusList = new ArrayList<>();
            statusList.add(QueueStatusEnum.WAITING.getStatus());
            num = queueMapper.checkTypeAndStatusStatistic(checkTypes, statusList);
        } else {
            log.warn("[getBedDevReadyStatistic][工位未配置检查类型,排队中按 0 è®¡] roomId={} bedNo={}", roomId, bedNo);
        }
        patientStatisticVO.setQueuingNum(num == null ? 0 : num);
        return patientStatisticVO;
    }
@@ -578,6 +636,73 @@
        startHurryUpOneBed(roomId, bedNo);
        return updateNum;
    }
    // ==================== éœ€æ±‚2:撤销「叫号 / æ£€æŸ¥å®Œæˆã€ ====================
    /**
     * æ’¤é”€ã€Œæ£€æŸ¥å®Œæˆã€ï¼šæŠŠæœ¬å·¥ä½ä»Šæ—¥çš„æŒ‡å®šæ‚£è€…从 å·²å°±è¯Š(40) é€€å›ž **候诊中(20)**。
     * <p>
     * <b>Q9 ç¡®è®¤</b>:撤销后回到**候诊区**(而非"就诊中"),便于医生重新叫号。
     * <p>
     * æ”¶å°¾å¿…须调 {@link #startHurryUpOneBed}:它会重新计算该工位的
     * {@code readyPatNum} å¹¶åˆ·æ–°åˆ†æµé˜Ÿåˆ—优先级。若不同步内存计数,
     * {@code hurryupOneCheckType} ä¼šæŠ›å‡º {@code exceed max queue number}。
     *
     * @return å—影响行数(0 è¡¨ç¤ºæ²¡æ‰¾åˆ°è¯¥æ‚£è€…今日的已完成记录)
     */
    @Override
    public Integer undoFinishPatient(Long roomId, String bedNo, String patId, Integer checkType) {
        Integer updateNum = queueMapper.undoFinishPatient(roomId, bedNo, patId, checkType,
                QueueStatusEnum.FINISH.getStatus(), QueueStatusEnum.READY.getStatus());
        if (null != updateNum && updateNum > 0) {
            startHurryUpOneBed(roomId, bedNo);
        }
        return updateNum;
    }
    /**
     * æ’¤é”€ã€Œè¯¯ç‚¹å«å·ã€ï¼šæŠŠæœ¬å·¥ä½å½“前 å°±è¯Šä¸­(30) çš„æ‚£è€…退回 **候诊中(20)**。
     *
     * @return å—影响行数(0 è¡¨ç¤ºå½“前没有就诊中的患者)
     */
    @Override
    public Integer undoCallPatient(Long roomId, String bedNo) {
        Integer updateNum = queueMapper.undoCallPatient(roomId, bedNo,
                QueueStatusEnum.ONSTAGE.getStatus(), QueueStatusEnum.READY.getStatus());
        if (null != updateNum && updateNum > 0) {
            startHurryUpOneBed(roomId, bedNo);
        }
        return updateNum;
    }
    @Override
    public QueueDO getBedLastFinishedQueueItem(Long roomId, String bedNo) {
        return queueMapper.getBedLastFinishedQueueItem(roomId, bedNo, QueueStatusEnum.FINISH.getStatus());
    }
    // ==================== éœ€æ±‚3:停止候诊开关 ====================
    @Override
    public ErrorCode setStopReady(Long roomId, String bedNo, boolean stop) {
        // èµ°å•线程执行器,与 bedDoctorPause/Resume ä¿æŒä¸€è‡´çš„串行化处理,
        // é¿å…ä¸Žåˆ†æµé˜Ÿåˆ—的并发修改冲突。
        Future<ErrorCode> future = singleThreadExecutor.submit(
                () -> queueServiceTxFunctions.setStopReady(roomId, bedNo, stop));
        try {
            return future.get();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.error("[setStopReady] è¢«ä¸­æ–­ roomId={} bedNo={}", roomId, bedNo, e);
        } catch (ExecutionException e) {
            log.error("[setStopReady] æ‰§è¡Œå¼‚常 roomId={} bedNo={}", roomId, bedNo, e);
        }
        return ECG_INNER_ERROR;
    }
    @Override
    public Boolean getStopReady(Long roomId, String bedNo) {
        return queueServiceTxFunctions.getStopReady(roomId, bedNo);
    }
    @Override
@@ -632,6 +757,10 @@
        queueStatusList.add(QueueStatusEnum.READY.getStatus());
        queueStatusList.add(QueueStatusEnum.ONSTAGE.getStatus());
        queueStatusList.add(QueueStatusEnum.PASSED.getStatus());
        // ã€éœ€æ±‚4 å£å¾„统一】补上 PASSED_WAITING(3)「已过号-排队」。
        // åŽŸå…ˆåˆ—è¡¨åªå« 5,而大屏/诊间屏查询含 3,导致同一患者在不同界面出现与否不一致;
        // ç»Ÿè®¡ä¾§ä¹Ÿå°† 3 è®¡å…¥"过号"(见 getPatientStatistic / getBedDevReadyStatistic)。
        queueStatusList.add(QueueStatusEnum.PASSED_WAITING.getStatus());
        queueStatusList.add(QueueStatusEnum.AFFINITY.getStatus());
        List<QueueDO> queueDOList = getBedQueueByStatus(roomId, bedNo, queueStatusList);
@@ -677,9 +806,31 @@
        queueStatusList.add(QueueStatusEnum.INSTALLING.getStatus());
        queueDOList.addAll(0, getBedQueueByStatus(roomId, bedNo, queueStatusList)); // å½“前工位的
        // ======================================================================
        // ã€ä¿®å¤ã€‘只保留「需要设备领用」的检查项目。
        //
        // èƒŒæ™¯ï¼šä¸Šé¢ä¸¤å¤„查询的条件是 `room_id = ? AND status IN (...)`
        // ï¼ˆgetRoomQueueByStatus ç”šè‡³**不含 bed_no**),**完全不看 book_check_type**。
        // è€Œè‡ªåŠ¨åˆ†æµï¼ˆpreemptWaitingPatientWithBedCheckTypes)是**按工位 checkTypes è¿‡æ»¤**的,
        // æ‰€ä»¥åªæœ‰"人工把患者拉进来"这条路径会漏 â€”— è¡¨çŽ°ä¸º
        // ã€Œè®¾å¤‡é¢†ç”¨ã€é¡µé”™è¯¯å‡ºçŽ°å¸¸è§„å¿ƒç”µå›¾(100)、平板运动心电(400)等**不需领用设备**的患者。
        //
        // åˆ¤å®šä¾æ®ç”¨é…ç½® `check_type.need_dev_ready`:
        //   1 = éœ€è¦è®¾å¤‡é¢†ç”¨ï¼ˆå¦‚ åŠ¨æ€å¿ƒç”µ 200、动态血压 300)
        //   0 = ä¸éœ€è¦ï¼ˆå¸¸è§„心电图、平板运动心电 â€¦ï¼‰
        //
        // æ”¾åœ¨æœåŠ¡å±‚è€Œä¸æ˜¯æ”¹ SQL:一次查询得到集合后在内存过滤,逻辑集中、易读,
        // ä¸”不需要为多数据源/动态表名额外调整 SQL。本诊室待安装患者量级很小(几十行)。
        // ======================================================================
        queueDOList = filterNeedDevReady(queueDOList);
        // æ³¨æ„ï¼šä¸‹é¢ç”¨ lambda,捕获的变量必须是 effectively final;
        // æ•…这里另起一个 final å˜é‡ï¼Œè€Œä¸æ˜¯å¤ç”¨ä¸Šé¢çš„ queueDOList(它已被重新赋值)。
        final List<QueueDO> installQueueList = queueDOList;
        //把 [亲和的] æŒªåˆ°ä¸»é¡¹åŽé¢
        List<QueueDO> affinityReceivedList = new ArrayList<>();
        ListIterator<QueueDO> iterator = queueDOList.listIterator();
        ListIterator<QueueDO> iterator = installQueueList.listIterator();
        while (iterator.hasNext()) {
            QueueDO item = iterator.next();
            if (item.getStatus() == QueueStatusEnum.AFFINITY_INSTALL.getStatus()) {
@@ -688,15 +839,52 @@
            }
        }
        affinityReceivedList.forEach(affinityReceivedItem -> {
            for (int i = queueDOList.size() - 1; i >= 0; i--) {
                if (affinityReceivedItem.getSeqNum() == queueDOList.get(i).getSeqNum()) {
                    queueDOList.add(i + 1, affinityReceivedItem);
            for (int i = installQueueList.size() - 1; i >= 0; i--) {
                if (affinityReceivedItem.getSeqNum() == installQueueList.get(i).getSeqNum()) {
                    installQueueList.add(i + 1, affinityReceivedItem);
                    break;
                }
            }
        });
        return queueDOList;
        return installQueueList;
    }
    /**
     * åªä¿ç•™ã€Œéœ€è¦è®¾å¤‡é¢†ç”¨ã€ï¼ˆ{@code check_type.need_dev_ready = 1})的检查项目的患者。
     *
     * <p>用于「设备领用 / è£…机」取数:这些工位只应处理需要领用设备的检查项目,
     * å¦åˆ™ä¼šå‡ºçŽ°å¸¸è§„å¿ƒç”µå›¾ç­‰æ‚£è€…æ··å…¥è®¾å¤‡æµç¨‹ã€‚
     *
     * <p>配置异常(查不到检查项目)时**返回原列表**,宁可多显示也不要把患者"藏起来"。
     */
    private List<QueueDO> filterNeedDevReady(List<QueueDO> queueDOList) {
        if (null == queueDOList || queueDOList.isEmpty()) {
            return queueDOList;
        }
        List<CheckTypeDO> needDevList = checkTypeMapper.selectList(
                new LambdaQueryWrapperX<CheckTypeDO>().eq(CheckTypeDO::getNeedDevReady, 1));
        if (null == needDevList || needDevList.isEmpty()) {
            log.warn("[filterNeedDevReady] æœªé…ç½®ä»»ä½•[需要设备领用]的检查项目,跳过过滤以免患者全部不可见");
            return queueDOList;
        }
        Set<Integer> needDevCheckTypes = new HashSet<>();
        needDevList.forEach(ct -> {
            if (null != ct.getValue()) {
                needDevCheckTypes.add(ct.getValue());
            }
        });
        List<QueueDO> filtered = new ArrayList<>();
        for (QueueDO item : queueDOList) {
            if (needDevCheckTypes.contains(item.getBookCheckType())) {
                filtered.add(item);
            } else {
                log.info("[filterNeedDevReady] è¿‡æ»¤æŽ‰ä¸éœ€è¦è®¾å¤‡é¢†ç”¨çš„æ‚£è€…. patId={} æ£€æŸ¥é¡¹ç›®={} çŠ¶æ€={}",
                        item.getPatId(), item.getBookCheckType(), item.getStatus());
            }
        }
        return filtered;
    }
    private RoomDO getRoomDO(Long roomId, String bedNo) {
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/queue/QueueServiceTxFunctions.java
@@ -194,6 +194,67 @@
        return GlobalErrorCodeConstants.SUCCESS;
    }
    // ==================== éœ€æ±‚3:停止候诊开关 ====================
    /**
     * è®¾ç½®ã€Œåœæ­¢å€™è¯Šã€å¼€å…³ã€‚
     * <p>
     * {@code stop=true}:该工位**不再新增**候诊患者(移出分流队列),
     * ä½†**已有候诊患者仍可正常叫号**({@code nextPatient} ä¸æ£€æŸ¥æ­¤å¼€å…³ï¼‰ã€‚
     * <p>
     * {@code stop=false}:恢复正常,重新加入分流队列开始接收患者
     * ï¼ˆå‰ææ˜¯å·¥ä½çŠ¶æ€ä¸º"医生在座",与 {@code bedReload} çš„判定一致)。
     * <p>
     * çŠ¶æ€å†™å…¥ {@code clinic_room.stop_ready},以在 {@code bedReload} åŽä¿æŒã€‚
     *
     * @return æˆåŠŸè¿”å›ž SUCCESS;工位不存在返回 {@link #QUEUE_BED_NOT_EXIST}
     */
    public ErrorCode setStopReady(Long roomId, String bedNo, boolean stop) {
        BedBO bedBO = mapOpeningBed.get( Utils.formatRoomBed(roomId, bedNo) );
        if (null == bedBO) {
            log.error("[setStopReady] å·¥ä½ä¸å­˜åœ¨. roomId={} bedNo={}", roomId, bedNo);
            return QUEUE_BED_NOT_EXIST;
        }
        // å…ˆè½åº“(失败则不改内存,避免状态不一致)
        //
        // ã€éƒ¨ç½²ä¿æŠ¤ã€‘若数据库尚未执行升级脚本(缺 clinic_room.stop_ready åˆ—),
        // roomMapper.setBedStopReady(...) ä¼šåœ¨ Mapper å±‚直接抛 SQL å¼‚常
        // ï¼ˆ@Update æ³¨è§£æ–¹æ³•没有返回值可判断,异常直接冒泡)。
        // è¿™é‡Œæ˜¾å¼æ•获并转成明确的业务错误码,避免把原始 SQL é”™è¯¯æš´éœ²ç»™å‰ç«¯ï¼Œ
        // ä¾¿äºŽçŽ°åœºä¸€çœ¼çœ‹å‡ºæ˜¯"部署遗漏"而不是"功能坏了"。
        Integer updateNum;
        try {
            updateNum = roomMapper.setBedStopReady(roomId, bedNo, stop ? 1 : 0);
        } catch (Exception e) {
            log.error("[setStopReady] å†™åº“失败,请确认已执行 _prep/11_需求升级_汇总SQL.sql"
                    + "(clinic_room.stop_ready). roomId={} bedNo={}", roomId, bedNo, e);
            return QUEUE_STOP_READY_NOT_CONFIGURED;
        }
        if (null == updateNum || 0 == updateNum) {
            // å·¥ä½å¯èƒ½å°šæœªåœ¨ clinic_room ç™»è®°ï¼Œä»…告警但仍允许改内存,
            // ä»¥å…å›  DB è®°å½•缺失导致医生完全无法使用该功能。
            log.warn("[setStopReady] DB æœªæ›´æ–°åˆ°è¡Œï¼Œä»…更新内存. roomId={} bedNo={} stop={}", roomId, bedNo, stop);
        }
        bedBO.setStopReady(stop);
        // åŒæ­¥åˆ†æµé˜Ÿåˆ—:停止 â†’ ç§»å‡ºï¼›æ¢å¤ â†’ åŠ å…¥ï¼ˆä»…åœ¨åŒ»ç”Ÿåœ¨åº§æ—¶ï¼‰
        if (stop) {
            removeBedFromRoutingQueue(bedBO);
        } else if (BedStatusEnum.DOCTOR_ON.getStatus().equals(bedBO.getStatus())) {
            addBedToRoutingQueue(bedBO);
        }
        log.info("[setStopReady] roomId={} bedNo={} stopReady={}", roomId, bedNo, stop);
        return GlobalErrorCodeConstants.SUCCESS;
    }
    /** æŸ¥è¯¢å½“前「停止候诊」状态(工位不存在返回 null) */
    public Boolean getStopReady(Long roomId, String bedNo) {
        BedBO bedBO = mapOpeningBed.get( Utils.formatRoomBed(roomId, bedNo) );
        return null == bedBO ? null : Boolean.TRUE.equals(bedBO.getStopReady());
    }
    public ErrorCode bedDoctorOn(Long roomId, String bedNo, Long docId, String docName) {
        BedBO bedBO = mapOpeningBed.get( Utils.formatRoomBed(roomId, bedNo) );
        if (null == bedBO) {
@@ -325,7 +386,12 @@
        bedBOList.forEach(item -> {
            mapOpeningBed.put( Utils.formatRoomBed(item.roomId, item.bedNo), item);
            if (BedStatusEnum.DOCTOR_ON.getStatus() == item.getStatus()) {
            // ã€éœ€æ±‚3】「停止候诊」的工位不加入分流队列 â€”— ä¸ŽåŒ»ç”Ÿåœ¨åº§(status=DOCTOR_ON)同样判定,
            // å¦åˆ™æ¯æ¬¡ bedReload(开诊/重启/诊室重置)后开关会失效、又开始收病人。
            // æ³¨æ„ï¼šstopReady ä»…阻止**新增**候诊患者,已有候诊患者仍可正常叫号
            // ï¼ˆnextPatient ä¸æ£€æŸ¥è¯¥å¼€å…³ï¼‰ã€‚
            if (BedStatusEnum.DOCTOR_ON.getStatus() == item.getStatus()
                    && !Boolean.TRUE.equals(item.getStopReady())) {
                addBedToRoutingQueue(item);
            }
        });
@@ -367,8 +433,13 @@
        BedBO bedBO = getBedQueueBO(roomId, bedNo);
        // ã€éœ€æ±‚3】「停止候诊」时不再把【已召回】患者放入候诊区。
        // æ³¨æ„ï¼šè¿™**不影响**已有候诊患者被正常叫号(nextPatient ä¸æ£€æŸ¥æœ¬å¼€å…³ï¼‰ï¼Œ
        // ä¹Ÿä¸å½±å“åŒ»ç”Ÿå¯¹å…·ä½“患者的"召回"操作本身(只影响其自动进入候诊的补位)。
        boolean stopReady = Boolean.TRUE.equals(bedBO.getStopReady());
        // å¤„理 è¿‡å·-回来 çš„人
        while (bedBO.getReadyPatNum() < bedBO.getMaxReadyPatNum()) {
        while (!stopReady && bedBO.getReadyPatNum() < bedBO.getMaxReadyPatNum()) {
            // æŸ¥çœ‹ å½“前工位 æ˜¯å¦æœ‰è¿‡å·-回来的患者
            Integer updateNum = queueMapper.queueRecalledPatient(
                    bedBO.getRoomId(),
@@ -410,6 +481,12 @@
     */
    public void hurryupOneCheckType(Integer checkType) {
        // ã€ä¿®å¤æ—¢æœ‰ç¼ºé™·ã€‘补上开诊门闸。原实现**唯独这里漏了** openingFlag æ£€æŸ¥
        // ï¼ˆhurryupAllBed ä¸Ž hurryupOneBed éƒ½æœ‰ï¼‰ï¼Œå¯¼è‡´æ‰‹åЍ"闭诊"后,
        // åªè¦æœ‰äººé¢„约确认 / åŠ æ€¥ï¼Œæ‚£è€…ä»ä¼šè¢«æŽ¨è¿›å€™è¯ŠåŒºã€‚
        if (0 == openingFlag.get())
            return;
        PriorityBlockingQueue<BedBO> routingQueue = mapCheckTypeRoutingQueue.get(checkType);
        if (null == routingQueue)
            return;
@@ -418,6 +495,18 @@
            BedBO bedBO = routingQueue.peek();
            if (null == bedBO)
                return;
            // ã€éœ€æ±‚3】跳过处于「停止候诊」的工位。
            // ä¸èƒ½ç›´æŽ¥ return â€”— è¯¥é˜Ÿåˆ—按 readyPatNum å‡åº peek,
            // ç›´æŽ¥ return ä¼šè®©å…¶ä½™å·¥ä½ä¹Ÿæ”¶ä¸åˆ°æ‚£è€…(饿死)。
            // è¿™é‡Œ poll å‡ºæ¥åŽè‹¥å‘½ä¸­åœæ­¢æ€ï¼Œä»…将其从队列移除(不再 offer å›žåŽ»ï¼‰ï¼Œ
            // ç„¶åŽ continue å¤„理下一个工位。
            if (Boolean.TRUE.equals(bedBO.getStopReady())) {
                routingQueue.poll();
                log.info("[hurryupOneCheckType] å·¥ä½[停止候诊],移出分流队列. roomId={} bedNo={}",
                        bedBO.getRoomId(), bedBO.getBedNo());
                continue;
            }
            int curQueueNum = bedBO.getReadyPatNum();
            if (curQueueNum > bedBO.getMaxReadyPatNum())
@@ -495,6 +584,11 @@
    }
    private void hurryupOneBedCheckTypeList(BedBO bedBO) {
        // ã€éœ€æ±‚3】「停止候诊」的工位不再新增候诊患者。
        // å·²æœ‰å€™è¯Šæ‚£è€…仍可正常叫号(本方法只负责"补齐候诊",不参与叫号)。
        if (Boolean.TRUE.equals(bedBO.getStopReady())) {
            return;
        }
        while (bedBO.getReadyPatNum() < bedBO.getMaxReadyPatNum()) {
            // æŠ¢å  æ˜¯å¦æœ‰æŽ’队中的患者
            Integer updateNum = queueMapper.preemptWaitingPatientWithBedCheckTypes(
@@ -575,6 +669,18 @@
        if (Objects.equals(BedOpTypeEnum.OP_INSTALL.getOpType(), bedBO.getOpType()))
            return;
        // ã€éœ€æ±‚3】「停止候诊」的工位不进分流队列。
        // è¿™æ˜¯**唯一入口**的统一门闸:只要工位被标记为停止候诊,
        // æ— è®ºä»Žå“ªæ¡è·¯å¾„(开诊 addBedToRoutingQueue、医生恢复、bedReload、
        // æˆ–手动恢复开关)试图加入,都会被拒绝,从而保证:
        //   Â· å·²æœ‰å€™è¯Šæ‚£è€…仍可正常叫号(nextPatient ä¸æ£€æŸ¥è¯¥å¼€å…³ï¼‰
        //   Â· ä½†ä¸ä¼šå†è¢«åˆ†é…ã€æŽ’队中】的新患者
        if (Boolean.TRUE.equals(bedBO.getStopReady())) {
            log.info("[addBedToRoutingQueue] å·¥ä½å¤„于[停止候诊],跳过加入分流队列. roomId={} bedNo={}",
                    bedBO.getRoomId(), bedBO.getBedNo());
            return;
        }
        // åŒæ­¥DB, è®¾ç½®å·¥ä½çš„分流控制信息
        List<Integer> queueStatusList = new ArrayList<>();
        queueStatusList.add(QueueStatusEnum.READY.getStatus()); //[候诊中]
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/queuesequence/QueueSequenceService.java
@@ -66,5 +66,25 @@
    Integer distributeSeqNo(Integer checkType, Integer timeslot);
    Integer distributeVipSeqNo(Integer checkType, Integer timeslot);
    /**
     * æŒ‰ã€æ£€æŸ¥ç±»åž‹é…ç½®çš„规则】分配排队序号(签到时调用)。
     *
     * <p>规则来自 {@code check_type.seq_rule},取值见
     * {@link cn.lihu.jh.module.ecg.enums.SeqRuleEnum}:
     * <ul>
     *   <li>{@code 0=预约号}   â€”— ç›´æŽ¥ç”¨é¢„约系统已下发的号({@code existingSeqNo}),不再分配;</li>
     *   <li>{@code 1=签到序号} â€”— å½“天同检查类型内连续递增,每 9 ä¸ªå·é¢„ç•™ 1 ä¸ªç»™åŠ æ€¥ï¼›</li>
     *   <li>{@code 2=时段号}   â€”— æŒ‰é¢„约时段从号池取号(改造前的原有行为,也是未配置时的默认)。</li>
     * </ul>
     *
     * @param checkType     æ£€æŸ¥ç±»åž‹å€¼ï¼ˆ{@code check_type.value})
     * @param timeslot      HIS ä¸‹å‘的预约时段(仅规则 2 ä½¿ç”¨ï¼‰
     * @param existingSeqNo é¢„约系统已下发的号(仅规则 0 ä½¿ç”¨ï¼›ä¸º null æ—¶å›žé€€åˆ°ç­¾åˆ°åºå·è§„则)
     * @param urgent        æ˜¯å¦åŠ æ€¥ï¼ˆ{@code jjbz=J})—— ä»…规则 1 ä½¿ç”¨ï¼šåŠ æ€¥å¯å ç”¨é¢„ç•™å·
     * @return åˆ†é…ç»™è¯¥æ‚£è€…的排队序号(从 1 å¼€å§‹ï¼‰
     */
    Integer distributeSeqNoByRule(Integer checkType, Integer timeslot,
                                 Integer existingSeqNo, boolean urgent);
    void initNumber();
}
jh-module-ecg/jh-module-ecg-biz/src/main/java/cn/lihu/jh/module/ecg/service/queuesequence/QueueSequenceServiceImpl.java
@@ -2,14 +2,18 @@
import cn.lihu.jh.module.ecg.Utils;
import cn.lihu.jh.module.ecg.dal.dataobject.checktype.CheckTypeDO;
import cn.lihu.jh.module.ecg.dal.dataobject.queuesequence.SeqCounterDO;
import cn.lihu.jh.module.ecg.dal.mysql.checktype.CheckTypeMapper;
import cn.lihu.jh.module.ecg.enums.SeqRuleEnum;
import cn.lihu.jh.module.infra.api.config.ConfigApi;
import cn.lihu.jh.module.system.api.dict.DictDataApi;
import cn.lihu.jh.module.system.api.dict.dto.DictDataRespDTO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.*;
import cn.lihu.jh.module.ecg.controller.admin.queuesequence.vo.*;
@@ -17,6 +21,7 @@
import cn.lihu.jh.framework.common.pojo.PageResult;
import cn.lihu.jh.framework.common.pojo.PageParam;
import cn.lihu.jh.framework.common.util.object.BeanUtils;
import cn.lihu.jh.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.lihu.jh.module.ecg.dal.mysql.queuesequence.QueueSequenceMapper;
@@ -33,7 +38,16 @@
 */
@Service
@Validated
@Slf4j
public class QueueSequenceServiceImpl implements QueueSequenceService {
    /**
     * å‘后查找可用时段的最大步数(约 3 å°æ—¶ï¼‰ã€‚
     * <p>
     * å– 6 çš„原因:字典时段为 30 åˆ†é’Ÿç²’度,6 ä¸ªçº¦ 3 å°æ—¶ï¼›
     * æ—¢èƒ½è·¨è¿‡"少数时段缺失/已满"的情况,又不会把上午的患者排到下午造成体验问题。
     */
    private static final int MAX_TIMESLOT_LOOKAHEAD = 6;
    @Resource
    private ConfigApi configApi;
@@ -47,6 +61,23 @@
    @Resource
    private CheckTypeMapper checkTypeMapper;
    /**
     * ç”¨äºŽæŠŠã€Œè®¡æ•°å™¨æŽ¨è¿› + è¯»å›žã€æ˜¾å¼åŒ…è¿›**同一个事务**。
     *
     * <h4>为什么必须用 TransactionTemplate è€Œä¸æ˜¯ @Transactional(重要)</h4>
     * è¿™ä¸¤æ­¥åœ¨ {@link #nextCheckInSeqNo} è¿™ä¸ª **private æ–¹æ³•**里,且由**同类内部调用**
     * ï¼ˆ{@code distributeSeqNoByRule} â†’ {@code nextCheckInSeqNo})。
     * Spring çš„ {@code @Transactional} åŸºäºŽä»£ç†ï¼Œ**同类自调用不会走代理 â‡’ æ³¨è§£å®Œå…¨ä¸ç”Ÿæ•ˆ**。
     * äºŽæ˜¯"推进"与"读回"会各自独立提交,在 REPEATABLE READ ä¸‹è¯»åˆ°çš„可能是旧快照
     * â‡’ **并发重号**(早先实测:独立事务只有 75/160 ä¸ªå”¯ä¸€å€¼ï¼›åŒäº‹åŠ¡ä¸º 160/160)。
     *
     * æ•…这里用 {@link TransactionTemplate} **显式**开启事务,不依赖 AOP ä»£ç†ï¼Œå¿…然生效。
     * ä¼ æ’­è¡Œä¸ºç”¨é»˜è®¤çš„ REQUIRED:外层已有事务(如 {@code distributeSeqNoByRule}
     * æ ‡æ³¨äº† {@code @Transactional})时并入,没有时新建。
     */
    @Resource
    private org.springframework.transaction.support.TransactionTemplate counterTransactionTemplate;
    @Override
    public void initQueueSequenceTable() {
        queueSequenceMapper.clearQueueSequenceTableNotCurrent();
@@ -56,8 +87,17 @@
        }
    }
    /**
     * æ–°å¢žé¢„约时段规则。
     *
     * <p>校验「同一检查类型 + åŒä¸€æ—¶æ®µã€ä¸é‡å¤ â€”— è¡¨ä¸Šæ²¡æœ‰è¯¥å”¯ä¸€çº¦æŸï¼ˆåŽ†å²é—ç•™ï¼‰ï¼Œ
     * è€Œå–号 SQL {@code select queue_no ... where check_type=? and time_slot=? for update}
     * æœŸæœ›åªå‘½ä¸­ä¸€è¡Œï¼›ä¸€æ—¦å‡ºçŽ°é‡å¤è¡Œï¼Œå–å·ä¼šå‘½ä¸­ä¸ç¡®å®šçš„ä¸€è¡Œã€‚
     * è¿™é‡Œåœ¨ç»´æŠ¤å…¥å£å¤„拦截,给出明确提示而不是让问题进入取号阶段。
     */
    @Override
    public Integer createQueueSequence(QueueSequenceSaveReqVO createReqVO) {
        validateTimeslotNotDuplicated(createReqVO.getCheckType(), createReqVO.getTimeSlot(), null);
        // æ’å…¥
        QueueSequenceDO queueSequence = BeanUtils.toBean(createReqVO, QueueSequenceDO.class);
        queueSequenceMapper.insert(queueSequence);
@@ -69,9 +109,32 @@
    public void updateQueueSequence(QueueSequenceSaveReqVO updateReqVO) {
        // æ ¡éªŒå­˜åœ¨
        validateQueueSequenceExists(updateReqVO.getId());
        // ã€æ ¡éªŒã€‘改了 checkType æˆ– timeSlot æ—¶ä¸èƒ½ä¸Žå…¶å®ƒè¡Œæ’žè½¦ï¼ˆæŽ’除自身)
        validateTimeslotNotDuplicated(updateReqVO.getCheckType(), updateReqVO.getTimeSlot(),
                updateReqVO.getId());
        // æ›´æ–°
        QueueSequenceDO updateObj = BeanUtils.toBean(updateReqVO, QueueSequenceDO.class);
        queueSequenceMapper.updateById(updateObj);
    }
    /**
     * æ ¡éªŒã€Œæ£€æŸ¥ç±»åž‹ + æ—¶æ®µã€åœ¨è¡¨ä¸­å”¯ä¸€ã€‚
     *
     * @param excludeId éœ€æŽ’除的行 id(更新时传自身 id;新增时传 null)
     */
    private void validateTimeslotNotDuplicated(Integer checkType, Integer timeSlot, Integer excludeId) {
        if (null == checkType || null == timeSlot) {
            return;   // ç”± VO ä¸Šçš„ @NotNull å…œåº•,这里不重复报错
        }
        QueueSequenceDO exist = queueSequenceMapper.selectOne(
                new LambdaQueryWrapperX<QueueSequenceDO>()
                        .eq(QueueSequenceDO::getCheckType, checkType)
                        .eq(QueueSequenceDO::getTimeSlot, timeSlot));
        if (null != exist && !exist.getId().equals(excludeId)) {
            log.warn("[validateTimeslotNotDuplicated] æ—¶æ®µè§„则重复. checkType={} timeSlot={} "
                            + "已存在id={} æœ¬æ¬¡id={}", checkType, timeSlot, exist.getId(), excludeId);
            throw exception(QUEUE_SEQUENCE_TIMESLOT_DUPLICATE);
        }
    }
    @Override
@@ -138,14 +201,30 @@
    public Integer distributeSeqNo(Integer checkType, Integer timeslot) {
        Integer curSeqNo = queueSequenceMapper.selectQueueNoForUpdate(checkType, timeslot);
        if (null == curSeqNo) {
            // ä»Žä¸‹ä¸€ä¸ªæ—¶æ®µ èŽ·å–åºå·ã€‚åŽŸå› ç³»ç»Ÿç”¨30分钟的间隔划分预约时间段,HIS的预约时间段可能是30分钟或60分钟
            Integer nextTimeslot = getNextTimeSlot(timeslot, checkType);
            // ã€ä¿®å¤ã€Œæ—¶æ®µä¸å…¨/已满导致没法签到」】
            // åŽŸé€»è¾‘ï¼šåªå–"字典里的紧邻下一个时段",且要求该时段在 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);
            }
            curSeqNo = queueSequenceMapper.selectQueueNoForUpdate(checkType, nextTimeslot);
            if (null == curSeqNo) {
                // æŽ¢æµ‹ä¸Žå–号之间被并发抢空,保守报错(由前端提示重新签到)
                log.warn("[distributeSeqNo] æŽ¢æµ‹åˆ°çš„æ—¶æ®µéšå³è¢«å æ»¡. checkType={} timeslot={}",
                        checkType, nextTimeslot);
                throw exception(QUEUE_SEQUENCE_TIMESLOT_FULL);
            }
@@ -180,6 +259,215 @@
        return curVipSeqNo + 1;
    }
    /**
     * æŒ‰ã€æ£€æŸ¥ç±»åž‹é…ç½®çš„规则】分配排队序号。
     *
     * <h3>三种规则</h3>
     * <ol>
     *   <li><b>预约号(0)</b>:直接用预约系统已下发的号,不再分配。
     *       æ³¨æ„ {@code existingSeqNo} ä¸ºç©ºæ—¶**回退到签到序号规则** â€”— å› ä¸ºè‹¥ç›´æŽ¥è¿”回
     *       null,调用方会把 null å†™å…¥ {@code book_seq_num},患者将拿不到号。</li>
     *   <li><b>签到序号(1)</b>:当天同检查类型内连续递增(第 1 ä¸ªç­¾åˆ° = 1 å·ï¼‰ï¼Œ
     *       å¹¶æŒ‰ã€Œæ¯ 9 ä¸ªå·é¢„ç•™ 1 ä¸ªã€è·³è¿‡é¢„留号给加急患者。
     *       **不设当日上限**(与规则 2 çš„æ—¶æ®µå®¹é‡ä¸åŒï¼‰ã€‚</li>
     *   <li><b>时段号(2)</b>:调用原有的 {@link #distributeSeqNo}(按 {@code time_slot} ä»Žå·æ± å–号),
     *       ä¿ç•™æ—¶æ®µå®¹é‡æŽ§åˆ¶ã€‚这也是 {@code seq_rule} æœªé…ç½®æ—¶çš„默认值。</li>
     * </ol>
     *
     * <h3>为什么规则 1 éœ€è¦ç‹¬ç«‹è®¡æ•°å™¨</h3>
     * ä¸èƒ½ç”¨ {@code MAX(book_seq_num)+1} è®¡ç®—:预留号(10、20…)若无人使用就不会出现在
     * æ•°æ®é‡Œï¼Œä¸‹ä¸€ä¸ªæ™®é€šå·æŒ‰ MAX+1 ä¼šå†æ¬¡ç®—出同一个预留号,造成冲突。
     * å› æ­¤ç”¨ {@code seq_counter} è¡¨**必须被消耗**地递增(见 Mapper çš„ LAST_INSERT_ID å†™æ³•)。
     */
    @Override
    @Transactional
    public Integer distributeSeqNoByRule(Integer checkType, Integer timeslot,
                                        Integer existingSeqNo, boolean urgent) {
        SeqRuleEnum rule = resolveRule(checkType);
        switch (rule) {
            case APPOINT:
                // â‘  é¢„约号:预约系统已给号则直接用;否则回退到签到序号(避免患者拿不到号)
                if (null != existingSeqNo && existingSeqNo > 0) {
                    log.info("[distributeSeqNoByRule] è§„则=预约号,直接使用预约系统号. checkType={} seqNo={}",
                            checkType, existingSeqNo);
                    return existingSeqNo;
                }
                log.warn("[distributeSeqNoByRule] è§„则=预约号,但预约系统未下发号,回退到签到序号. checkType={}",
                        checkType);
                return nextCheckInSeqNo(checkType, urgent);
            case CHECKIN:
                // â‘¡ ç­¾åˆ°åºå·ï¼šå½“天同检查类型连续递增,每 9 ä¸ªé¢„ç•™ 1 ä¸ª
                return nextCheckInSeqNo(checkType, urgent);
            case TIMESLOT:
            default:
                // â‘¢ æ—¶æ®µå·ï¼šä¿æŒæ”¹é€ å‰çš„行为(含时段容量控制)
                return distributeSeqNo(checkType, timeslot);
        }
    }
    /**
     * è§£æžæŸæ£€æŸ¥ç±»åž‹é…ç½®çš„æŽ’队序号规则;查不到配置或未配置时返回默认(时段号)。
     */
    private SeqRuleEnum resolveRule(Integer checkType) {
        if (null == checkType) {
            return SeqRuleEnum.DEFAULT;
        }
        CheckTypeDO checkTypeDO = checkTypeMapper.getCheckTypeByValue(checkType);
        if (null == checkTypeDO) {
            log.warn("[resolveRule] æœªæ‰¾åˆ°æ£€æŸ¥ç±»åž‹é…ç½®ï¼Œä½¿ç”¨é»˜è®¤è§„则(时段号). checkType={}", checkType);
            return SeqRuleEnum.DEFAULT;
        }
        return SeqRuleEnum.parse(checkTypeDO.getSeqRule());
    }
    /**
     * è®¡ç®—下一个「签到序号」。
     *
     * <h4>两个独立计数器(这是踩坑后的结论)</h4>
     * {@code seq_counter} ç»´æŠ¤ä¸¤ä¸ªäº’不干扰的计数器:
     * <ul>
     *   <li>{@code normal_next} â€”— æ™®é€šå·ï¼Œåºåˆ— 1..9、11..19、21..29…(跳过 10 çš„倍数);</li>
     *   <li>{@code reserved_next} â€”— é¢„留号(加急用),序列 10、20、30…。</li>
     * </ul>
     * <b>为什么不能只用一个计数器</b>:若用单个 {@code candidate} é€’增后再"跳过预留号",
     * å¿…然撞车 â€”— æ™®é€šæ‚£è€…拿到 candidate=10 â†’ è·³è¿‡å¾— 11;下一个普通患者拿到 candidate=11
     * â†’ ä¹Ÿå¾— 11。实测 20 æ¬¡å–号中第 10、11 æ¬¡åˆ†é…å·ç›¸åŒã€‚
     * ä¸¤ä¸ªè®¡æ•°å™¨ç‰©ç†éš”离后,二者取值域不相交,**不可能重号**。
     *
     * <h4>并发(两处坑,均已修复)</h4>
     * <ol>
     *   <li><b>推进必须原子</b>:用单条 {@code INSERT .. ON DUPLICATE KEY UPDATE}
     *       åœ¨æ•°æ®åº“端完成"读-改-写",避免先查后改造成重号。
     *       æ³¨æ„è¯¥è¯­å¥å¿…须加 {@code @InterceptorIgnore(dataPermission="true")},
     *       å¦åˆ™ MyBatis-Plus çš„ DataPermissionInterceptor ä¼šç”¨ JSqlParser è§£æžå®ƒå¹¶
     *       **抛 UnsupportedOperationException**(JSqlParser ä¸æ”¯æŒè¯¥ MySQL ä¸“有语法),
     *       è¡¨çŽ°ä¸º**预约确认直接 500** â€”— è¯¦è§ Mapper ä¸Šçš„æ³¨é‡Šã€‚</li>
     *   <li><b>推进与读回必须同事务</b>:MySQL é»˜è®¤ REPEATABLE READ,
     *       è‹¥è¯»å›žåœ¨å¦ä¸€ä¸ªäº‹åŠ¡é‡Œï¼Œä¼šè¯»åˆ°æ—§å¿«ç…§ â‡’ é‡å·ã€‚
     *       æœ¬æ–¹æ³•是 private ä¸”被**同类内部调用**,{@code @Transactional} å› ä¸èµ°ä»£ç†è€Œ**失效**,
     *       æ•…改用 {@link org.springframework.transaction.support.TransactionTemplate} æ˜¾å¼å¼€å¯äº‹åŠ¡ã€‚</li>
     * </ol>
     * å®žæµ‹ 8 çº¿ç¨‹ Ã— 20 æ¬¡ = 160 æ¬¡å–号:同事务 160/160 å”¯ä¸€ã€å·å€¼è¿žç»­ï¼›
     * ç‹¬ç«‹äº‹åŠ¡åªæœ‰ 75/160 â€”— è¯¦è§ Mapper æ³¨é‡Šã€‚
     */
    private Integer nextCheckInSeqNo(Integer checkType, boolean urgent) {
        final LocalDate today = LocalDate.now();
        // ç”¨æ˜¾å¼äº‹åŠ¡æŠŠã€ŒæŽ¨è¿›ã€ä¸Žã€Œè¯»å›žã€åŒ…åœ¨ä¸€èµ·ï¼ˆ@Transactional åœ¨æ­¤å¤„不生效,见方法注释)
        Integer assigned = counterTransactionTemplate.execute(status -> {
            if (urgent) {
                queueSequenceMapper.advanceReservedSeq(checkType, today);
            } else {
                queueSequenceMapper.advanceNormalSeq(checkType, today);
            }
            SeqCounterDO counter = queueSequenceMapper.getSeqCounter(checkType, today);
            if (null == counter) {
                return null;
            }
            return urgent ? counter.getReservedNext() : counter.getNormalNext();
        });
        if (null == assigned) {
            // ç†è®ºä¸Šä¸ä¼šå‘生(刚推进过);保守回退,避免抛错阻断签到
            assigned = urgent ? SeqRuleEnum.ROUND_SIZE : 1;
            log.error("[nextCheckInSeqNo] è®¡æ•°å™¨è¯»å–失败,回退为默认号. checkType={} åŠ æ€¥={} å›žé€€å·={}",
                    checkType, urgent, assigned);
        }
        log.info("[nextCheckInSeqNo] è§„则=签到序号. checkType={} åŠ æ€¥={} æœ¬æ¬¡åˆ†é…={}",
                checkType, urgent, assigned);
        return assigned;
    }
    /**
     * å‘后查找一个**仍有余量**的预约时段。
     * <p>
     * ä¸Žæ—§çš„ {@code getNextTimeSlot} çš„区别:
     * <ol>
     *   <li>旧实现只看"紧邻的下一个"字典项,且**不检查该时段在 queue_sequence ä¸­æ˜¯å¦å­˜åœ¨**
     *       â€”— è‹¥ä¸‹ä¸€ä¸ªæ—¶æ®µæ°å¥½ç¼ºå¤±ï¼ˆå·æ± æ²¡æœ‰è¿™ä¸€è¡Œï¼‰ï¼Œä¼šç›´æŽ¥è¿”回它,随后
     *       {@code selectQueueNoForUpdate} å–不到值 â†’ æŠ› TIMESLOT_FULL。
     *       è¿™ä¹Ÿæ˜¯"运动试验没法签到"的直接原因之一。</li>
     *   <li>旧实现在 {@code timeslot} ä¸åœ¨å­—典中时(HIS ä¼šä¸‹å‘ 60 åˆ†é’Ÿç²’度值,
     *       å¦‚ 10001100,而字典只有 30 åˆ†é’Ÿç²’度)直接 {@code return null}。
     *       æœ¬å®žçŽ°æ”¹ä¸º**取字典中第一个晚于 {@code timeslot} çš„æ—¶æ®µ**继续向后找,
     *       ä»Žè€Œä»èƒ½ä¸ºè¿™ç±»æ‚£è€…分配号。</li>
     *   <li>最多向后找 {@link #MAX_TIMESLOT_LOOKAHEAD} ä¸ªæ—¶æ®µï¼ˆçº¦ 3 å°æ—¶ï¼‰ï¼Œ
     *       é¿å…"当前是最后一个时段"时无处可去;全都满才返回 null。</li>
     * </ol>
     *
     * @param checkType  æ£€æŸ¥ç±»åž‹
     * @param timeslot   HIS ä¼ å…¥çš„预约时段(可能是字典外的 60 åˆ†é’Ÿç²’度值)
     * @param includeVip true æ—¶è¦æ±‚ VIP å·ä¹Ÿæœ‰ä½™é‡
     * @return å¯ç”¨çš„æ—¶æ®µå€¼ï¼›æ— å¯ç”¨æ—¶æ®µè¿”回 null
     */
    private Integer findAvailableTimeslot(Integer checkType, Integer timeslot, boolean includeVip) {
        List<DictDataRespDTO> dictBookTimeslotList = dictDataApi.getDictDataList(ECG_BOOK_TIMESLOT);
        if (dictBookTimeslotList == null || dictBookTimeslotList.isEmpty()) {
            log.error("[findAvailableTimeslot] å­—å…¸ {} ä¸ºç©ºï¼Œæ— æ³•分配时段号", ECG_BOOK_TIMESLOT);
            return null;
        }
        // èµ·ç‚¹ï¼šä¸¥æ ¼æ™šäºŽ timeslot çš„第一个字典项。
        // è¿™æ ·å³ä½¿ timeslot ä¸åœ¨å­—典内(60 åˆ†é’Ÿç²’度值),也能落到其后的时段继续找。
        int startIdx = -1;
        for (int i = 0; i < dictBookTimeslotList.size(); i++) {
            Integer v = parseSlotValue(dictBookTimeslotList.get(i).getValue());
            if (null != v && v > timeslot) {
                startIdx = i;
                break;
            }
        }
        if (startIdx < 0) {
            // timeslot å·²æ˜¯æœ€åŽä¸€ä¸ªæ—¶æ®µï¼ˆæˆ–全部时段都早于它)
            log.warn("[findAvailableTimeslot] å·²æ˜¯æœ€åŽä¸€ä¸ªæ—¶æ®µæˆ–无更晚时段. checkType={} timeslot={}",
                    checkType, timeslot);
            return null;
        }
        int end = Math.min(dictBookTimeslotList.size(), startIdx + MAX_TIMESLOT_LOOKAHEAD);
        for (int i = startIdx; i < end; i++) {
            Integer candidate = parseSlotValue(dictBookTimeslotList.get(i).getValue());
            if (null == candidate) {
                continue;
            }
            Integer avail = queueSequenceMapper.countAvailableTimeslot(
                    checkType, candidate, includeVip ? 1 : 0);
            if (null != avail && avail > 0) {
                log.info("[findAvailableTimeslot] checkType={} åŽŸæ—¶æ®µ={} â†’ æ”¹ç”¨æ—¶æ®µ={}(第 {} ä¸ªå€™é€‰ï¼‰",
                        checkType, timeslot, candidate, i - startIdx + 1);
                return candidate;
            }
        }
        log.warn("[findAvailableTimeslot] å‘后 {} ä¸ªæ—¶æ®µå†…均无余量. checkType={} timeslot={}",
                MAX_TIMESLOT_LOOKAHEAD, checkType, timeslot);
        return null;
    }
    /** å®‰å…¨è§£æžå­—典值(形如 "9301000")为 Integer,忽略脏数据 */
    private Integer parseSlotValue(String value) {
        if (null == value || value.isEmpty()) {
            return null;
        }
        try {
            return Integer.valueOf(value.trim());
        } catch (NumberFormatException e) {
            log.warn("[parseSlotValue] å­—典时段值非法,已忽略: {}", value);
            return null;
        }
    }
    /**
     * æ—§çš„"取紧邻下一个时段"。保留供参考/兼容,新逻辑请用
     * {@link #findAvailableTimeslot(Integer, Integer, boolean)}。
     *
     * @deprecated åªçœ‹ä¸‹ä¸€ä¸ªæ—¶æ®µä¸”不校验号池是否存在,会导致签到失败
     */
    @Deprecated
    private Integer getNextTimeSlot(Integer timeslot, Integer checkType) {
        List<DictDataRespDTO> dictBookTimeslotList = dictDataApi.getDictDataList(ECG_BOOK_TIMESLOT);
        Optional<Integer> index = dictBookTimeslotList.stream()
jh-module-system/jh-module-system-api/src/main/java/cn/lihu/jh/module/system/api/mail/MailSendApi.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-api/src/main/java/cn/lihu/jh/module/system/api/mail/dto/MailSendSingleToUserReqDTO.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-api/src/main/java/cn/lihu/jh/module/system/api/notify/NotifyMessageSendApi.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-api/src/main/java/cn/lihu/jh/module/system/api/notify/dto/NotifySendSingleToUserReqDTO.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-api/src/main/java/cn/lihu/jh/module/system/api/notify/dto/NotifyTemplateReqDTO.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-api/src/main/java/cn/lihu/jh/module/system/enums/mail/MailSendStatusEnum.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-api/src/main/java/cn/lihu/jh/module/system/enums/notify/NotifyTemplateTypeEnum.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/api/mail/MailSendApiImpl.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/api/notify/NotifyMessageSendApiImpl.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/MailAccountController.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/MailLogController.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/MailTemplateController.http
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/MailTemplateController.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/vo/account/MailAccountPageReqVO.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/vo/account/MailAccountRespVO.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/vo/account/MailAccountSaveReqVO.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/vo/account/MailAccountSimpleRespVO.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/vo/log/MailLogPageReqVO.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/vo/log/MailLogRespVO.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/vo/template/MailTemplatePageReqVO.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/vo/template/MailTemplateRespVO.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/vo/template/MailTemplateSaveReqVO.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/vo/template/MailTemplateSendReqVO.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/mail/vo/template/MailTemplateSimpleRespVO.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/notify/NotifyMessageController.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/notify/NotifyTemplateController.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/notify/vo/message/NotifyMessageMyPageReqVO.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/notify/vo/message/NotifyMessagePageReqVO.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/notify/vo/message/NotifyMessageRespVO.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/notify/vo/template/NotifyTemplatePageReqVO.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/notify/vo/template/NotifyTemplateRespVO.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/notify/vo/template/NotifyTemplateSaveReqVO.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/controller/admin/notify/vo/template/NotifyTemplateSendReqVO.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/dal/dataobject/mail/MailAccountDO.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/dal/dataobject/mail/MailLogDO.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/dal/dataobject/mail/MailTemplateDO.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/dal/dataobject/notify/NotifyMessageDO.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/dal/dataobject/notify/NotifyTemplateDO.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/dal/mysql/mail/MailAccountMapper.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/dal/mysql/mail/MailLogMapper.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/dal/mysql/mail/MailTemplateMapper.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/dal/mysql/notify/NotifyMessageMapper.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/dal/mysql/notify/NotifyTemplateMapper.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/dal/redis/RedisKeyConstants.java
@@ -68,30 +68,6 @@
    String OAUTH2_ACCESS_TOKEN = "oauth2_access_token:%s";
    /**
     * ç«™å†…信模版的缓存
     * <p>
     * KEY æ ¼å¼ï¼šnotify_template:{code}
     * VALUE æ•°æ®æ ¼å¼ï¼šString æ¨¡ç‰ˆä¿¡æ¯
     */
    String NOTIFY_TEMPLATE = "notify_template";
    /**
     * é‚®ä»¶è´¦å·çš„缓存
     * <p>
     * KEY æ ¼å¼ï¼šmail_account:{id}
     * VALUE æ•°æ®æ ¼å¼ï¼šString è´¦å·ä¿¡æ¯
     */
    String MAIL_ACCOUNT = "mail_account";
    /**
     * é‚®ä»¶æ¨¡ç‰ˆçš„缓存
     * <p>
     * KEY æ ¼å¼ï¼šmail_template:{code}
     * VALUE æ•°æ®æ ¼å¼ï¼šString æ¨¡ç‰ˆä¿¡æ¯
     */
    String MAIL_TEMPLATE = "mail_template";
    /**
     * çŸ­ä¿¡æ¨¡ç‰ˆçš„缓存
     * <p>
     * KEY æ ¼å¼ï¼šsms_template:{id}
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/mq/consumer/mail/MailSendConsumer.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/mq/message/mail/MailSendMessage.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/mq/producer/mail/MailProducer.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/service/mail/MailAccountService.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/service/mail/MailAccountServiceImpl.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/service/mail/MailLogService.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/service/mail/MailLogServiceImpl.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/service/mail/MailSendService.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/service/mail/MailSendServiceImpl.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/service/mail/MailTemplateService.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/service/mail/MailTemplateServiceImpl.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/service/notify/NotifyMessageService.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/service/notify/NotifyMessageServiceImpl.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/service/notify/NotifySendService.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/service/notify/NotifySendServiceImpl.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/service/notify/NotifyTemplateService.java
ÎļþÒÑɾ³ý
jh-module-system/jh-module-system-biz/src/main/java/cn/lihu/jh/module/system/service/notify/NotifyTemplateServiceImpl.java
ÎļþÒÑɾ³ý
jh-server/pom.xml
@@ -148,6 +148,38 @@
    <build>
        <!-- è®¾ç½®æž„建的 jar åŒ…名 -->
        <finalName>${project.artifactId}</finalName>
        <!--
            åˆå¹¶éƒ¨ç½²ï¼šæŠŠ ecg_web çš„前端构建产物打进 jar。
            Â· ç›®æ ‡ç›®å½•选 META-INF/resources/frontend è€Œéž static:
              META-INF/resources æ˜¯ Servlet 3.0 æ ‡å‡†é™æ€èµ„源目录,Spring Boot
              é»˜è®¤ä¼šæ‰«æï¼›é¢å¤–加一层 frontend/ å‰ç¼€ï¼Œä¾¿äºŽåŽç«¯ç”¨
              addResourceHandlers ç²¾ç¡®æŽ§åˆ¶"哪些 URL äº¤ç»™å‰ç«¯",
              ä¸å½±å“åŽç«¯å·²æœ‰çš„ /doc.html、/druid/**、/profile/** ç­‰è·¯å¾„。
              è§ cn.lihu.jh.server.ecg.FrontendResourceConfig。
            Â· äº§ç‰©ç›®å½•ç”± profile æ³¨å…¥çš„ env.VITE_OUT_DIR å†³å®šï¼Œ
              é»˜è®¤ dist-merged(对应 ecg_web çš„ `pnpm build:merged`)。
            Â· æ³¨æ„ï¼šæœ¬æ¨¡å—【不】引入 frontend-maven-plugin。
              å‰ç«¯æž„建请在部署前单独执行(cd ecg_web && pnpm build:merged),
              é¿å…åŽç«¯æž„建强依赖 node/pnpm,也避免构建机需要外网下载 node。
        -->
        <resources>
            <!-- ä¿ç•™åŽŸæœ‰çš„åŽç«¯èµ„æºï¼ˆapplication*.yaml、logback-spring.xml ç­‰ï¼‰ -->
            <resource>
                <directory>src/main/resources</directory>
                <filtering>false</filtering>
            </resource>
            <!-- æ–°å¢žï¼šå‰ç«¯äº§ç‰© -->
            <resource>
                <directory>${project.basedir}/../../ecg_web/${env.VITE_OUT_DIR}</directory>
                <targetPath>META-INF/resources/frontend</targetPath>
                <filtering>false</filtering>
            </resource>
        </resources>
        <plugins>
            <!-- æ‰“包 -->
            <plugin>
@@ -165,4 +197,31 @@
        </plugins>
    </build>
    <!--
        å‰ç«¯äº§ç‰©ç›®å½•的选择。env.VITE_OUT_DIR ä¼šè¢«ä¸Šé¢ <resources> å¼•用。
        Â· frontend-merged(默认):使用 ecg_web æ‰§è¡Œ `pnpm build:merged` äº§å‡ºçš„
          dist-merged,对应"前端打进 jar、与后端同源"的合并部署模式。
        Â· frontend-prod:回退到原有的 dist-prod。若前端仍由 nginx ç‹¬ç«‹æ‰˜ç®¡
          ï¼ˆå³ä¸åšåˆå¹¶éƒ¨ç½²ï¼‰ï¼Œç”¨ -P frontend-prod æ¿€æ´»æ­¤ profile å³å¯ï¼Œ
          æ­¤æ—¶ jar å†…不含前端,行为与改造前一致。
    -->
    <profiles>
        <profile>
            <id>frontend-merged</id>
            <activation>
                <activeByDefault>true</activeByDefault>
            </activation>
            <properties>
                <env.VITE_OUT_DIR>dist-merged</env.VITE_OUT_DIR>
            </properties>
        </profile>
        <profile>
            <id>frontend-prod</id>
            <properties>
                <env.VITE_OUT_DIR>dist-prod</env.VITE_OUT_DIR>
            </properties>
        </profile>
    </profiles>
</project>
jh-server/src/main/resources/application-local.yaml
@@ -1,5 +1,5 @@
server:
  host: 192.168.2.100
  host: localhost
  port: 48080
--- #################### åŒ»é™¢å¹³å°æœåŠ¡åœ°å€ ####################
@@ -51,16 +51,16 @@
      primary: master
      datasource:
        master:
          url: jdbc:mysql://192.168.2.200:3306/lihu?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true # MySQL Connector/J 8.X è¿žæŽ¥çš„示例
          #          url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=true&allowPublicKeyRetrieval=true&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai # MySQL Connector/J 5.X è¿žæŽ¥çš„示例
          url: jdbc:mysql://47.93.127.38:3308/lihu?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true # MySQL Connector/J 8.X è¿žæŽ¥çš„示例
#          url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=true&allowPublicKeyRetrieval=true&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai # MySQL Connector/J 5.X è¿žæŽ¥çš„示例
          #          url: jdbc:postgresql://127.0.0.1:5432/ruoyi-vue-pro # PostgreSQL è¿žæŽ¥çš„示例
          #          url: jdbc:oracle:thin:@127.0.0.1:1521:xe # Oracle è¿žæŽ¥çš„示例
          #          url: jdbc:sqlserver://127.0.0.1:1433;DatabaseName=ruoyi-vue-pro;SelectMethod=cursor;encrypt=false;rewriteBatchedStatements=true;useUnicode=true;characterEncoding=utf-8 # SQLServer è¿žæŽ¥çš„示例
          #          url: jdbc:dm://127.0.0.1:5236?schema=RUOYI_VUE_PRO # DM è¿žæŽ¥çš„示例
          #          url: jdbc:kingbase8://127.0.0.1:54321/test  # äººå¤§é‡‘仓 KingbaseES è¿žæŽ¥çš„示例
          #          url: jdbc:postgresql://127.0.0.1:5432/postgres # OpenGauss è¿žæŽ¥çš„示例
          username: root
          password: Smartor.2023
          username: smartor
          password: Smartor.2016
          #          username: sa # SQL Server è¿žæŽ¥çš„示例
          #          password: Jh@2024 # SQL Server è¿žæŽ¥çš„示例
          #          username: SYSDBA # DM è¿žæŽ¥çš„示例
@@ -69,16 +69,16 @@
          #          password: Jh@2024 # OpenGauss è¿žæŽ¥çš„示例
        slave: # æ¨¡æ‹Ÿä»Žåº“,可根据自己需要修改
          lazy: true # å¼€å¯æ‡’加载,保证启动速度
          url: jdbc:mysql://192.168.2.200:3306/lihu?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true # MySQL Connector/J 8.X è¿žæŽ¥çš„示例
          username: root
          password: Smartor.2023
          url: jdbc:mysql://47.93.127.38:3308/lihu?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true # MySQL Connector/J 8.X è¿žæŽ¥çš„示例
          username: smartor
          password: Smartor.2016
  # Redis é…ç½®ã€‚Redisson é»˜è®¤çš„配置足够使用,一般不需要进行调优
  redis:
    host: 192.168.2.200   #r-bp1zyjl0g07e1ry08rpd.redis.rds.aliyuncs.com # åœ°å€
    port: 6379            #端口 6379
    host: 127.0.0.1   #r-bp1zyjl0g07e1ry08rpd.redis.rds.aliyuncs.com # åœ°å€
    port: 6020            #端口 6379
    database: 15 # æ•°æ®åº“索引
    password: Smartor.2023    #Root@yinyu # å¯†ç ï¼Œå»ºè®®ç”Ÿäº§çŽ¯å¢ƒå¼€å¯
    password: Smartor    #Root@yinyu # å¯†ç ï¼Œå»ºè®®ç”Ÿäº§çŽ¯å¢ƒå¼€å¯
--- #################### å®šæ—¶ä»»åŠ¡ç›¸å…³é…ç½® ####################
jh-server/src/main/resources/application.yaml
@@ -3,8 +3,8 @@
    name: jh-server
  profiles:
    active: stage
  #    active: stage
#    active: local
    active: prod
  main:
    allow-circular-references: true # å…è®¸å¾ªçŽ¯ä¾èµ–ï¼Œå› ä¸ºé¡¹ç›®æ˜¯ä¸‰å±‚æž¶æž„ï¼Œæ— æ³•é¿å…è¿™ä¸ªæƒ…å†µã€‚
sql/mysql/jh.sql
@@ -34,6 +34,13 @@
  `updater` varchar(10) DEFAULT '' COMMENT '更新者',
  `update_time` datetime NOT NULL COMMENT '更新时间',
  `deleted` bit(1) DEFAULT b'0' COMMENT '删除标记',
  -- ä»¥ä¸‹ 6 åˆ—ç”± _prep/06_add_his_columns.sql æ·»åŠ ï¼ˆHIS æŽ¥å…¥è¡¥å……字段,2026-09-27)
  `jjbz` varchar(4) DEFAULT NULL COMMENT '优先级标记 N常规 J加急',
  `method_code` varchar(64) DEFAULT NULL COMMENT '检查方式编码',
  `target_site_code` varchar(64) DEFAULT NULL COMMENT '检查部位编码',
  `room_name` varchar(64) DEFAULT NULL COMMENT '检查房间名称',
  `remark` varchar(255) DEFAULT NULL COMMENT '注意事项',
  `book_seq_num` int DEFAULT NULL COMMENT '预约排序号',
  PRIMARY KEY (`id`),
  KEY `idx-pat` (`pat_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=27 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约表';