yxh
3 天以前 1f23e7dfc186c65434eda46fd6756d41fd01e867
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
package cn.lihu.jh.module.ecg.service.queue;
 
import cn.lihu.jh.framework.common.exception.ErrorCode;
import cn.lihu.jh.framework.common.exception.enums.GlobalErrorCodeConstants;
import cn.lihu.jh.framework.common.util.object.BeanUtils;
import cn.lihu.jh.module.ecg.Utils;
import cn.lihu.jh.module.ecg.controller.admin.room.vo.MonitorInfoVO;
import cn.lihu.jh.module.ecg.dal.dataobject.checktype.CheckTypeDO;
import cn.lihu.jh.module.ecg.dal.dataobject.queue.QueueDO;
import cn.lihu.jh.module.ecg.dal.dataobject.room.RoomDO;
import cn.lihu.jh.module.ecg.dal.mysql.call.CallMapper;
import cn.lihu.jh.module.ecg.dal.mysql.checktype.CheckTypeMapper;
import cn.lihu.jh.module.ecg.dal.mysql.devrent.DevRentMapper;
import cn.lihu.jh.module.ecg.dal.mysql.queue.QueueMapper;
import cn.lihu.jh.module.ecg.dal.mysql.room.RoomMapper;
import cn.lihu.jh.module.ecg.enums.BedOpTypeEnum;
import cn.lihu.jh.module.ecg.enums.BedStatusEnum;
import cn.lihu.jh.module.ecg.enums.QueueStatusEnum;
import cn.lihu.jh.module.infra.api.config.ConfigApi;
import cn.lihu.jh.module.system.api.oauth2.OAuth2TokenApi;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.validation.annotation.Validated;
 
import javax.annotation.Resource;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
 
import static cn.lihu.jh.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.lihu.jh.framework.common.pojo.CommonResult.error;
import static cn.lihu.jh.module.ecg.enums.ErrorCodeConstants.*;
 
/**
 * 排队 Service 数据库事务相关的方法
 *
 * @author 芋道源码
 */
@Component
@Validated
@Slf4j
public class QueueServiceTxFunctions {
 
    @Resource
    private ConfigApi configApi;
 
    @Resource
    private OAuth2TokenApi oAuth2TokenApi;
 
    @Resource
    private QueueMapper queueMapper;
 
    @Resource
    private RoomMapper roomMapper;
 
    @Resource
    private CallMapper callMapper;
 
    @Resource
    private DevRentMapper devRentMapper;
 
    @Resource
    private CheckTypeMapper checkTypeMapper;
 
 
    AtomicInteger openingFlag = new AtomicInteger(0);
 
    // 【号统一(本需求)】原 curSeqNum(分流时给患者编"内部序号"的内存计数器)已删除。
    // 排队号改为签到时一次性写入 queue.seq_num,之后任何环节都不再赋号:
    // 分流、叫号后补齐候诊、过号、召回、加急、装机都只改状态/诊室/工位。
 
    // 维护 开放的工位
    ConcurrentHashMap<String, BedBO> mapOpeningBed = new ConcurrentHashMap<>();
 
    // 装机工位..不进入分流队列
    ConcurrentHashMap<Integer, PriorityBlockingQueue<BedBO>> mapCheckTypeRoutingQueue = new ConcurrentHashMap<>();
    
    Map<Integer, CheckTypeDO> mapCheckTypeVsReadyMax = null;
 
    /**
     * 已关闭 或者 关闭中,可以开通工位
     * @param roomId
     * @param bedNo
     * @return
     */
    public ErrorCode bedOpen(Long roomId, String roomName, String bedNo) {
        BedBO bedBO2 = mapOpeningBed.get( Utils.formatRoomBed(roomId, bedNo) );
        if (null != bedBO2) {
            log.error("bedOpen mapBedVsQueue has existed. " + roomId + " " + bedNo);
            return QUEUE_BED_EXIST;
        }
 
        // DB update
        List statusList = new ArrayList<BedStatusEnum>();
        statusList.add(BedStatusEnum.CLOSED);
        Integer updateNum = roomMapper.setBedOpeningOpening(roomId, bedNo, BedStatusEnum.OPENING, statusList);
        if ( null==updateNum || 0 == updateNum ) {
            log.error("bedOpen DB invalid status. " + roomId + " " + bedNo);
            return ROOM_INVALID_STATUS;
        }
 
        RoomDO roomDO = roomMapper.getRoom(roomId, bedNo);
 
        // 新增..工位队列
        BedBO bedBO = new BedBO();
        bedBO.setRoomId(roomId);
        bedBO.setRoomName(roomName);
        bedBO.setBedNo(bedNo);
        bedBO.setStatus(BedStatusEnum.OPENING.getStatus());
        bedBO.setCheckTypes( roomDO.getCheckTypes() );
        bedBO.setOpType( roomDO.getOpType() );
        mapOpeningBed.put( Utils.formatRoomBed(roomId, bedNo), bedBO);
 
        return GlobalErrorCodeConstants.SUCCESS;
    }
 
    public ErrorCode bedClose(Long roomId, String bedNo) {
        BedBO bedBO = mapOpeningBed.get( Utils.formatRoomBed(roomId, bedNo) );
        if (null == bedBO) {
            log.error("bedClose mapBedVsQueue DONOT existed. " + roomId + " " + bedNo);
            return QUEUE_BED_NOT_EXIST;
        }
 
        // 检查是否还有患者需要处理
        List<Integer> queueStatusList = new ArrayList<>();
        queueStatusList.add(QueueStatusEnum.READY.getStatus());
        queueStatusList.add(QueueStatusEnum.ONSTAGE.getStatus());
        queueStatusList.add(QueueStatusEnum.INSTALLING.getStatus());
        queueStatusList.add(QueueStatusEnum.PASSED.getStatus());
        queueStatusList.add(QueueStatusEnum.PASSED_INSTALL.getStatus());
        queueStatusList.add(QueueStatusEnum.RECALLED.getStatus());
        queueStatusList.add(QueueStatusEnum.RECALLED_INSTALL.getStatus());
        queueStatusList.add(QueueStatusEnum.AFFINITY.getStatus());
        queueStatusList.add(QueueStatusEnum.AFFINITY_INSTALL.getStatus());
        List<QueueDO>  queueDOList = queueMapper.getBedQueueByStatus(roomId, bedNo, queueStatusList);
        if (queueDOList.size() > 0)
            return QUEUE_HAVE_PATIENT;
 
        // DB update
        List statusList = new ArrayList<BedStatusEnum>();
        statusList.add(BedStatusEnum.OPENING);
        statusList.add(BedStatusEnum.DOCTOR_ON);
        statusList.add(BedStatusEnum.DOCTOR_PAUSE);
        Integer updateNum = roomMapper.setBedOpeningClosed(roomId, bedNo,
                BedStatusEnum.CLOSED, statusList);
        if ( null==updateNum || 0 == updateNum ) {
            log.error("bedClose DB invalid status. " + roomId + " " + bedNo);
            return ROOM_INVALID_STATUS;
        }
 
        mapOpeningBed.remove( Utils.formatRoomBed(roomId, bedNo) );
        removeBedFromRoutingQueue(bedBO);
        return GlobalErrorCodeConstants.SUCCESS;
    }
 
    public ErrorCode bedDoctorPause(Long roomId, String bedNo, Long docId, String docName) {
        BedBO bedBO = mapOpeningBed.get( Utils.formatRoomBed(roomId, bedNo) );
        if (null == bedBO) {
            log.error("bedDoctorPause mapBedVsQueue DONOT existed. " + roomId + " " + bedNo);
            return QUEUE_BED_NOT_EXIST;
        }
 
        // DB update
        List statusList = new ArrayList<BedStatusEnum>();
        statusList.add(BedStatusEnum.DOCTOR_ON);
        Integer updateNum = roomMapper.setBedDoctorStatus(roomId, bedNo, docId, BedStatusEnum.DOCTOR_PAUSE, statusList);
        if ( null==updateNum || 0 == updateNum ) {
            log.error("bedDoctorPause DB invalid status. " + roomId + " " + bedNo);
            return ROOM_INVALID_STATUS;
        }
 
        bedBO.setStatus(BedStatusEnum.DOCTOR_PAUSE.getStatus());
        removeBedFromRoutingQueue(bedBO);
        return GlobalErrorCodeConstants.SUCCESS;
    }
 
