| | |
| | | |
| | | 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.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; |
| | | |
| | |
| | | */ |
| | | @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(); |
| | | } |
| | |
| | | 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) { |
| | | // 校验存在 |
| | | validateQueueSequenceExists(id); |
| | | QueueSequenceDO exist = queueSequenceMapper.selectById(id); |
| | | if (null == exist) { |
| | | throw exception(QUEUE_SEQUENCE_NOT_EXISTS); |
| | | } |
| | | // 删除 |
| | | queueSequenceMapper.deleteById(id); |
| | | // 【本需求】删除后重排号段(否则后续时段的起点会留空洞) |
| | | recalcSegmentBounds(exist.getCheckType()); |
| | | } |
| | | |
| | | private void validateQueueSequenceExists(Integer id) { |
| | |
| | | } |
| | | |
| | | @Override |
| | | public List<QueueSequenceDO> selectGivenCheckTypeTimeslot(Integer checkType) { |
| | | return queueSequenceMapper.selectTimeslotByCheckType( checkType ); |
| | | } |
| | | |
| | | @Override |
| | | public PageResult<QueueSequenceDO> getQueueSequencePage(QueueSequencePageReqVO pageReqVO) { |
| | | return queueSequenceMapper.selectPage(pageReqVO); |
| | | } |
| | |
| | | |
| | | // 读取时段配置 |
| | | String strBookTimeslotLength = configApi.getConfigValueByKey(BOOK_TIMESLOT_LENGTH); |
| | | String strBookTimeslotList = configApi.getConfigValueByKey(BOOK_TIMESLOT_LIST); |
| | | |
| | | // 方案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 -> { |
| | | List<LocalTime> timeslotList = Utils.parseTimeSlotList(strBookTimeslotList, Integer.valueOf(strBookTimeslotLength)); |
| | | for (int i=0; i < timeslotList.size(); i++) { |
| | | LocalTime timeslot = timeslotList.get(i); |
| | | |
| | | for (int i=0; i < dictBookTimeslotList.size(); i++) { |
| | | QueueSequenceDO queueSequenceDO = new QueueSequenceDO(); |
| | | queueSequenceDO.setCheckType( checkTypeDO.getValue() ); |
| | | queueSequenceDO.setTimeSlot( timeslot.getHour()*100 + timeslot.getMinute() ); |
| | | 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()); |
| | |
| | | }); |
| | | } |
| | | |
| | | @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(); |
| | | } |
| | | |
| | | } |
| | | |