yxh
7 天以前 68ff33beff11310e830b0615b5a81f83fad0f683
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
package cn.lihu.jh.module.ecg.service.queuesequence;
 
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.*;
import cn.lihu.jh.module.ecg.dal.dataobject.queuesequence.QueueSequenceDO;
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;
 
import javax.annotation.Resource;
 
import static cn.lihu.jh.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.lihu.jh.module.ecg.Constants.*;
import static cn.lihu.jh.module.ecg.enums.ErrorCodeConstants.*;
 
/**
 * 当天序号 Service 实现类
 *
 * @author 金华医院
 */
@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;
 
    @Resource
    private DictDataApi dictDataApi;
 
    @Resource
    private QueueSequenceMapper queueSequenceMapper;
 
    @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();
        Integer rowCount = queueSequenceMapper.getQueueSequenceTableRowCount();
        if (null == rowCount || 0 == rowCount) {
            resetQueueSequence();
        }
    }
 
    /**
     * 新增预约时段规则。
     *
     * <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);
        if (null == queueSequence.getTimeSlot() || 0 != queueSequence.getTimeSlot()) {
            // 【本需求】号段起点 / 预留起点由系统计算,不接受前端随意传值(否则号段会重叠)
            queueSequence.setQueueSet(null);
            queueSequence.setQueueVipSet(null);
        }
        queueSequenceMapper.insert(queueSequence);
        // 【本需求】新增/编辑后统一重排该检查类型的号段(保证"段与段首尾相接")
        if (null != createReqVO.getCheckType()) {
            recalcSegmentBounds(createReqVO.getCheckType());
        }
        // 返回
        return queueSequence.getId();
    }
 
    @Override
    public void updateQueueSequence(QueueSequenceSaveReqVO updateReqVO) {
        // 校验存在
        validateQueueSequenceExists(updateReqVO.getId());
        // 【校验】改了 checkType 或 timeSlot 时不能与其它行撞车(排除自身)
        validateTimeslotNotDuplicated(updateReqVO.getCheckType(), updateReqVO.getTimeSlot(),
                updateReqVO.getId());
        // 更新
        QueueSequenceDO updateObj = BeanUtils.toBean(updateReqVO, QueueSequenceDO.class);
        // 【本需求】起始序号由系统重排,忽略前端传值(前端已置为只读)
        updateObj.setQueueSet(null);
        updateObj.setQueueVipSet(null);
        queueSequenceMapper.updateById(updateObj);
        if (null != updateReqVO.getCheckType()) {
            recalcSegmentBounds(updateReqVO.getCheckType());
        }
    }
 
    /**
     * 【本需求】按「时段升序」重排某检查类型的号段:
     * <pre>
     *   段 i:queue_set = 上一段的 queue_vip_full(首段 0)
     *         queue_full = queue_set + P
     *         queue_vip_set = queue_full
     *         queue_vip_full = queue_vip_set + V
     *   其中 P / V = 该行当前的(queue_full - queue_set)、(queue_vip_full - queue_full),
     *   即各段的"额度"保持不变,只把起点串起来。
     * </pre>
     * 这样无论新增/删除/编辑时段,都不会出现号段重叠或空洞错位。
     * <p>总预留池(time_slot = 0)不参与重排,只把起点对齐到最后一段之后。
     */
    private void recalcSegmentBounds(Integer checkType) {
        if (null == checkType) {
            return;
        }
        List<QueueSequenceDO> all = queueSequenceMapper.selectTimeslotByCheckType(checkType);
        if (null == all || all.isEmpty()) {
            return;
        }
        all.sort(Comparator.comparingInt(r -> null == r.getTimeSlot() ? 0 : r.getTimeSlot()));
 
        CheckTypeDO ct = checkTypeMapper.getCheckTypeByValue(checkType);
 
        int cursor = 0;
        int slotCount = 0;
        for (QueueSequenceDO row : all) {
            int slot = null == row.getTimeSlot() ? 0 : row.getTimeSlot();
            if (0 == slot) {
                continue;   // 总预留池最后单独处理
            }
            slotCount++;
 
            // 额度(宽):优先沿用该行现有号段宽度;未知/非正时回退到检查类型配置
            int p = width(row.getQueueSet(), row.getQueueFull());
            if (p <= 0) {
                p = (null == ct || null == ct.getTimeslotBookNum()) ? 0 : ct.getTimeslotBookNum();
            }
            int v = width(row.getQueueFull(), row.getQueueVipFull());
            if (v < 0) {
                v = (null == ct || null == ct.getTimeslotReservedNum()) ? 0 : ct.getTimeslotReservedNum();
            }
 
            QueueSequenceDO update = new QueueSequenceDO();
            update.setId(row.getId());
            update.setQueueSet(cursor);
            update.setQueueFull(cursor + p);
            update.setQueueVipSet(cursor + p);
            update.setQueueVipFull(cursor + p + v);
            // 游标只**上抬**到号段起点,绝不**下调**:
            // 下调会让已发出的号被再次发出(重号)。若游标已超过上限,说明该段已超发,
            // 取号时自然会落到「预留号 → 总预留池」。
            update.setQueueNo(raiseAtLeast(row.getQueueNo(), cursor));
            update.setQueueVipNo(raiseAtLeast(row.getQueueVipNo(), cursor + p));
            queueSequenceMapper.updateById(update);
 
            cursor = cursor + p + v;
        }
 
        // 总预留池:起点对齐到最后一段之后
        QueueSequenceDO pool = queueSequenceMapper.selectTotalPool(checkType);
        if (null != pool) {
            int poolFull = Math.max(null == pool.getQueueVipFull() ? 0 : pool.getQueueVipFull(), cursor);
            QueueSequenceDO update = new QueueSequenceDO();
            update.setId(pool.getId());
            update.setQueueVipSet(cursor);
            update.setQueueVipFull(poolFull);
            update.setQueueVipNo(raiseAtLeast(pool.getQueueVipNo(), cursor));
            queueSequenceMapper.updateById(update);
        }
        log.info("[recalcSegmentBounds] 已重排号段. checkType={} 段数={} 末尾最大号={}",
                checkType, slotCount, cursor);
    }
 
    /** 号段宽度(终止 - 起始),任一为 null 返回 -1 表示"未知" */
    private int width(Integer from, Integer to) {
        if (null == from || null == to) {
            return -1;
        }
        return to - from;
    }
 
    /**
     * 游标只上抬、不下调(下调会导致已发出的号被再次发出 ⇒ 重号)。
     */
    private Integer raiseAtLeast(Integer value, int min) {
        int v = null == value ? min : value;
        return Math.max(v, min);
    }
 
    /**
     * 校验「检查类型 + 时段」在表中唯一。
     *
     * @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
    public void deleteQueueSequence(Integer id) {
        // 校验存在
        QueueSequenceDO exist = queueSequenceMapper.selectById(id);
        if (null == exist) {
            throw exception(QUEUE_SEQUENCE_NOT_EXISTS);
        }
        // 删除
        queueSequenceMapper.deleteById(id);
        // 【本需求】删除后重排号段(否则后续时段的起点会留空洞)
        recalcSegmentBounds(exist.getCheckType());
    }
 
    private void validateQueueSequenceExists(Integer id) {
        if (queueSequenceMapper.selectById(id) == null) {
            throw exception(QUEUE_SEQUENCE_NOT_EXISTS);
        }
    }
 
    @Override
    public QueueSequenceDO getQueueSequence(Integer id) {
        return queueSequenceMapper.selectById(id);
    }
 
    @Override
    public List<QueueSequenceDO> selectGivenCheckTypeTimeslot(Integer checkType) {
        return queueSequenceMapper.selectTimeslotByCheckType( checkType );
    }
 
    @Override
    public PageResult<QueueSequenceDO> getQueueSequencePage(QueueSequencePageReqVO pageReqVO) {
        return queueSequenceMapper.selectPage(pageReqVO);
    }
 
    @Override
    public void resetQueueSequence() {
        queueSequenceMapper.clearQueueSequenceTable();
 
        // 读取时段配置
        String strBookTimeslotLength = configApi.getConfigValueByKey(BOOK_TIMESLOT_LENGTH);
 
        // 方案1:配置中取    7:30,8:30,9:30,10:30,11:30,12:30,13:30,14:30,15:30
        //String strBookTimeslotList = configApi.getConfigValueByKey(BOOK_TIMESLOT_LIST);
        //List<LocalTime> timeslotList = Utils.parseTimeSlotList(strBookTimeslotList, Integer.valueOf(strBookTimeslotLength));
 
        List<DictDataRespDTO> dictBookTimeslotList = dictDataApi.getDictDataList(ECG_BOOK_TIMESLOT);
 
        // 读取所有的检查类型
        List<CheckTypeDO> checkTypeDOList =  checkTypeMapper.simpleCheckTypeList();
        checkTypeDOList.forEach( checkTypeDO -> {
 
            for (int i=0; i < dictBookTimeslotList.size(); i++) {
                QueueSequenceDO queueSequenceDO = new QueueSequenceDO();
                queueSequenceDO.setCheckType( checkTypeDO.getValue() );
                queueSequenceDO.setTimeSlot( Integer.valueOf(dictBookTimeslotList.get(i).getValue()) );
                queueSequenceDO.setQueueNo( i * checkTypeDO.getTimeslotBookNum() + checkTypeDO.getTimeslotReservedNum());
                queueSequenceDO.setQueueVipNo( i * checkTypeDO.getTimeslotBookNum());
                queueSequenceDO.setQueueFull( i * checkTypeDO.getTimeslotBookNum() + checkTypeDO.getTimeslotBookNum());
                queueSequenceDO.setQueueVipFull( i * checkTypeDO.getTimeslotBookNum() + checkTypeDO.getTimeslotReservedNum());
                queueSequenceMapper.insert( queueSequenceDO );
            };
        });
    }
 
    @Override
    @Transactional
    public Integer distributeSeqNo(Integer checkType, Integer timeslot) {
        // 【本需求:按时段预约】普通患者三级取号,**不再跨段借号**:
        //   ① 本时段普通号(queue_no   < queue_full)
        //   ② 本时段预留号(queue_vip_no < queue_vip_full)
        //   ③ 总预留池(time_slot=0,**不设上限**)
        // 任何一级成功即返回;③ 永不失败 ⇒ 不存在"签不上"。
        // 无预约时段(timeslot 为 null/0/字典外的值)时 ①② 自然查不到行,直接落到 ③。
 
        // ① 本段普通号
        Integer curSeqNo = queueSequenceMapper.selectQueueNoForUpdate(checkType, timeslot);
        if (null != curSeqNo) {
            Integer updateNum = queueSequenceMapper.updateGivenCheckTypeTimeslotSeqNo(checkType, timeslot, curSeqNo);
            if (null != updateNum && updateNum > 0) {
                return curSeqNo + 1;
            }
            // 并发被抢空 → 落到 ②
            log.info("[distributeSeqNo] 本段普通号并发被抢空,降级到预留号. checkType={} timeslot={}", checkType, timeslot);
        }
 
        // ② 本段预留号
        Integer curVipNo = queueSequenceMapper.selectQueueVipNoForUpdate(checkType, timeslot);
        if (null != curVipNo) {
            Integer updateNum = queueSequenceMapper.updateGivenCheckTypeTimeslotVipSeqNo(checkType, timeslot, curVipNo);
            if (null != updateNum && updateNum > 0) {
                log.info("[distributeSeqNo] 普通号已满,改发本段预留号. checkType={} timeslot={} seqNo={}",
                        checkType, timeslot, curVipNo + 1);
                return curVipNo + 1;
            }
            log.info("[distributeSeqNo] 本段预留号并发被抢空,降级到总预留池. checkType={} timeslot={}", checkType, timeslot);
        }
 
        // ③ 总预留池(不设上限,保证一定能签到)
        return nextTotalPoolSeqNo(checkType, timeslot);
    }
    /**
     * 【本需求】VIP / 临时加号患者取号:**直接取本段预留号**;本段预留也满 → 总预留池。
     * <p>
     * 与普通通道的差别只在于"跳过本段普通号",其余(预留号、总池兜底)完全一致。
     */
    @Override
    @Transactional
    public Integer distributeVipSeqNo(Integer checkType, Integer timeslot) {
        Integer curVipSeqNo = queueSequenceMapper.selectQueueVipNoForUpdate(checkType, timeslot);
        if (null != curVipSeqNo) {
            Integer updateRowNum = queueSequenceMapper.updateGivenCheckTypeTimeslotVipSeqNo(checkType, timeslot, curVipSeqNo);
            if (null != updateRowNum && updateRowNum > 0) {
                return curVipSeqNo + 1;
            }
            log.info("[distributeVipSeqNo] 本段预留号并发被抢空,降级到总预留池. checkType={} timeslot={}", checkType, timeslot);
        }
        return nextTotalPoolSeqNo(checkType, timeslot);
    }
 
    /**
     * 【本需求】总预留池取号({@code time_slot = 0} 那一行)—— **永不失败**。
     *
     * <h3>为什么不会满</h3>
     * 总池的 {@code queue_vip_full} 在发号时同步"顶高"(见 Mapper 的
     * {@code advanceTotalPoolVipNo}),因此 {@code queue_vip_no} 永远可以继续 +1。
     * 这是"绝不出现签不上"的最后一道兜底。
     *
     * <h3>总池行缺失时自动补</h3>
     * 号段放在当天所有时段的最大号之后({@code max(queue_vip_full)}),
     * 保证总池号 **大于所有时段号** ⇒ 排序天然排在全天最后。
     */
    private Integer nextTotalPoolSeqNo(Integer checkType, Integer timeslot) {
        Integer curVipNo = null;
        try {
            curVipNo = queueSequenceMapper.selectTotalPoolVipNo(checkType);
        } catch (Exception e) {
            log.warn("[nextTotalPoolSeqNo] 读取总预留池异常,按缺失处理. checkType={}", checkType, e);
        }
 
        if (null == curVipNo) {
            // 现场兜底:自动补一行总预留池(幂等:以 检查类型 + time_slot=0 唯一)
            Integer base = queueSequenceMapper.selectMaxVipFullOfCheckType(checkType);
            if (null == base) {
                base = 0;
            }
            QueueSequenceDO pool = new QueueSequenceDO();
            pool.setCheckType(checkType);
            pool.setTimeSlot(0);
            pool.setQueueSet(0);
            pool.setQueueNo(0);
            pool.setQueueFull(0);
            pool.setQueueVipSet(base);
            pool.setQueueVipNo(base);
            pool.setQueueVipFull(base);
            try {
                queueSequenceMapper.insert(pool);
            } catch (Exception e) {
                // 并发下可能已被别的线程插入,忽略后继续读
                log.warn("[nextTotalPoolSeqNo] 补总预留池行失败(可能并发已插入),继续取号. checkType={}", checkType, e);
            }
            if (null == queueSequenceMapper.selectTotalPoolVipNo(checkType)) {
                // 插入后仍读不到(极端情况)→ 用当天最大号兜底,保证能签到
                Integer maxFull = queueSequenceMapper.selectMaxVipFullOfCheckType(checkType);
                log.error("[nextTotalPoolSeqNo] 总预留池不可用,使用最大号兜底. checkType={} maxFull={}", checkType, maxFull);
                return (null == maxFull ? 0 : maxFull) + 1;
            }
        }
 
        Integer updateNum = queueSequenceMapper.advanceTotalPoolVipNo(checkType);
        if (null == updateNum || updateNum == 0) {
            throw exception(QUEUE_SEQUENCE_TIMESLOT_EXCEPTION);
        }
        Integer after = queueSequenceMapper.selectTotalPoolVipNo(checkType);
        if (null == after) {
            throw exception(QUEUE_SEQUENCE_TIMESLOT_EXCEPTION);
        }
        log.info("[nextTotalPoolSeqNo] 已从总预留池取号(本段已满或没有时段). checkType={} timeslot={} seqNo={}",
                checkType, timeslot, after);
        return after;
    }
 
    /**
     * 按【检查类型配置的规则】分配排队序号。
     *
     * <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) {
        // 兼容旧调用方:未传 VIP 标记时按"非 VIP"处理
        return distributeSeqNoByRule(checkType, timeslot, existingSeqNo, urgent, false);
    }
 
    /**
     * 【本需求】带 VIP/加号标记的取号入口。
     * <p>
     * 与四参版本的唯一差别:按时段预约(TIMESLOT)下,VIP/加号患者**直接取本段预留号**
     * (见 {@link #distributeVipSeqNo}),而不是先取普通号;签到序号(CHECKIN)下
     * VIP/加号同样可占用预留号(每 9 留 1 的预留位)。
     */
    @Override
    @Transactional
    public Integer distributeSeqNoByRule(Integer checkType, Integer timeslot,
                                        Integer existingSeqNo, boolean urgent, boolean vip) {
        SeqRuleEnum rule = resolveRule(checkType);
 
        switch (rule) {
            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 || vip);
 
            case CHECKIN:
                // ② 签到序号:当天同检查类型连续递增,每 9 个预留 1 个
                return nextCheckInSeqNo(checkType, urgent || vip);
 
            case TIMESLOT:
            default:
                // ③ 时段号:VIP/加号走「本段预留号 → 总预留池」;普通走「普通 → 预留 → 总池」
                if (vip) {
                    return distributeVipSeqNo(checkType, timeslot);
                }
                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()
                .filter(e -> e.getValue().equals(String.valueOf(timeslot)))
                .findFirst()
                .map(dictBookTimeslotList::indexOf);
        if (!index.isPresent())
            return null;
 
        if (dictBookTimeslotList.size() <= (index.get() + 1))
            return null;
 
        DictDataRespDTO dictDataRespDTO = dictBookTimeslotList.get(index.get() + 1);
        return Integer.valueOf(dictDataRespDTO.getValue());
    }
    @Override
    public void initNumber() {
        queueSequenceMapper.initNumber();
    }
 
}