    public ErrorCode bedDoctorResume(Long roomId, String bedNo, Long docId, String docName) {
        BedBO bedBO = mapOpeningBed.get( Utils.formatRoomBed(roomId, bedNo) );
        if (null == bedBO || !bedBO.getStatus().equals(BedStatusEnum.DOCTOR_PAUSE.getStatus())) {
            log.error("bedDoctorResume mapBedVsQueue DONOT existed OR NOT Paused. " + roomId + " " + bedNo);
            return QUEUE_BED_NOT_EXIST;
        }
 
        // DB update
        List statusList = new ArrayList<BedStatusEnum>();
        statusList.add(BedStatusEnum.DOCTOR_PAUSE);
        Integer updateNum = roomMapper.setBedDoctorStatus(roomId, bedNo, docId,
                BedStatusEnum.DOCTOR_ON, statusList);
        if ( null==updateNum || 0 == updateNum ) {
            log.error("bedDoctorResume DB invalid status. " + roomId + " " + bedNo);
            return ROOM_INVALID_STATUS;
        }
 
        bedBO.setStatus(BedStatusEnum.DOCTOR_ON.getStatus());
        addBedToRoutingQueue(bedBO);
        return GlobalErrorCodeConstants.SUCCESS;
    }
 
    // ==================== 需求3:停止候诊开关 ====================
 
    /**
     * 设置「停止候诊」开关。
     * <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());
    }
 
    /**
     * 入座(把工位主人设为我)。
     *
     * @param bedId 工位主键(前端请求里带;**优先按它定位**,见下)——可为空,为空时按 roomId+bedNo 定位
     */
    public ErrorCode bedDoctorOn(Long roomId, String bedNo, Long docId, String docName, Integer bedId) {
        BedBO bedBO = mapOpeningBed.get( Utils.formatRoomBed(roomId, bedNo) );
        if (null == bedBO) {
            log.error("bedDoctorOn mapBedVsQueue DONOT existed. " + roomId + " " + bedNo);
            return QUEUE_BED_NOT_EXIST;
        }
 
        // 可以"成为主人"的状态:已开通 / 有医生 / 暂停(**排除"已关闭"**)。
        // 【本次放宽】原来"无主人"时只认"已开通":一旦数据出现"doc_id 为空但状态仍是有医生/暂停"
        // 这种不一致,医生站在工位前却始终坐不上,只报"状态异常",现场无从处理。
        List<BedStatusEnum> claimableStatusList = new ArrayList<>();
        claimableStatusList.add(BedStatusEnum.OPENING);
        claimableStatusList.add(BedStatusEnum.DOCTOR_ON);
        claimableStatusList.add(BedStatusEnum.DOCTOR_PAUSE);
 
        // 【本次修复】优先按**主键**更新:现场出现过"工位在库里明明空闲、入座却更新 0 行",
        // 已排除"被占用";剩下的可能是 bed_no 与请求值有肉眼难辨的差异
        // (结尾空格 / 大小写 / 全角,JSON 与复制粘贴都看不出来)。前端本来就带 id,用它最稳。
        Integer updateNum = null;
        if (null != bedId) {
            updateNum = roomMapper.setBedDoctorOnById(bedId, docId, docName,
                    BedStatusEnum.DOCTOR_ON, claimableStatusList);
            if (null != updateNum && updateNum > 0) {
                // 校验一下 id 指向的行确实就是请求的工位(不一致说明前端缓存与库不同步,记日志便于排查)
                RoomDO seatRow = roomMapper.getRoom(roomId, bedNo);
                RoomDO idRow = roomMapper.selectById(bedId);
                if (null != idRow && null != seatRow && !idRow.getId().equals(seatRow.getId())) {
                    log.warn("[bedDoctorOn] 按 id 入座成功,但 id 指向的工位与请求的 roomId/bedNo 不是同一行!"
                                    + " id={} → {}/{}, 请求 → {}/{}",
                            bedId, idRow.getRoomId(), idRow.getBedNo(), roomId, bedNo);
                }
            }
        }
        if (null == updateNum || 0 == updateNum) {
            // 回退:按 roomId + bedNo 定位(兼容不带 id 的调用方,如自动入座的老路径)
            updateNum = roomMapper.setBedDoctorOn(roomId, bedNo, docId, docName,
                    BedStatusEnum.DOCTOR_ON, claimableStatusList);
        }
 
        if ( null==updateNum || 0 == updateNum ) {
            // 仍失败 → 把库里**真实状态**摊开,并区分原因(原来一律"状态异常",现场看不出所以然)
            RoomDO rowById = (null == bedId) ? null : roomMapper.selectById(bedId);
            RoomDO rowByRoomBed = roomMapper.getRoom(roomId, bedNo);
            log.warn("[bedDoctorOn] 入座更新 0 行. 请求 roomId={} bedNo=[{}] bedId={} docId={};"
                            + "按id查到={} 按roomId+bedNo查到={}",
                    roomId, bedNo, bedId, docId,
                    null == rowById ? "无" : describeBedRow(rowById),
                    null == rowByRoomBed ? "无" : describeBedRow(rowByRoomBed));
 
            RoomDO occupant = (null != rowById) ? rowById : rowByRoomBed;
            if (null != occupant && null != occupant.getDocId() && !occupant.getDocId().equals(docId)) {
                log.warn("[bedDoctorOn] 工位已被他人占用,拒绝入座(不挤掉). 占用人={}({})",
                        occupant.getDocName(), occupant.getDocId());
                return ROOM_OCCUPIED_BY_OTHER;
            }
            return ROOM_INVALID_STATUS;
        }
 
        // 【本需求·一人多工位】入座成功后**不再释放**该医生的其它工位:
        // 节假日值班时一位医生可能同时占「检查工位 + 领用工位 + 装机工位」,
        // 由前端「我的工位」下拉在各工位间切换(写操作仍由 @RequireBedOwner 按工位 doc_id 校验)。
        // 原实现:roomMapper.releaseOtherBedsOfDoc(docId, roomId, bedNo, BedStatusEnum.OPENING);
        // 工位释放改为三种明确途径:① 医生点「离座」;② 闭诊统一清空;③ 管理员「清空工位」/接班医生「接管」。
 
        bedBO.setStatus(BedStatusEnum.DOCTOR_ON.getStatus());
        addBedToRoutingQueue(bedBO);
        return GlobalErrorCodeConstants.SUCCESS;
    }
 
    /**
     * 手工把某位「排队中」患者拉入指定工位(工作台"本诊室排队中(未分配)"分区的「拉入」按钮)。
     *
     * <p>与自动分流({@code hurryupOneCheckType})走**同一套**动作:
     * 条件更新「排队中(10) → 准备中(20)」并把 room/bed 写成本工位,然后处理亲和项。
     * 号不变(号在签到时已定)。
     *
     * <p>三重校验:工位存在 → 该行仍是「排队中」→ 检查项目在本工位开展范围内。
     * 条件更新自带并发保护:两个工位同时点「拉入」,只有一个能成功。
     */
    public ErrorCode pullWaitingPatient(Long roomId, String bedNo, Integer queueId) {
        RoomDO bed = (null == roomId || null == bedNo) ? null : roomMapper.getRoom(roomId, bedNo);
        if (null == bed) {
            log.warn("[pullWaitingPatient] 工位不存在. roomId={} bedNo={}", roomId, bedNo);
            return QUEUE_BED_NOT_EXIST;
        }
 
        QueueDO item = (null == queueId) ? null : queueMapper.selectById(queueId);
        if (null == item) {
            log.warn("[pullWaitingPatient] 排队行不存在. queueId={}", queueId);
            return QUEUE_NOT_EXISTS;
        }
        if (!QueueStatusEnum.WAITING.getStatus().equals(item.getStatus())) {
            log.warn("[pullWaitingPatient] 该患者已不在排队中. queueId={} patId={} status={}",
                    queueId, item.getPatId(), item.getStatus());
            return QUEUE_NOT_WAITING;
        }
        Integer[] bedCheckTypes = bed.getCheckTypes();
        if (null == bedCheckTypes || 0 == bedCheckTypes.length
                || null == item.getBookCheckType()
                || !java.util.Arrays.asList(bedCheckTypes).contains(item.getBookCheckType())) {
            log.warn("[pullWaitingPatient] 检查项目不在本工位范围. queueId={} checkType={} 本工位={}",
                    queueId, null == item ? null : item.getBookCheckType(), java.util.Arrays.toString(bedCheckTypes));
            return QUEUE_CHECK_TYPE_NOT_MATCH;
        }
 
        Integer updateNum = queueMapper.preemptWaitingPatientById(queueId, roomId, bed.getRoomName(), bedNo,
                QueueStatusEnum.WAITING.getStatus(), QueueStatusEnum.READY.getStatus());
        if (null == updateNum || 0 == updateNum) {
            log.warn("[pullWaitingPatient] 拉入未生效(已被其他工位拉走). queueId={}", queueId);
            return QUEUE_NOT_WAITING;
        }
 
        // 内存对象与库内保持一致(亲和项要跟随主项的诊室/工位)
        item.setStatus(QueueStatusEnum.READY.getStatus());
        item.setRoomId(roomId);
        item.setRoomName(bed.getRoomName());
        item.setBedNo(bedNo);
 
        try {
            CheckTypeDO checkTypeDO = getCheckTypeItem(item.getBookCheckType());
            if (null != checkTypeDO && null != checkTypeDO.getAffinityCheckTypes()
                    && checkTypeDO.getAffinityCheckTypes().length > 0) {
                procAffinityWhenPreempt(item, checkTypeDO.getAffinityCheckTypes());
            }
        } catch (Exception e) {
            // 亲和项处理失败不影响主项已成功拉入
            log.error("[pullWaitingPatient] 亲和项处理失败(主项已拉入). queueId={}", queueId, e);
        }
 
        log.info("[pullWaitingPatient] 已手工拉入. roomId={} bedNo={} queueId={} patId={} seqNum={}",
                roomId, bedNo, queueId, item.getPatId(), item.getSeqNum());
        return GlobalErrorCodeConstants.SUCCESS;
    }
 
