yxh
昨天 fce96ef468291fb9a0e6a4d34ab371315e9485d4
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
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);
        queueSequenceMapper.insert(queueSequence);
        // 返回
        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);
        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
    public void deleteQueueSequence(Integer id) {
        // 校验存在
        validateQueueSequenceExists(id);
        // 删除
        queueSequenceMapper.deleteById(id);
    }
 
    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) {
        Integer curSeqNo = queueSequenceMapper.selectQueueNoForUpdate(checkType, timeslot);
        if (null == curSeqNo) {
            // 【修复「时段不全/已满导致没法签到」】
            // 原逻辑:只取"字典里的紧邻下一个时段",且要求该时段在 queue_sequence 中存在。
            // 现实问题(实测):
            //   · 字典 ecg_book_timeslot 有 21 个时段,但 queue_sequence 中 checkType=400
            //     只有 13 个、checkType=100 只有 17 个 —— 部分时段根本没有号池行;
            //   · HIS 会下发 60 分钟粒度的 bookTimeslot(如 10001100),字典里没有该值,
            //     getNextTimeSlot 直接 return null;
            //   · 个别时段容量很小(平板运动 timeslot_book_num=3),很快销完。
            // 三者任一命中 → 抛 QUEUE_SEQUENCE_TIMESLOT_FULL → **患者没法签到**。
            //
            // 现在改为:**向后最多找 N 个时段,取第一个仍有余量的**(下面 findAvailableTimeslot 内实现),
            // 大幅降低"因个别时段缺失/已满就签到失败"的概率;确实都满了才抛错。
            Integer nextTimeslot = findAvailableTimeslot(checkType, timeslot, false);
            if (null == nextTimeslot) {
                log.error("[distributeSeqNo] 该检查类型向后 {} 个时段内均无可取号时段. checkType={} timeslot={}",
                        MAX_TIMESLOT_LOOKAHEAD, checkType, timeslot);
                throw exception(QUEUE_SEQUENCE_TIMESLOT_FULL);
            }
 
            curSeqNo = queueSequenceMapper.selectQueueNoForUpdate(checkType, nextTimeslot);
            if (null == curSeqNo) {
                // 探测与取号之间被并发抢空,保守报错(由前端提示重新签到)
                log.warn("[distributeSeqNo] 探测到的时段随即被占满. checkType={} timeslot={}",
                        checkType, nextTimeslot);
                throw exception(QUEUE_SEQUENCE_TIMESLOT_FULL);
            }
 
            Integer updateRowNum = queueSequenceMapper.updateGivenCheckTypeTimeslotSeqNo(checkType, nextTimeslot, curSeqNo);
            if (null == updateRowNum || updateRowNum == 0) {
                throw exception(QUEUE_SEQUENCE_TIMESLOT_EXCEPTION);
            }
 
        } else {
            Integer updateRowNum = queueSequenceMapper.updateGivenCheckTypeTimeslotSeqNo(checkType, timeslot, curSeqNo);
            if (null == updateRowNum || updateRowNum == 0) {
                throw exception(QUEUE_SEQUENCE_TIMESLOT_EXCEPTION);
            }
        }
 
        return curSeqNo + 1;
    }
 
    @Override
    @Transactional
    public Integer distributeVipSeqNo(Integer checkType, Integer timeslot) {
        Integer curVipSeqNo = queueSequenceMapper.selectQueueVipNoForUpdate(checkType, timeslot);
        if (null == curVipSeqNo) {
            throw exception(QUEUE_SEQUENCE_TIMESLOT_FULL);
        }
 
        Integer updateRowNum = queueSequenceMapper.updateGivenCheckTypeTimeslotVipSeqNo(checkType, timeslot, curVipSeqNo);
        if (null == updateRowNum || updateRowNum == 0) {
            throw exception(QUEUE_SEQUENCE_TIMESLOT_EXCEPTION);
        }
 
        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()
                .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();
    }
 
}