    /** 拼一行工位关键字段,供日志/排查快速对比(尤其看 bed_no 是否与请求一致) */
    private String describeBedRow(RoomDO row) {
        return String.format("id=%s,room_id=%s,bed_no=[%s](len=%d),status=%s,doc_id=%s,doc_name=%s",
                row.getId(), row.getRoomId(), row.getBedNo(),
                null == row.getBedNo() ? -1 : row.getBedNo().length(),
                row.getStatus(), row.getDocId(), row.getDocName());
    }
 
    public ErrorCode bedDoctorOff(Long roomId, String bedNo, Long docId, String docName) {
        BedBO bedBO = mapOpeningBed.get( Utils.formatRoomBed( roomId, bedNo ) );
        if (null == bedBO) {
            log.error("bedDoctorOff mapBedVsQueue DONOT existed. " + roomId + " " + bedNo);
            return QUEUE_BED_NOT_EXIST;
        }
 
        // DB update
        List statusList = new ArrayList<BedStatusEnum>();
        statusList.add(BedStatusEnum.DOCTOR_ON);
        statusList.add(BedStatusEnum.DOCTOR_PAUSE);
        Integer updateNum = roomMapper.setBedDoctorOff(roomId, bedNo, docId, BedStatusEnum.OPENING, statusList);
        if ( null==updateNum || 0 == updateNum ) {
            log.error("bedDoctorOff DB invalid status. " + roomId + " " + bedNo);
            return ROOM_INVALID_STATUS;
        }
 
        removeBedFromRoutingQueue(bedBO);
 
        bedBO.setStatus(BedStatusEnum.OPENING.getStatus());
        return GlobalErrorCodeConstants.SUCCESS;
    }
 
    /**
     * 【本次需求】管理员强制清空工位(不要求是本人)。
     * <p>
     * 与 {@link #bedDoctorOff} 的区别:不清 {@code doc_id=?} 条件,
     * 因此可把「别人占用中」的工位清掉 —— 用于医生下班忘离座、换人使用同一工位的场景。
     * 仍然只清「医生在岗 / 暂停」且 doc_id 非空的工位,空闲工位不会被误动。
     */
    public ErrorCode bedDoctorOffByAdmin(Long roomId, String bedNo) {
        BedBO bedBO = mapOpeningBed.get( Utils.formatRoomBed( roomId, bedNo ) );
        if (null == bedBO) {
            log.error("bedDoctorOffByAdmin mapBedVsQueue DONOT existed. " + roomId + " " + bedNo);
            return QUEUE_BED_NOT_EXIST;
        }
 
        List statusList = new ArrayList<BedStatusEnum>();
        statusList.add(BedStatusEnum.DOCTOR_ON);
        statusList.add(BedStatusEnum.DOCTOR_PAUSE);
        Integer updateNum = roomMapper.setBedDoctorOffAdmin(roomId, bedNo, BedStatusEnum.OPENING, statusList);
        if ( null==updateNum || 0 == updateNum ) {
            log.error("bedDoctorOffByAdmin DB invalid status. " + roomId + " " + bedNo);
            return ROOM_INVALID_STATUS;
        }
 
        removeBedFromRoutingQueue(bedBO);
 
        bedBO.setStatus(BedStatusEnum.OPENING.getStatus());
        log.info("[管理员清空工位] roomId={} bedNo={} 已置为空闲", roomId, bedNo);
        return GlobalErrorCodeConstants.SUCCESS;
    }
 
    /**
     * 【本次需求】闭诊清空:把所有「医生在岗 / 暂停」的工位置为空闲(doc_id / doc_name 清空)。
     * <p>
     * 与 {@link #closeBiz()} 配合:先清库,随后 {@code startBedReload()} 会按库重新装载内存,
     * 保证内存(mapOpeningBed / 分流队列)与库一致。
     *
     * @return 清空的工位数
     */
    public int closeBizClearBeds() {
        List statusList = new ArrayList<BedStatusEnum>();
        statusList.add(BedStatusEnum.DOCTOR_ON);
        statusList.add(BedStatusEnum.DOCTOR_PAUSE);
        Integer updateNum = roomMapper.clearAllBedDoctors(BedStatusEnum.OPENING, statusList);
 
        // 内存同步:把工位状态改回空闲,并从分流队列摘除(避免闭诊后仍有工位被派患者)
        // 注意:status 是 Integer 包装类型,必须用 equals 比较(== 只在 -128~127 缓存区间内偶然成立)
        for (BedBO bedBO : mapOpeningBed.values()) {
            if (Objects.equals(BedStatusEnum.DOCTOR_ON.getStatus(), bedBO.getStatus())
                    || Objects.equals(BedStatusEnum.DOCTOR_PAUSE.getStatus(), bedBO.getStatus())) {
                removeBedFromRoutingQueue(bedBO);
                bedBO.setStatus(BedStatusEnum.OPENING.getStatus());
            }
        }
        log.info("[闭诊] 已清空当班工位 {} 个(doc_id / doc_name 置空,状态改为空闲)",
                updateNum == null ? 0 : updateNum);
        return updateNum == null ? 0 : updateNum;
    }
 
    /* 2024.11.11
    // 常规 或者 领用 时 医生 取下一位患者
    public ErrorCode nextPatient(Long roomId, String bedNo) {
        // 从 DB 把该工位.序号最小的 [候诊中中] 的人 设置为 [就诊中] (或领用中)
        Integer updateNum = queueMapper.updateBedQueueStatus(roomId, bedNo,
                QueueStatusEnum.READY.getStatus(), QueueStatusEnum.ONSTAGE.getStatus());
        // 该工位 没有 [候诊中中] 人员
        if (null == updateNum || 0 == updateNum) {
            return QUEUE_NOT_READY_PATIENT;
        }
 
        // 分流队列中 该工位 候诊中中人的数量 减一
        BedQueueBO bo = mapBedVsQueue.get( Utils.formatRoomBed( roomId, bedNo ) );
        bo.queueNum.getAndDecrement(); // 可能已经【并发的】在 hurry-up 中改变了值
 
        refreshRoutingQueue(bo);
        return GlobalErrorCodeConstants.SUCCESS;
    }
    */
 
    // 常规 或者 领用 时 医生 取下一位患者
    public ErrorCode nextPatient(Long roomId, String bedNo) {
        // ⓪ 【并发加固·本需求】本工位**已有「就诊中(30)」患者时不再叫下一位**。
        //
        // 原先这个判断只在 `QueueServiceImpl#finishNextPatient` 里做过,而它位于
        // singleThreadExecutor 串行区**之外**:两个并发请求(双击 / 多端)都可能读到 0,
        // 随后各自提交一次 nextPatient,串行执行时会各推进一条 READY ⇒
        // 同一工位出现 2 个「就诊中」,患者被"卡"在就诊中、工位也不再补人。
        // 这里在串行区内再判一次(本方法只由该串行线程池调用)。
        List<Integer> onstageStatusList = new ArrayList<>();
        onstageStatusList.add(QueueStatusEnum.ONSTAGE.getStatus());
        Integer onstageNum = queueMapper.bedQueueStatisticByStatus(roomId, bedNo, onstageStatusList);
        if (null != onstageNum && onstageNum > 0) {
            log.warn("[nextPatient] 本工位已有 {} 个「就诊中」患者,跳过本次叫号(防并发双就诊中). 工位={}/{}",
                    onstageNum, roomId, bedNo);
            return GlobalErrorCodeConstants.SUCCESS;
        }
 
        // ① 候诊中(20) 优先
        QueueDO firstQueueItem = queueMapper.getBedFirstQueueItemByStatus(
                roomId, bedNo, QueueStatusEnum.READY.getStatus());
        boolean fromRecalled = false;
 
        // ② 候诊区没人时,回退取「已召回(15)」里号最小的一位
        //
        // 【缺陷修复】过号患者"召回"后会先置为 RECALLED(15),再靠 hurryupOneBed 的补人循环
        // 提升成 READY(20)。但补人循环有三道门闸(openingFlag=0 / stopReady=true / 候诊区已满),
        // 任一条成立患者就**一直停在 15**;而叫号此前只认 READY ⇒ 出现死结:
        // 现场表现为"排队号 A2/A3 状态是已召回,点叫号加载不出患者信息,无法检查"(2026-10-29 反馈)。
        // 这里按与 nextInstallPatient(已含 RECALLED_INSTALL)一致的思路直接叫到,
        // 不再依赖补人是否成功。
        if (null == firstQueueItem) {
            firstQueueItem = queueMapper.getBedFirstQueueItemByStatus(
                    roomId, bedNo, QueueStatusEnum.RECALLED.getStatus());
            fromRecalled = null != firstQueueItem;
            if (fromRecalled) {
                log.info("[nextPatient] 候诊区无人,改叫「已召回」患者. 工位={}/{} patId={} seqNum={}",
                        roomId, bedNo, firstQueueItem.getPatId(), firstQueueItem.getSeqNum());
            }
        }
 
        // 该工位既没有 [候诊中] 也没有 [已召回] 人员
        if (null == firstQueueItem) {
            return QUEUE_NOT_READY_PATIENT;
        }
 
        // ③ READY / RECALLED => ONSTAGE:用「主键 + 当前状态」条件更新(并发加固),
        //    0 行说明该行已被另一请求推进,本次放弃(下次再取)。
        int curStatus = fromRecalled
                ? QueueStatusEnum.RECALLED.getStatus() : QueueStatusEnum.READY.getStatus();
        Integer updated = queueMapper.updateStatusByIdIfCurrent(
                firstQueueItem.getId(), curStatus, QueueStatusEnum.ONSTAGE.getStatus());
        if (null == updated || 0 == updated) {
            log.warn("[nextPatient] 候诊行已被并发推进,放弃本次叫号. 工位={}/{} queueId={} 期望状态={}",
                    roomId, bedNo, firstQueueItem.getId(), curStatus);
            return QUEUE_NOT_READY_PATIENT;
        }
        firstQueueItem.setStatus(QueueStatusEnum.ONSTAGE.getStatus());
 
        // 分流队列中 该工位 【候诊中】患者数量 减一
        // 注意:召回的这位本来就不在候诊计数里,不能减;另外 bo 可能为 null(工位不在开诊列表),
        // 原实现直接 bo.readyPatNum-- 会 NPE。
        BedBO bo = mapOpeningBed.get(Utils.formatRoomBed(roomId, bedNo));
        if (null != bo) {
            if (!fromRecalled) {
                bo.readyPatNum--; // 可能已经在 hurry-up 中改变了值
            }
            refreshBedPriorityLevelInRoutingQueue(bo);
        } else {
            log.warn("[nextPatient] 工位不在开诊列表,跳过内存计数与分流刷新. 工位={}/{}", roomId, bedNo);
        }
        return GlobalErrorCodeConstants.SUCCESS;
    }
 
    // 装机时 医生 取下一位患者
    public ErrorCode nextInstallPatient(Long roomId, String bedNo) {
        // 从 DB 把 序号最小的 [已领用] [已召回-安装] 的人 设置为 [安装中]
        QueueDO firstItem = queueMapper.getRoomFirstQueueItemByStatus(roomId, List.of(QueueStatusEnum.RECEIVED.getStatus(), QueueStatusEnum.RECALLED_INSTALL.getStatus()));
        // 该工位 没有 [已领用] | [已召回-安装] 人员
        if (null == firstItem) {
            return QUEUE_NOT_READY_PATIENT;
        }
 
        firstItem.setStatus( QueueStatusEnum.INSTALLING.getStatus() );
        firstItem.setBedNo( bedNo );  // 转到 安装工位
        queueMapper.updateById( firstItem );
 
        // 检查项目.亲和性 处理逻辑:  变为【安装中】后 拉 其他检查项来亲和
        CheckTypeDO checkTypeDO = getCheckTypeItem( firstItem.getBookCheckType() );
        if (checkTypeDO.getAffinityCheckTypes().length > 0) {
            procAffinityWhenSomeOneEntryInstalling(firstItem.getPatId(), roomId, bedNo, checkTypeDO.getAffinityCheckTypes() );
        }
 
        // 装机工位 不参与分流
 
        return GlobalErrorCodeConstants.SUCCESS;
    }
 
    /**
     * 1. 每天开诊前 从DB同步工位的患者队列数据到 工位分流队列
     * 2. 服务运维重启时
     */
    public ErrorCode bedReload() {
        mapCheckTypeRoutingQueue.clear();
        mapOpeningBed.clear();
 
        // 清除非当天的排队人员
        queueMapper.clearQueue();
        // 清除非当天的语音叫号记录
        callMapper.clearCall();
 
        // 从DB 获取 工位列表
        List<BedStatusEnum> bedStatusEnumList = new ArrayList<BedStatusEnum>();
        bedStatusEnumList.add(BedStatusEnum.OPENING);
        bedStatusEnumList.add(BedStatusEnum.DOCTOR_ON);
        bedStatusEnumList.add(BedStatusEnum.DOCTOR_PAUSE);
        List<RoomDO> roomDOList = roomMapper.simpleRoomList(bedStatusEnumList);
        List<BedBO> bedBOList = roomDOList.stream().map(item -> BeanUtils.toBean(item, BedBO.class)).toList();
 
        bedBOList.forEach(item -> {
            mapOpeningBed.put( Utils.formatRoomBed(item.roomId, item.bedNo), item);
            // 【需求3】「停止候诊」的工位不加入分流队列 —— 与医生在座(status=DOCTOR_ON)同样判定,
            // 否则每次 bedReload(开诊/重启/诊室重置)后开关会失效、又开始收病人。
            // 注意:stopReady 仅阻止**新增**候诊患者,已有候诊患者仍可正常叫号
            // (nextPatient 不检查该开关)。
            if (BedStatusEnum.DOCTOR_ON.getStatus() == item.getStatus()
                    && !Boolean.TRUE.equals(item.getStopReady())) {
                addBedToRoutingQueue(item);
            }
        });
 
        // 【号统一(本需求)】不再初始化内存计数器 curSeqNum:
        // 排队号来自签到时的 queue.seq_num,分流不再分配号。
        return GlobalErrorCodeConstants.SUCCESS;
    }
 
    public void resetRoom(Boolean needCloseBed) {
        if (needCloseBed) {
            // 踢出在座的医生
            List<BedStatusEnum> bedStatusEnumList = new ArrayList<BedStatusEnum>();
            bedStatusEnumList.add(BedStatusEnum.DOCTOR_ON);
            bedStatusEnumList.add(BedStatusEnum.DOCTOR_PAUSE);
            List<RoomDO> roomDOList = roomMapper.simpleRoomList(bedStatusEnumList);
            List<Long> userIdList = roomDOList.stream().map(roomDO -> roomDO.getDocId()).toList();
            oAuth2TokenApi.tick(userIdList);
 
            // 所有工位状态 => 关闭
            Integer ret = roomMapper.resetRoom(BedStatusEnum.CLOSED.getStatus());
        }
    }
 
    public void hurryupAllBed() {
        if (0 == openingFlag.get())
            return;
 
        mapOpeningBed.values().forEach(bedQueueBO -> hurryupOneBed(bedQueueBO.roomId, bedQueueBO.bedNo));
    }
 
    /**
     * 【周期兜底专用】只给**有医生在座(DOCTOR_ON)**的工位补人。
     *
     * <p>为什么不能直接用 {@link #hurryupAllBed()}:它遍历的是 `mapOpeningBed`
     * (含「已开通但没人」的工位),会把排队患者推进**没有医生**的工位候诊区 ——
     * 同一检查项目有多个工位时(如常规心电图 116/117/118),患者可能被"分配"到
     * 一个没人看的工位,真正在座的医生反而看不到。该方法原本只在开诊/重启时跑一次,
     * 若改成每分钟跑一遍,这个副作用会被放大。因此周期兜底只补在座工位,
     * 与自动分流队列({@code addBedToRoutingQueue} 只收 DOCTOR_ON)的口径保持一致。
     */
    public void hurryupAllSeatedBeds() {
        if (0 == openingFlag.get())
            return;
 
        mapOpeningBed.values().forEach(bedQueueBO -> {
            // 只补「有医生在座」的工位
            if (!Objects.equals(BedStatusEnum.DOCTOR_ON.getStatus(), bedQueueBO.getStatus())) {
                return;
            }
            // 「停止候诊」的工位直接跳过:hurryupOneBed 内部本来也会跳过,
            // 这里提前判断是为了避免每分钟刷一条 "[补人] 工位处于【停止候诊】" 的 WARN。
            if (Boolean.TRUE.equals(bedQueueBO.getStopReady())) {
                return;
            }
            hurryupOneBed(bedQueueBO.roomId, bedQueueBO.bedNo);
        });
    }
 
    /**
     * 把医生候诊的队列塞满
     */
    public void hurryupOneBed(Long roomId, String bedNo) {
        if (0 == openingFlag.get()) {
            // 【现场排查】未开诊时**整个自动补人都是关闭的**(排队中的病人不会被搬进候诊区)。
            // 原实现是静默 return,现场只能看到"人都在,就是不进来",故这里明确记一条。
            log.warn("[补人] 系统未处于【开诊】状态(openingFlag=0),跳过自动补人. 工位={}/{}(可在监控页点「开诊」)",
                    roomId, bedNo);
            return;
        }
 
        BedBO bedBO = getBedQueueBO(roomId, bedNo);
        if (null == bedBO) {
            log.warn("[补人] 工位不在「已开诊工位」列表中,跳过. 工位={}/{}", roomId, bedNo);
            return;
        }
 
        // 【本次加固】用库里的真实候诊人数校正内存计数:
        // readyPatNum 是内存值,若某条路径(撤销/放弃/过号等)没同步,它会虚高,
        // 于是 "候诊区已满" 的判断一直成立、永远不补人(而界面上候诊区其实是空的)。
        int realReadyNum = countReadyInDb(roomId, bedNo);
        if (realReadyNum != bedBO.getReadyPatNum()) {
            log.warn("[补人] 内存候诊人数({}) 与库内({}) 不一致,已按库内校正. 工位={}/{}",
                    bedBO.getReadyPatNum(), realReadyNum, roomId, bedNo);
            bedBO.setReadyPatNum(realReadyNum);
        }
 
        // 【需求3】「停止候诊」时不再把【已召回】患者放入候诊区。
        // 注意:这**不影响**已有候诊患者被正常叫号(nextPatient 不检查本开关),
        // 也不影响医生对具体患者的"召回"操作本身(只影响其自动进入候诊的补位)。
        boolean stopReady = Boolean.TRUE.equals(bedBO.getStopReady());
 
        // 处理 过号-回来 的人
        while (!stopReady && bedBO.getReadyPatNum() < bedBO.getMaxReadyPatNum()) {
            // 查看 当前工位 是否有「已召回(15)」的患者(排队号最小的那一位)
            QueueDO recalledQueueItem = queueMapper.getBedFirstQueueItemByStatus(
                    bedBO.getRoomId(), bedBO.getBedNo(), QueueStatusEnum.RECALLED.getStatus());
            if (null == recalledQueueItem)
                break;
 
            // 【号统一(本需求)】过号召回**不再重排队号**:号是签到时定的,
            // 患者按原号回到候诊序列(需求:「病人过号了,也排着」)。
            // 旧实现(seq_num = 当前号+1,排到队尾)会让他与屏幕上/患者手里的号对不上。
            // 不需要处理 [安装召回], 原因: 安装工位 不进入分流队列
            recalledQueueItem.setStatus(QueueStatusEnum.READY.getStatus());
            queueMapper.updateById(recalledQueueItem);
 
            // [过号] 患者 => [候诊中] ,处理 检查项目.亲和性 逻辑
            CheckTypeDO checkTypeDO = getCheckTypeItem( recalledQueueItem.getBookCheckType() );
            if ( checkTypeDO.getAffinityCheckTypes().length > 0) {
                procAffinityWhenRecalled(recalledQueueItem, checkTypeDO.getAffinityCheckTypes());
            }
 
            // 可能已经在 nextPatient 中改变了值
            bedBO.readyPatNum++;
 
            // 可能已经【并发的】在 nextPatient 中改变了分流队列顺序
            refreshBedPriorityLevelInRoutingQueue(bedBO);
        }
 
        // 处理 排队中 患者
        hurryupOneBedCheckTypeList(bedBO);
    }
 
    /**
     * 一个工位可以支持多个检查项目,所以需要保证多个检查项目的就诊时间先后顺序
     * 按照一个检查项目来处理,就不能保证同一个工位的多个检查项目的就诊时间的先后顺序
     * 所以实现逻辑是 让支持该检查项目的所有工位去抢占 【排队中】【工位支持的检查项目】的患者
     * 实际抢占的未必是 参数中的检查项目的患者,可能是其他检查项目的患者
     * @param checkType
     */
    public void hurryupOneCheckType(Integer checkType) {
 
        // 【修复既有缺陷】补上开诊门闸。原实现**唯独这里漏了** openingFlag 检查
        // (hurryupAllBed 与 hurryupOneBed 都有),导致手动"闭诊"后,
        // 只要有人预约确认 / 加急,患者仍会被推进候诊区。
        if (0 == openingFlag.get())
            return;
 
        PriorityBlockingQueue<BedBO> routingQueue = mapCheckTypeRoutingQueue.get(checkType);
        if (null == routingQueue)
            return;
 
        while (true) {
            BedBO bedBO = routingQueue.peek();
            if (null == bedBO)
                return;
 
            // 【需求3】跳过处于「停止候诊」的工位。
            // 不能直接 return —— 该队列按 readyPatNum 升序 peek,
            // 直接 return 会让其余工位也收不到患者(饿死)。
            // 这里 poll 出来后若命中停止态,仅将其从队列移除(不再 offer 回去),
            // 然后 continue 处理下一个工位。
            if (Boolean.TRUE.equals(bedBO.getStopReady())) {
                routingQueue.poll();
                log.info("[hurryupOneCheckType] 工位[停止候诊],移出分流队列. roomId={} bedNo={}",
                        bedBO.getRoomId(), bedBO.getBedNo());
                continue;
            }
 
            int curQueueNum = bedBO.getReadyPatNum();
            if (curQueueNum > bedBO.getMaxReadyPatNum())
                throw new RuntimeException("hurryupOneCheckType: exceed max queue number!");
 
            if (curQueueNum == bedBO.getMaxReadyPatNum())
                return;
 
            // 查看 是否有排队中的患者
            //
            // 【号统一(本需求)】分流只做「排队中(10) → 候诊中(20)」的搬迁,**不再分配排队号**:
            // 号在签到时已写入 seq_num;加急只影响这里的取人顺序(SQL 内 jump_flag 优先)。
            // 旧实现是「UPDATE ... 顺带 seq_num=当前号+1,再按号查回该行」——
            // 号不再唯一后"按号查回"会取错人,故改为:先取行 → 按 id 条件更新。
            QueueDO preemptQueueItem = queueMapper.getFirstWaitingByCheckTypes(
                    QueueStatusEnum.WAITING.getStatus(),
                    bedBO.checkTypes);  //关键点 关键点 关键点 checkType
 
            // 没有排队中的患者
            if (null == preemptQueueItem) {
                return;
            }
 
            // 条件更新:status 必须仍是「排队中」,保证并发下只有一方抢到该行
            Integer updateNum = queueMapper.preemptWaitingPatientById(
                    preemptQueueItem.getId(),
                    bedBO.getRoomId(),
                    bedBO.getRoomName(),
                    bedBO.getBedNo(),
                    QueueStatusEnum.WAITING.getStatus(),
                    QueueStatusEnum.READY.getStatus());
            if (null == updateNum || 0 == updateNum) {
                // 被其它工位抢先:本轮放弃,等下一次补齐触发
                return;
            }
 
            // 内存对象与库内保持一致 —— 亲和项要跟随主项的诊室/工位
            preemptQueueItem.setStatus(QueueStatusEnum.READY.getStatus());
            preemptQueueItem.setRoomId(bedBO.getRoomId());
            preemptQueueItem.setRoomName(bedBO.getRoomName());
            preemptQueueItem.setBedNo(bedBO.getBedNo());
 
            CheckTypeDO checkTypeDO = getCheckTypeItem( preemptQueueItem.getBookCheckType() );
 
            // 抢到排队患者后,处理 检查项目.亲和性 逻辑
            if ( checkTypeDO.getAffinityCheckTypes().length > 0) {
                procAffinityWhenPreempt(preemptQueueItem, checkTypeDO.getAffinityCheckTypes());
            }
 
            // 可能已经在 nextPatient 中改变了值
            bedBO.readyPatNum++;
 
            // 可能已经【并发的】在 nextPatient 中改变了分流队列顺序
            refreshBedPriorityLevelInRoutingQueue(bedBO);
        }
    }
 
    public Integer getOpeningFlag() {
        return openingFlag.get();
    }
 
    public void setOpeningFlag(Integer flag) {
        openingFlag.set(flag);
    }
 
    public void initCheckType() {
        List<CheckTypeDO> checkTypeDOList = checkTypeMapper.simpleCheckTypeList();
        mapCheckTypeVsReadyMax = checkTypeDOList.stream().collect(Collectors.toMap(CheckTypeDO::getValue, checkType -> checkType));
    }
 
    public CheckTypeDO getCheckTypeItem(Integer checkType) {
        return mapCheckTypeVsReadyMax.get( checkType );
    }
 
    public BedBO getBedQueueBO(Long roomId, String bedNo) {
        return mapOpeningBed.get( Utils.formatRoomBed(roomId, bedNo) );
    }
 
    public MonitorInfoVO getMonitorInfo() {
        MonitorInfoVO monitorInfoVO = new MonitorInfoVO();
        monitorInfoVO.setOpeningFlag( openingFlag.get() );
        roomBedStatistic(monitorInfoVO);
 
        return monitorInfoVO;
    }
 
    public void monitorInfo() {
        MonitorInfoVO monitorInfoVO = new MonitorInfoVO();
        roomBedStatistic(monitorInfoVO);
        log.info(" opening " + openingFlag.get() + " " + monitorInfoVO.getOpeningBedNum() + " " + monitorInfoVO.getDocBedNum() + " " + monitorInfoVO.getCheckTypeBedInfo().toString() );
    }
 
    private void hurryupOneBedCheckTypeList(BedBO bedBO) {
        // 【需求3】「停止候诊」的工位不再新增候诊患者。
        // 已有候诊患者仍可正常叫号(本方法只负责"补齐候诊",不参与叫号)。
        if (Boolean.TRUE.equals(bedBO.getStopReady())) {
            // 【现场排查】明确记一条:工位开了「停止候诊」时不会自动补人
            log.warn("[补人] 工位处于【停止候诊】,跳过自动补人. 工位={}/{}(如需接收请关闭该开关)",
                    bedBO.getRoomId(), bedBO.getBedNo());
            return;
        }
        if (bedBO.getReadyPatNum() >= bedBO.getMaxReadyPatNum()) {
            // 【现场排查】候诊区已达上限(上限=各检查项目 check_type.ready_num 的最大值)
            log.info("[补人] 候诊区已达上限,跳过. 工位={}/{} 当前候诊={} 上限={}",
                    bedBO.getRoomId(), bedBO.getBedNo(), bedBO.getReadyPatNum(), bedBO.getMaxReadyPatNum());
            refreshBedPriorityLevelInRoutingQueue(bedBO);
            return;
        }
        int pulledNum = 0;
        while (bedBO.getReadyPatNum() < bedBO.getMaxReadyPatNum()) {
            // 抢占 是否有排队中的患者
            //
            // 【号统一(本需求)】与 hurryupOneCheckType 相同:只搬迁状态,**不再分配排队号**。
            QueueDO preemptQueueItem = queueMapper.getFirstWaitingByCheckTypes(
                    QueueStatusEnum.WAITING.getStatus(),
                    bedBO.checkTypes);
 
            // 没有排队中的患者
            if (null == preemptQueueItem) {
                return;
            }
 
            // 条件更新:status 必须仍是「排队中」,保证并发下只有一方抢到该行
            Integer updateNum = queueMapper.preemptWaitingPatientById(
                    preemptQueueItem.getId(),
                    bedBO.getRoomId(),
                    bedBO.getRoomName(),
                    bedBO.getBedNo(),
                    QueueStatusEnum.WAITING.getStatus(),
                    QueueStatusEnum.READY.getStatus());
            if (null == updateNum || 0 == updateNum) {
                // 被其它工位抢先:本轮放弃,等下一次补齐触发
                return;
            }
 
            // 内存对象与库内保持一致 —— 亲和项要跟随主项的诊室/工位
            preemptQueueItem.setStatus(QueueStatusEnum.READY.getStatus());
            preemptQueueItem.setRoomId(bedBO.getRoomId());
            preemptQueueItem.setRoomName(bedBO.getRoomName());
            preemptQueueItem.setBedNo(bedBO.getBedNo());
 
            CheckTypeDO checkTypeDO = getCheckTypeItem( preemptQueueItem.getBookCheckType() );
 
            // 抢到排队患者后,处理 检查项目.亲和性 逻辑
            if ( checkTypeDO.getAffinityCheckTypes().length > 0) {
                procAffinityWhenPreempt(preemptQueueItem, checkTypeDO.getAffinityCheckTypes());
            }
 
            bedBO.readyPatNum++;
            pulledNum++;
        }
 
        if (pulledNum > 0) {
            log.info("[补人] 已补入 {} 人. 工位={}/{} 当前候诊={} 上限={}",
                    pulledNum, bedBO.getRoomId(), bedBO.getBedNo(),
                    bedBO.getReadyPatNum(), bedBO.getMaxReadyPatNum());
        }
 
        // 改变了分流队列顺序
        refreshBedPriorityLevelInRoutingQueue(bedBO);
    }
 
    /** 库里该工位当前「准备中(20)」的真实人数(用于校正内存计数与现场诊断) */
    private int countReadyInDb(Long roomId, String bedNo) {
        List<Integer> readyStatus = new ArrayList<>();
        readyStatus.add(QueueStatusEnum.READY.getStatus());
        List<QueueDO> readyList = queueMapper.getBedQueueByStatus(roomId, bedNo, readyStatus);
        return null == readyList ? 0 : readyList.size();
    }
 
    /**
     * 立即补人(现场自救 + 自助诊断):手动触发一次该工位的自动分流,并把"为什么没补/补了几人"一起返回。
     *
     * <p>用于工作台候诊区为空时的「立即补人」按钮:不必等定时任务、也不用重启去猜是哪道门闸挡住的。
     */
    public Map<String, Object> hurryUpNow(Long roomId, String bedNo) {
        Map<String, Object> data = new LinkedHashMap<>();
        int flag = openingFlag.get();
        data.put("系统开诊状态", 0 == flag ? "已闭诊(此时不会自动补人,请先在监控页点「开诊」)" : "开诊中");
 
        BedBO bedBO = getBedQueueBO(roomId, bedNo);
        if (null == bedBO) {
            data.put("结论", "该工位不在「已开诊工位」列表里(可能已关闭/本次未开诊),无法补人;请先到选工位页入座或让管理员开诊");
            log.warn("[补人] hurryUpNow 工位不存在. 工位={}/{}", roomId, bedNo);
            return data;
        }
 
        boolean stopReady = Boolean.TRUE.equals(bedBO.getStopReady());
        int before = countReadyInDb(roomId, bedNo);
        int memoryBefore = bedBO.getReadyPatNum();
        // 先按库内真实值校正内存(内存虚高会导致永久"已满")
        bedBO.setReadyPatNum(before);
 
        data.put("工位", bedBO.getRoomName() + " " + bedNo);
        data.put("检查项目", null == bedBO.getCheckTypes() ? "[]" : java.util.Arrays.toString(bedBO.getCheckTypes()));
        data.put("停止候诊", stopReady);
        data.put("候诊上限", bedBO.getMaxReadyPatNum());
        data.put("补人前候诊人数", before + (memoryBefore == before ? "" : "(内存曾为 " + memoryBefore + ",已校正)"));
 
        if (0 == flag) {
            data.put("结论", "系统已闭诊,本次未补人;请先在监控页点「开诊」");
            return data;
        }
        if (stopReady) {
            data.put("结论", "本工位开了「停止候诊」,本次未补人;如需接收患者请关闭该开关");
            return data;
        }
 
        hurryupOneBed(roomId, bedNo);
 
        int after = countReadyInDb(roomId, bedNo);
        int pulled = Math.max(0, after - before);
        data.put("补人后候诊人数", after);
        data.put("本次补入", pulled);
        if (pulled > 0) {
            data.put("结论", "已补入 " + pulled + " 人");
        } else if (after >= bedBO.getMaxReadyPatNum()) {
            data.put("结论", "候诊区已达上限(" + after + "/" + bedBO.getMaxReadyPatNum() + "),不再补人");
        } else {
            data.put("结论", "没有匹配本工位检查项目的「排队中」患者(可到工作台查看「本诊室排队中(未分配)」分区)");
        }
        log.info("[补人] hurryUpNow 工位={}/{} 前={} 后={} 结论={}", roomId, bedNo, before, after, data.get("结论"));
        return data;
    }
 
    /**
     * 有检查项分流到诊室后,也就是进入[候诊中]后,拉 [排队中] + 具有亲和性的 检查项 来依附该检查项。
     *
     * <p>【亲和项与排队号】亲和项目(同一患者、可在一个工位一起做的多个检查项)在候诊区
     * <b>跟随主项显示</b>:这里把亲和项的排队号改成**主项的号**、并搬到主项所在诊室/工位,
     * 界面(工作台/诊间屏/大屏)才能把两者排在一起。
     * <p>注意这**不是**"重新赋号":号仍是签到时定的那个,只是亲和项沿用主项的号,
     * 与"分流/叫号/召回不再改号"并不冲突。
     */
    private void procAffinityWhenPreempt(QueueDO preemptQueueItem, Integer[] affinityCheckTypes) {
        List<QueueDO> affinityItems = queueMapper.getCurPatGivenCheckTypesAndStatus(
                preemptQueueItem.getPatId(), affinityCheckTypes, QueueStatusEnum.WAITING.getStatus());
        for (int i = 0; i < affinityItems.size(); i++) {
            QueueDO queueItem = affinityItems.get(i);
            queueItem.setStatus(QueueStatusEnum.AFFINITY.getStatus()); //改变 排队状态
            queueItem.setAffinityItem( 1 );
            queueItem.setRoomId(preemptQueueItem.getRoomId());
            queueItem.setRoomName(preemptQueueItem.getRoomName());
            queueItem.setBedNo(preemptQueueItem.getBedNo());
            queueItem.setSeqNum( preemptQueueItem.getSeqNum());  // 亲和项沿用主项的排队号(跟随显示)
            queueMapper.updateById(queueItem);
        }
    }
 
    /**
     * [过号] 患者 => [候诊中] ,处理 检查项目.亲和性 逻辑。
     *
     * <p>召回时主项的号不变,因此只需让已经跟随的亲和项保持一致(保持同号 → 界面仍排在一起)。
     */
    private void procAffinityWhenRecalled(QueueDO recalledQueueItem, Integer[] affinityCheckTypes) {
        List<QueueDO> affinityItems = queueMapper.getCurPatGivenCheckTypesAndStatus(
                recalledQueueItem.getPatId(), affinityCheckTypes, QueueStatusEnum.AFFINITY.getStatus());
        for (int i = 0; i < affinityItems.size(); i++) {
            QueueDO queueItem = affinityItems.get(i);
            queueItem.setSeqNum( recalledQueueItem.getSeqNum() );  // 亲和项与主项保持同一个号
            queueMapper.updateById(queueItem);
        }
    }
 
    /**
     * 工位「候诊区容量上限」= 该工位开展的**各检查项目** {@code check_type.ready_num}
     * (页面字段:「检查类型管理」→「候诊准备人数」)的**最大值**。
     *
     * <p><b>返回 0 表示"不自动分流"</b>:此时排队中(10) 的患者**不会**被主动搬进候诊区(20),
     * 需要医生在候诊区用「拉入」手动补人(现场确认的口径)。
     *
     * <p>【本次加固】
     * <ul>
     *   <li><b>实时读库</b>:原来这份配置只在服务启动时加载一次
     *       ({@code MySpringEventListener} → {@code initCheckType()}),
     *       在页面上改了「候诊准备人数」必须**重启服务**才生效;</li>
     *   <li><b>容错</b>:检查项目在配置里查不到、或 {@code ready_num} 为空 → **跳过并记警告**,
     *       不再抛 NPE。原先这个 NPE 发生在 {@code addBedToRoutingQueue}(入座/开诊/重载都会走),
     *       会连带把入座弄失败。</li>
     * </ul>
     */
    private Integer getBedReadyMax(BedBO bedBO) {
        Integer[] checkTypes = bedBO.getCheckTypes();
        if (null == checkTypes || 0 == checkTypes.length) {
            return 0;
        }
        Map<Integer, CheckTypeDO> fresh = readCheckTypeMapFresh();
        int ret = 0;
        for (Integer checkType : checkTypes) {
            CheckTypeDO checkTypeDO = fresh.get(checkType);
            if (null == checkTypeDO) {
                // 兼容:实时读失败时回落到启动缓存,两边都没有就跳过(不再 NPE)
                checkTypeDO = getCheckTypeItem(checkType);
            }
            if (null == checkTypeDO) {
                log.warn("[getBedReadyMax] 检查项目在 check_type 里不存在,跳过(按 0 计). checkType={} 工位={}/{}",
                        checkType, bedBO.getRoomId(), bedBO.getBedNo());
                continue;
            }
            Integer tmp = checkTypeDO.getReadyNum();
            if (null == tmp) {
                log.warn("[getBedReadyMax] 检查项目[候诊准备人数]为空,跳过. checkType={} name={}",
                        checkType, checkTypeDO.getName());
                continue;
            }
            ret = Math.max(ret, tmp);  //【候诊中】数量 取大值
        }
        return ret;
    }
 
    /**
     * 实时读取「检查项目值 → 检查项目配置」映射(每次装配工位时读一次全表,检查项目只有十几个,量级很小)。
     * 这样管理员在「检查类型管理」里改了「候诊准备人数」**立刻生效,不用重启服务**。
     * 读库失败时回落到启动时加载的缓存({@link #initCheckType()})。
     */
    private Map<Integer, CheckTypeDO> readCheckTypeMapFresh() {
        try {
            List<CheckTypeDO> list = checkTypeMapper.simpleCheckTypeList();
            Map<Integer, CheckTypeDO> map = new HashMap<>();
            for (CheckTypeDO item : list) {
                if (null != item && null != item.getValue()) {
                    map.put(item.getValue(), item);
                }
            }
            return map;
        } catch (Exception e) {
            log.warn("[readCheckTypeMapFresh] 实时读检查项目失败,改用启动时缓存", e);
            return null == mapCheckTypeVsReadyMax ? new HashMap<>() : mapCheckTypeVsReadyMax;
        }
    }
 
    private void addBedToRoutingQueue(BedBO bedBO) {
        // 装机工位 不进 分流队列,不接收 【排队中】患者分流
        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()); //[候诊中]
        List<QueueDO> queueDOList = queueMapper.getBedQueueByStatus(bedBO.getRoomId(), bedBO.getBedNo(), queueStatusList);
        bedBO.setMaxReadyPatNum( getBedReadyMax(bedBO) );
        bedBO.setReadyPatNum( queueDOList.size() );
 
        // 【现场排查】把"候诊区上限 / 当前人数 / 来源"打出来:
        // 上限=0 表示**不自动分流**(排队中的患者不会被主动搬进候诊区,需在候诊区手动「拉入」)。
        // 现场反馈过"有这么多排队病人,为什么没主动加载到候诊区",一条日志即可自证。
        log.info("[addBedToRoutingQueue] 工位 {}/{} 候诊上限={} 当前候诊={} 检查项目={}"
                        + "(上限=各检查项目 check_type.ready_num 的最大值;0=不自动分流,靠工位手动「拉入」)",
                bedBO.getRoomId(), bedBO.getBedNo(), bedBO.getMaxReadyPatNum(), bedBO.getReadyPatNum(),
                null == bedBO.getCheckTypes() ? "[]" : java.util.Arrays.toString(bedBO.getCheckTypes()));
 
        Integer[] checkTypes = bedBO.getCheckTypes();
        Arrays.stream(checkTypes).forEach(checkType -> {
            CheckTypeDO checkTypeDO = mapCheckTypeVsReadyMax.get(checkType);
            PriorityBlockingQueue<BedBO> routingQueue = mapCheckTypeRoutingQueue.get(checkType);
            if (null == routingQueue) {
                routingQueue = new PriorityBlockingQueue<BedBO>();
                mapCheckTypeRoutingQueue.put( checkType, routingQueue );
            }
 
            if (!routingQueue.contains(bedBO)) {
                routingQueue.offer(bedBO);
            } else {
                log.error("bedDoctorResume routingQueue still exist!. " + bedBO.getRoomId() + " " + bedBO.getBedNo());
            }
        });
    }
 
    private void removeBedFromRoutingQueue(BedBO bedBO) {
        // 装机工位 不进 分流队列,不接收 【排队中】患者分流
        if (Objects.equals(BedOpTypeEnum.OP_INSTALL.getOpType(), bedBO.getOpType()))
            return;
 
        Integer[] checkTypes = bedBO.getCheckTypes();
        Arrays.stream(checkTypes).forEach(checkType -> {
            PriorityBlockingQueue<BedBO> routingQueue = mapCheckTypeRoutingQueue.get(checkType);
            if (null != routingQueue)
                routingQueue.remove(bedBO);
        });
    }
 
    private void refreshBedPriorityLevelInRoutingQueue(BedBO bedBO) {
        // 装机工位 不进 分流队列,不接收 【排队中】患者分流
        if (Objects.equals(BedOpTypeEnum.OP_INSTALL.getOpType(), bedBO.getOpType()))
            return;
 
        Integer[] checkTypes = bedBO.getCheckTypes();
        Arrays.stream(checkTypes).forEach(checkType -> {
            PriorityBlockingQueue<BedBO> routingQueue = mapCheckTypeRoutingQueue.get(checkType);
            if (null != routingQueue) {
                routingQueue.remove(bedBO);
                routingQueue.offer(bedBO);
            }
        });
    }
 
    /**
     * 检查项进入到 [安装中] 时,拉 [已领用] + 具有亲和性的 检查项 来依附该检查项
     */
    private void procAffinityWhenSomeOneEntryInstalling(String patId, Long roomId_operator, String bedNo_operator, Integer[] affinityCheckTypes) {
        List<QueueDO> affinityItems = queueMapper.getCurPatGivenCheckTypesAndStatus(
                                patId, affinityCheckTypes, QueueStatusEnum.RECEIVED.getStatus());
        for (int i = 0; i < affinityItems.size(); i++) {
            QueueDO queueItem = affinityItems.get(i);
            queueItem.setStatus(QueueStatusEnum.AFFINITY_INSTALL.getStatus()); //改变 排队状态
            queueItem.setAffinityItem(1);
            queueItem.setRoomId(roomId_operator);
            queueItem.setBedNo(bedNo_operator);  // 跟随到 装机 工位
            queueMapper.updateById(queueItem);
        }
    }
 
    private void roomBedStatistic(MonitorInfoVO monitorInfoVO) {
        Map<Integer, Integer>  mapOpeningCheckBedStatInfo = new HashMap<>();
        Map<Integer, Integer>  mapOpeningInstallBedStatInfo = new HashMap<>();
        Map<Integer, Integer>  mapDocCheckBedStatInfo = new HashMap<>();
        Map<Integer, Integer>  mapDocInstallBedStatInfo = new HashMap<>();
        mapCheckTypeVsReadyMax.keySet().forEach( checkType -> {
            mapOpeningCheckBedStatInfo.put(checkType, 0);
            mapOpeningInstallBedStatInfo.put(checkType, 0);
            mapDocCheckBedStatInfo.put(checkType, 0);
            mapDocInstallBedStatInfo.put(checkType, 0);
        });
 
        //开通的 和 运营中 的工位统计
        mapOpeningBed.values().forEach(bedQueueBO -> {
            Integer[] checkTypes = bedQueueBO.getCheckTypes();
            Arrays.stream(checkTypes).forEach(checkType -> {
                if (bedQueueBO.opType == 0 || bedQueueBO.opType == 1)
                    mapOpeningCheckBedStatInfo.put(checkType, mapOpeningCheckBedStatInfo.get(checkType) + 1 );
                else
                    mapOpeningInstallBedStatInfo.put(checkType, mapOpeningInstallBedStatInfo.get(checkType) + 1 );
 
                if (bedQueueBO.getStatus() == BedStatusEnum.DOCTOR_ON.getStatus() ||
                        bedQueueBO.getStatus() == BedStatusEnum.DOCTOR_PAUSE.getStatus()) {
                    if (bedQueueBO.opType == 0 || bedQueueBO.opType == 1)
                        mapDocCheckBedStatInfo.put(checkType, mapDocCheckBedStatInfo.get(checkType) + 1);
                    else
                        mapDocInstallBedStatInfo.put(checkType, mapDocInstallBedStatInfo.get(checkType) + 1);
                }
            });
        });
 
        // 接收..排队中患者..的分流队列
        HashSet<BedBO> hashSetOperatingBedQueue = new HashSet<>();
        mapCheckTypeRoutingQueue.values().forEach(routingQueue->{
            routingQueue.stream().forEach(bedQueueBO -> hashSetOperatingBedQueue.add(bedQueueBO));
        });
 
 
        Map<Integer, String> mapCheckTypeBedInfo = new HashMap<>();
        mapCheckTypeVsReadyMax.keySet().forEach( checkType -> {
           String str = mapOpeningCheckBedStatInfo.get(checkType) + " " +
                        mapOpeningInstallBedStatInfo.get(checkType) + " " +
                        mapDocCheckBedStatInfo.get(checkType) + " " +
                        mapDocInstallBedStatInfo.get(checkType) + " ";
            mapCheckTypeBedInfo.put( checkType, str);
        });
 
        monitorInfoVO.setCheckTypeBedInfo(mapCheckTypeBedInfo);
        monitorInfoVO.setOpeningBedNum(mapOpeningBed.size());
        monitorInfoVO.setDocBedNum((int) mapOpeningBed.values().stream().filter(item ->
                item.getStatus() == BedStatusEnum.DOCTOR_ON.getStatus() ||
                item.getStatus() == BedStatusEnum.DOCTOR_PAUSE.getStatus())
            .count());
        // 【本需求·统计按人合并】在座**医生数**:一人多工位时上面的 docBedNum 会按工位累计,
        // 这里按 doc_id 去重,给监控页一个"到底几个医生在岗"的指标。
        List<Integer> seatedStatusList = new ArrayList<>();
        seatedStatusList.add(BedStatusEnum.DOCTOR_ON.getStatus());
        seatedStatusList.add(BedStatusEnum.DOCTOR_PAUSE.getStatus());
        try {
            Integer docNum = roomMapper.countSeatedDoctors(seatedStatusList);
            monitorInfoVO.setDocNum(null == docNum ? 0 : docNum);
        } catch (Exception e) {
            // 统计失败不影响监控页其它数据
            log.warn("[roomBedStatistic] 统计在座医生数失败(不影响其它指标)", e);
            monitorInfoVO.setDocNum(0);
        }
        monitorInfoVO.setRoutingBedNum( hashSetOperatingBedQueue.size() );
        monitorInfoVO.setMapBedVsQueue(mapOpeningBed);
    }
 
}